Forgejo: re-check agent PRs for conflicts when their base branch is pushed #89
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#89
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
Today the user has to tell the agent manually that its pull requests have conflicts. When commits land on a branch that open agent-authored PRs target (e.g. a merge into
main), the server should check those PRs for conflicts and, when one conflicts, run a turn in the PR's implementer session so the agent rebases.Decisions:
push(pushes to a PR's head already arrive aspull_request_sync; pushes/merges to a base branch only arrive aspush). Pushes by the agent's own account are processed too (its merges move the base for other PRs).mergeableonce when the event is handled; anything butfalse→ nothing to do. Onfalse, confirm with a localgit merge-treecheck in the session's checkout; clean → nothing to do.Plan
Approach
Steps in order; each leaves the tree building.
Protocol types —
packages/protocol/src/index.ts,ForgejoTriggerSummary: add"push"toevent, add"pushed" | "base_pushed"toaction, and extend thecommentAuthordoc: "the pusher forpushed/base_pushed".pushed= the repo-level push row (number0,isPullfalse);base_pushed= one per-PR check row derived from it. No migration: the columns are untypedTEXT.Classify
pushdeliveries —apps/server/src/forgejo/webhook.ts:classifyForgejoDelivery, before the review/comment branches,if (eventType === "push") return classifyPush(payload);. (resolveEventTypealready falls back toX-Forgejo-Event, which ispush.)classifyPush(payload): not object → malformed"payload is not an object".refmust be a string starting withrefs/heads/, else{ kind: "ignore", reason: "not a branch push" }.afterequal to 40 zeros (or/^0+$/) → ignore"branch deleted".sender.loginnon-empty string else malformed"missing sender.login"(do NOT ignore the agent as self-authored).repository.full_namevalidated as inclassifyLifecycle(same malformed message). Accept:{ dedupeKey: sha256(JSON.stringify(["pushed", payload])), event: "push", action: "pushed", repo, number: 0, isPull: false, commentAuthor: sender }.Dispatch API: mergeable + open PR listing —
apps/server/src/forgejo/dispatch-api.ts:PullSummarygainsmergeable: boolean | null(RawPull.mergeable?: boolean | null; mapraw.mergeable ?? null) andbaseBranch: string(raw.base?.ref ?? ""; addref?: stringtoRawPull.base).DispatchApi.listOpenPulls(owner, repo, signal?): Promise<ApiResult<OpenPull[]>>withexport interface OpenPull { number: number; title: string; htmlUrl: string; author: string; headBranch: string; headSha: string; headRepo: string; baseBranch: string; body: string }. Implement withfetchAllPages<RawOpenPull>(client, repoApiPath(owner, repo, "pulls"), { signal, query: { state: "open" } })— checkfetchAllPages's option for query params inapps/server/src/forgejo/tools/shared.tsand use its existing mechanism (if it takes a path only, append?state=open).apps/server/src/forgejo/dispatcher.test.ts(pulldefault gainsmergeable: true, baseBranch: "main"; addopenPulls: ApiResult<OpenPull[]> = ok([])andlistOpenPulls()returning it).Fan out a
pushedrow —apps/server/src/forgejo/dispatcher.ts:gate, right after the closed/reopened branch:if (row.action === "pushed") { await this.handleBasePush(row); return null; }.handleBasePush(row)(finishes the row itself, never runs a turn): api/agent missing →finishForgejoTrigger(row.id, "failed", "Forgejo is not configured")and fall back withthis.fallback(row, reason, null)(no thread to comment on: number 0; do NOT callsurfaceFailure, which posts a comment). Parse payload;branch = ref.slice("refs/heads/".length).listOpenPulls; on error → finishfailedwithcould not list pull requests: <status> <message>+fallback. Filterp.baseBranch === branch && p.author.toLowerCase() === agent.toLowerCase(). For each,insertForgejoTrigger({ deliveryId: row.deliveryId, dedupeKey: sha256(JSON.stringify(["base_pushed", row.id, p.number])), event: "push", action: "base_pushed", repo: row.repo, number: p.number, isPull: true, commentAuthor: row.commentAuthor, payloadJson: JSON.stringify(<synthetic>) })(the push row's id is the parent identity;StoredForgejoTriggerexposes no dedupe key). Synthetic payload so existingPayloadreaders work:{ ref, before, after, sender: { login: row.commentAuthor }, pull_request: { number, title, body, html_url, user: { login: author }, head: { ref: headBranch, sha: headSha }, base: { ref: branch } } }(copyref/before/afterfrom the push payload). Finish the push rowdonewith reasonqueued <n> conflict check(s) for <branch>(n may be 0). Thenthis.pump()so the new per-PR keys are drained.artifactKey(repo#0); no change needed.Gate a
base_pushedrow — ingate:const basePushed = row.action === "base_pushed";Add"base-pushed"toReadyReason; setreason = "base-pushed"in the reason chain.basePushedeverywheresynchronizedskips work: author taken frompayload.pull_request.user.login(line ~344 condition), and skip the write-permission check (a push by anyone with push rights to the base only re-checks the agent's own PR). Do NOT add it to the "no session for this pull request" drop: a missing session is created.prHeadblock (already runs becauseisPull && agentPr): after head checks,if (basePushed && pull.data.mergeable !== false) { finishForgejoTrigger(row.id, "dropped", "mergeable per Forgejo"); return null; }. Ifpull.data.baseBranchdiffers from the payload'sbase.ref, drop with"pull request base changed".Run a
base_pushedrow —runReady/resolveSession:resolveSessionalready creates an implementer session whenprHead !== nulland nothing usable is mapped;base_pushedis notpull-synchronized, so creation is allowed. No change there.runReady, addelse if (reason === "base-pushed") seed = await this.baseConflictSeed(ready, handle, created, linkedIssue);. Change the commentA null outcome means pullSyncSeed already failed the row.toA null outcome means the seed builder already finished the row.baseConflictSeed(ready, handle, created, linkedIssue): Promise<string | null>:base = payload.pull_request.base.ref,head = ready.prHead. CallcheckBaseConflict(handle.cwd, { remote: "origin", refspecs: [+refs/heads/${base}:refs/remotes/origin/${base},+refs/heads/${head}:refs/remotes/origin/${head}], base:refs/remotes/origin/${base}, head:refs/remotes/origin/${head}})(import from../bridge/git-tools/base-conflict.ts; same refspec pattern asforgejo_create_prinforgejo/tools/prs.ts). Make it injectable: addcheckBaseConflict?: typeof checkBaseConflicttoForgejoDispatcherDeps(doc: "Local merge check for base pushes. Default: base-conflict.tscheckBaseConflict."), used as(this.deps.checkBaseConflict ?? checkBaseConflict). Result!ok→await this.fail(job,could not check for conflicts: ${error}, "internal", handle.sessionId); return null.files.length === 0→finishForgejoTrigger(row.id, "dropped", "no conflict in a local merge"); return null. Otherwise returnbuildBaseConflictSeed(...).buildBaseConflictSeed(t: { repo: string; number: number; payload: PullSyncPayloadLike; files: string[]; catchUp: boolean; linkedIssue: number | null })inapps/server/src/forgejo/conversation-detect.ts, next tobuildPullSyncSeed. Lines:`${base} moved: your pull request ${repo} #${number} "${title}" now conflicts with it`;`Branch: ${base} ← ${head} · ${html_url}`;`Pushed to ${base} by @${sender}`;Conflicting files:then- <file>per file; whencatchUp, the same catch-up sentence shape asbuildReviewSubmittedSeed(read with forgejo_read_pr / forgejo_list_pr_comments_and_reviews, plus the linked issue; checkout is detached at the PR head, create local branch<head>with git_branch first); final lineTask: ${rebaseInstruction("origin", base)} Post no comment unless the conflict cannot be resolved without input; then explain on the pull request.(importrebaseInstructionfrom base-conflict.ts).finishForgejoTrigger(row.id, "done", reason)already recordsbase-pushedon success.Docs —
docs/configuration.mdline ~231: add Push to the webhook's custom events and one sentence: pushes to a branch re-check the agent's open PRs targeting it for conflicts and prompt the PR's session when one conflicts.Critical files & anchors
apps/server/src/forgejo/webhook.ts—classifyForgejoDelivery, newclassifyPush.apps/server/src/forgejo/dispatcher.ts—gate(~290–401),runReady(~404), newhandleBasePush,baseConflictSeed.apps/server/src/forgejo/dispatch-api.ts—PullSummary,RawPull, newlistOpenPulls.apps/server/src/bridge/git-tools/base-conflict.ts—checkBaseConflict,rebaseInstruction(reuse, unchanged).apps/server/src/forgejo/conversation-detect.ts— newbuildBaseConflictSeed.Verification
Run from
/workspace/omp-deck(checkpackage.jsonfor the exact test script;bun test apps/server/src/forgejoworks for the server package):webhook.test.ts:pushtorefs/heads/mainby the agent → accept{ event: "push", action: "pushed", number: 0, isPull: false, commentAuthor: <agent> };refs/tags/v1→ ignorenot a branch push;afterall zeros → ignorebranch deleted; missingsender.login→ malformed.dispatcher.test.ts(existing fake API/bridge patterns around line 1340):pushedrow withopenPulls= [agent PR #5 basemain, agent PR #6 basedev, human PR #7 basemain] → push rowdonequeued 1 conflict check(s) for main; exactly onebase_pushedrow for #5.base_pushedwithpull.mergeable: true→ droppedmergeable per Forgejo, no turn.mergeable: false, injectedcheckBaseConflict→{ ok: true, files: [] }→ droppedno conflict in a local merge.mergeable: false, files["a.ts"], mapped implementer session → one prompt containinga.tsandgit_rebase; rowdonebase-pushed.checkBaseConflict→{ ok: false }→ rowfailed, failure comment posted.mainthat conflicts with an open agent PR; the deck's Forgejo settings panel shows apush/pushedrowdoneplus abase_pushedrowdone, and the agent's PR session receives the conflict turn.Assumptions & contingencies
mergeablemay readfalsewhile the PR is still being checked right after a push (unverified — confirm against the instance); the local merge-tree check filters those out, so no polling.fetchAllPagescannot pass query params, append?state=opento the path; if Forgejo ignores it, filterstate === "open"client-side as well.