Forgejo: rest of the PR lifecycle (pushes, merge/close, reopen, issue close) #72
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#72
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Finish the pull request lifecycle for sessions mapped to a Forgejo issue or pull request (KB
project/forgejo-integration-plan.mdstep 15; designproject/forgejo-integration.md, sections "PR synchronized", "PR merged / closed", "Session Lifecycle"). Submitted reviews and inline review comments already trigger sessions; this covers the remaining events:synchronized(new commits pushed): inject into the existing PR session. Harness precondition: fetch the updated branch in the session's clone before the turn. Seed: PR id, title, base/head branch, author, new commits (hash + title). Pushes by the agent itself must not trigger its session. No permission check on the pusher: an existing mapped session suffices. No mapped session → nothing (auto-review on push is out of scope, plan steps 18/19).Closes #Nwould archive the implementer through the issue).POST /sessions/:id/archive(apps/server/src/routes.ts): set archived, dispose the live session, remove the clone.Plan
Context
Today
classifyForgejoDelivery(apps/server/src/forgejo/webhook.ts) ignores everypull_requestaction exceptreview_requestedand everyissuesevent, so pushes, merges, closes and reopens never reachForgejoDispatcher(apps/server/src/forgejo/dispatcher.ts), and clones are only removed when a session is archived or deleted in the deck. End state: these events are queued asforgejo_triggersrows like other triggers; the dispatcher runs a seeded turn for pushes and performs archive/unarchive (no turn) for the others.Approach
Steps are ordered so the tree builds and existing tests pass after each.
1. Shared archive helper
apps/server/src/session-archive.tsexporting: Body = the current route body after the existence check:setArchived(id, true);const checkout = getSessionPlacement(id)?.checkoutPath; if set,await bridge.getSession(id)?.dispose()thenawait removeCheckout(checkout). Move the existing comment ("The clone is the session's cwd: …") with it.apps/server/src/routes.tsPOST /sessions/:id/archive: keep the 404 existence check, replace the rest of the body withawait archiveSession(bridge, id). Drop imports that become unused.2. Protocol union
In
packages/protocol/src/index.tsForgejoTriggerSummary(≈ line 2038):eventgains"pull_request_sync"and"issues".actiongains"synchronized" | "closed" | "reopened".commentAuthordoc: "…, the reviewer forreviewed, or the sender (pusher / closer / reopener) forsynchronized,closed,reopened."No web code matches on these literals (
apps/web/srchas noreview_requested/"reviewed"usage), so nothing else changes there.3. Classifier
In
apps/server/src/forgejo/webhook.ts:X-Forgejo-Event: pull_requestwithX-Forgejo-Event-Type: pull_request_sync, other PR actions with Event-Typepull_request, and issue state changes asissues(unverified — confirm against Forgejomodules/webhook/type.goHookEventPullRequestSync/HookEventIssuesbefore coding; if the Event-Type for pushes is different, use that literal in place ofpull_request_synceverywhere in this plan). Accept bothpull_requestandpull_request_syncfor every PR action so the-Event-only fallback ofresolveEventTypealso works.classifyForgejoDelivery: replace the first branch with: ifeventTypeispull_request_review_request,pull_requestorpull_request_sync→ dispatch onpayload.action(after theisPlainObjectcheck):review_requested→ existingclassifyReviewRequest(widen itseventTypeparameter type to include"pull_request_sync");synchronized,closed,reopened→ newclassifyLifecycle(eventType, action, payload, agentUsername, true); anything else →{ kind: "ignore", reason: "unsupported action <action>" }. New branch:eventType === "issues"→ actionsclosed/reopened→classifyLifecycle(…, false), else ignore with the same reason format. Update the module doc comment with these Forgejo facts.classifyLifecycle(eventType, action: "synchronized" | "closed" | "reopened", payload, agentUsername, isPull: boolean): WebhookVerdict:sender.loginrequired (malformed"missing sender.login"otherwise).synchronized: sender equal to the agent (case-insensitive) →{ kind: "ignore", reason: "self-authored" }. Close/reopen by the agent are NOT ignored (the agent may merge or close).repository.full_namevalidated exactly as the other classifiers. Number: PR →payload.number ?? payload.pull_request.number; issue →payload.issue.number; same integer/positive validation and error strings (missing or invalid pull_request.number/missing or invalid issue.number).dedupeKey= sha256 ofJSON.stringify([action, payload])(whole-body hash, same convention and comment as review requests),event: eventType,action,repo,number,isPull,commentAuthor: sender.4. Checkout refresh for pushes
In
apps/server/src/forgejo/checkout.ts:gitclosure ofcreateCheckoutinto a module-privaterunChecked(cwd: string, args: string[], step: string, ssh: …, network = false): Promise<string>(same behaviour: network calls getNETWORK_TIMEOUT_MSand the deck SSH env, failures throwwithHostKeyHint(gitErrorText(…))), and use it increateCheckout.prepareDeckSsh()as increateCheckout(throw its error if not ok).old = rev-parse HEAD;fetch origin refs/pull/<n>/head(network);new = rev-parse FETCH_HEAD; ifstatus --porcelainoutput is empty →checkout --detach FETCH_HEAD,movedHead = true, elsemovedHead = false.old = rev-parse --verify -q refs/remotes/origin/<headBranch>run viarunGitdirectly (non-zero exit →old = null, not an error);fetch --prune origin(network);new = rev-parse refs/remotes/origin/<headBranch>;movedHead = false.old === null→ null; elselog --reverse --format=%H%x09%s <old>..<new>parsed into{ sha, title }per non-empty line (split on the first tab).5. Dispatcher: lifecycle (archive / unarchive, no turn)
In
apps/server/src/forgejo/dispatcher.ts:archiveSession?: typeof archiveSession(default: the step-1 helper) andrefreshPullCheckout?: typeof refreshPullCheckout(default: step 4), mirroring the existingcreateCheckout?override. ExtendbridgePick only if needed (getSessionis already there).gate: as the very first statement, ifrow.action === "closed" || row.action === "reopened"→await this.handleLifecycle(row); return null;(before the Forgejo-config check: these need no API).handleLifecycle(row: StoredForgejoTrigger): Promise<void>; every path finishes the row:mapped = getForgejoSession(row.repo, row.number); null →finishForgejoTrigger(row.id, "dropped", "no session is mapped to this artifact").trigger = getSessionTrigger(mapped)(catch → null); null → dropped"the mapped session has no session trigger".reopened: ifgetArchivedMap([mapped]).has(mapped)→setArchived(mapped, false), done"session unarchived"; else done"session already active".closed, issue (!row.isPull): iftrigger.kind === "forgejo_implementer"and it has a PR —trigger.scope.pull !== undefined || listForgejoSessionNumbers(row.repo, mapped).some((n) => n !== row.number)→ dropped"an implementer session with a pull request is archived by its pull request". Otherwise archive (below), done"archived: issue closed".closed, PR: archive, done"archived: merged"whenpayload.pull_request?.merged === true, else"archived: closed without merge". This applies to every session kind, including the agent's own PR (implementer), until post-merge CI (plan step 20) replaces it.await this.waitUntilIdle(mapped, timeoutMs)withtimeoutMs = this.deps.turnTimeoutMs ?? SESSION_POLICIES[trigger.kind].turnTimeoutMs, thenawait (this.deps.archiveSession ?? archiveSession)(this.deps.bridge, mapped).gateSafely→failInternal(existing behaviour).waitUntilIdle(sessionId: string, timeoutMs: number): Promise<void>:handle = this.deps.bridge.getSession(sessionId); if absent or!handle.isStreamingNow()resolve immediately; else subscribe and resolve on a terminalagent_endonce!handle.isStreamingNow()(samesetTimeout(…, 0)re-check asrunTurn), or aftertimeoutMs; always unsubscribe and clear the timer. Archive proceeds regardless of which fired.6. Dispatcher: push turn (
synchronized)ReadyReasongains"pull-synchronized".Payloadgetspull_request?.merged?: booleanvia a newPullSyncPayloadLike(step 7) intersection.gate, withconst synchronized = row.action === "synchronized":synchronized→"pull-synchronized"(nodetect).getForgejoSession(row.repo, row.number) === null→ dropped"no session for this pull request".author: usepayload.pull_request?.user?.loginfor synchronized too (add it to thereviewRequest || reviewSubmittedcondition).checkPermissionblock inif (!synchronized)).readPull/prHeadblock are unchanged (they then supplyprHeadfor implementer sessions).resolveSession: whenready.reason === "pull-synchronized"andreuseMappedreturns a string (or mapping vanished), thrownew Error(\the mapped session is unusable: ${reason}`)` instead of creating a new session.runReady, synchronized branch (before building the seed): call(this.deps.refreshPullCheckout ?? refreshPullCheckout)(handle.cwd, { number: row.number, headBranch: payload.pull_request?.head?.ref ?? ready.prHead ?? "", mode: kind === "forgejo_implementer" ? "branch" : "detached" }). EmptyheadBranchin branch mode →await this.fail(job, "the pull request has no head branch", "forgejo", handle.sessionId)and return (dispose as thefinallyalready does — place the refresh inside the existingtrywhosefinallydisposes, i.e. restructure so refresh + turn share it). A thrown git error →await this.fail(job, \could not update the checkout: ${msg}`, "internal", handle.sessionId). Thenseed = buildPullSyncSeed({ repo: row.repo, number: row.number, payload, commits, movedHead, implementer: kind === "forgejo_implementer" }). Row finisheddonewith reason"pull-synchronized"` on success (existing path).7. Push seed
In
apps/server/src/forgejo/conversation-detect.ts:export interface PullSyncPayloadLike extends ReviewRequestPayloadLike { pull_request?: ReviewRequestPayloadLike["pull_request"] & { merged?: boolean } }.export function buildPullSyncSeed(t: { repo: string; number: number; payload: PullSyncPayloadLike; commits: { sha: string; title: string }[] | null; movedHead: boolean; implementer: boolean }): string— lines joined by\n:New commits pushed to ${repo} PR #${n}: "${title ?? "?"}"Author: @${pr.user.login ?? "?"} · ${base ?? "?"} ← ${head ?? "?"} (${head.sha?.slice(0,7) ?? "?"}) · ${html_url ?? "?"}Pushed by @${sender ?? "?"}New commits:then one- ${sha.slice(0, 7)} ${title}per commit;commits === null→- unknown: the previous head is not known in this checkout; empty list →- none found in the checkout (it may have been re-created); compare against your last review.!implementer):${movedHead ? "The checkout is detached at the new head." : "The checkout has local changes, so HEAD was not moved; the new head is FETCH_HEAD."} Task: continue your review of this pull request: inspect what the new commits change and post a review with forgejo_post_review covering them. Post nothing if they don't affect your review.— implementer:Someone else pushed to your pull request branch. Task: integrate origin/${head} into your local branch ${head} (git_rebase action:"start" onto:"origin/${head}") before committing further, so your next git_push doesn't conflict. Reply on the pull request only if the push needs a response.Critical files & anchors
apps/server/src/forgejo/webhook.ts—classifyForgejoDeliveryfirst branch (≈ line 84): routes allpull_requestevents toclassifyReviewRequesttoday.apps/server/src/forgejo/dispatcher.ts—gate(≈ 271),runReady(≈ 369),resolveSession(≈ 439),isUsableTriggerSession(≈ 780).apps/server/src/bridge/session-policy.ts—implementerPrNumbers(≈ 258) shows how an implementer's PRs are derived (scope.pull + mapped numbers); step 5.4 mirrors it.apps/server/src/bridge/in-process.tsresumeSession(≈ 320) —restoreCheckoutrecreates a removed clone on resume, which is what makes "unarchive only" on reopen sufficient.Verification
Run from
apps/server(existing test command for the package; same as for the current*.test.ts).webhook.test.ts, new cases with the existingclassify(overrides)factory:pull_request_sync, actionsynchronized, senderalice→ accept,action: "synchronized",isPull: true,commentAuthor: "alice"; same with senderdeck-bot→ ignore"self-authored".pull_requestactionclosedsenderdeck-bot→ accept (not self-ignored).issuesactionclosed→ accept withisPull: false, number fromissue.number;issuesactionopened→ ignore"unsupported action opened".pull_requestactionedited→ ignore"unsupported action edited".dispatcher.test.ts, newdescribe("ForgejoDispatcher lifecycle")usingbootDb,harness,insertSessionTrigger,setForgejoSession, a fakearchiveSessionand fakerefreshPullCheckoutdep recording calls:merged: trueon a mappedforgejo_pull_requestsession → archive called with that id, rowdone"archived: merged", no prompt."archived: closed without merge".droppedwith the step-5.4 reason; issue closed on aforgejo_conversationsession → archived.getArchivedMapno longer has it, rowdone"session unarchived", no prompt.dropped"no session is mapped to this artifact".FakeHandleis streaming: archive not called until the handle emits terminalagent_end(use the existing manual-mode harness), then called.synchronizedon a mapped reviewer session → refresh called withmode: "detached", one prompt whose text containsNew commits pushed to lucas/omp-deck PR #7and- abc1234 Fix x(fake returns[{ sha: "abc1234…", title: "Fix x" }]), and nocheckPermissioncall; on the agent's own PR mapped to an implementer session →mode: "branch"and seed containsintegrate origin/<head>; unmapped →dropped"no session for this pull request", no session created.checkout.test.ts(follow its real-git fixture pattern):refreshPullCheckoutindetachedmode on a clone whose remote gained a commit onrefs/pull/<n>/head→ returns that commit and moves HEAD; with a dirty file →movedHead: false, HEAD unchanged.<dataDir>/checkouts/<owner>/<repo>/pr-<n>is gone; reopen a closed PR → session unarchived.Assumptions & contingencies
pull_request_sync, substitute the real literal (step 3); ifpull_request.mergedis absent on closed payloads, usepayload.pull_request.merged_at != nullinstead (unverified — confirm first).