Forgejo: CI watchdog for branches without a PR #146
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#146
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
When CI fails on a monitored branch (e.g.
main,release/*) and the commit is not the head of any open PR, nobody is told. The agent should analyse the failure and report it on Forgejo: comment on an existing open issue about the same failure, or open a new one. When the branch goes green again, the agent says so on that issue.Current state (
apps/server/src):handleCommitStatus(forgejo/dispatcher.ts~1136) settles waiting post-merge records first. Then, once all checks are terminal and one failed, it fans out toci_failed/own_ci_failedrows for open PRs whose head is the SHA. Otherwise it finishesdonewithno open pull request has head <sha7>. Fully green runs finishdropped/all checks passed.repo-config.tsparseCiWatchdog(~164) readsci_watchdog.others_prsandown_prsbut notbranches.webhook.tsclassifyStatus(~298) stores status rows withnumber: 0,isPull: false. It reads onlysha,state,repository, andsender. The code reads no branch field, andDispatchApihas no branch listing.forgejo_create_prresults are mapped to a session mid-turn (bridge/in-process.tsmapImplementerPr~797, hook ~986).Decisions from grilling:
branchesentries are globs only (Bun.Glob:*does not cross/,**does). Absent or empty means disabled.others_prs/own_prsconfig) or if it has a waiting post-merge record.forgejo_ci_watchdog. It has read, CI, and search tools,forgejo_create_issue, andforgejo_create_issue_comment, but cannot write code, git, or labels. It reports on exactly one issue: anywhere until the first create/comment, then only that issue.forgejo_sessions: map the issue the session creates. Map the existing issue it comments on only if no session is mapped to it yet.Plan
Context
Add
ci_watchdog.branchesand handle failed, finished CI on the head of a watched branch when no PR or post-merge record covers it. The analysis runs in a newforgejo_ci_watchdogsession that finds or opens one issue and reports there. A per-branch record lets later failures continue the same session and a later green run post a recovery comment.Approach
Steps 1–3 are independent. Step 4 depends on 1–3, step 5 on 4, and step 6 on 2 and 3.
1. Config:
ci_watchdog.branches(forgejo/repo-config.ts)ciWatchdogBranches: string[]toRepoConfig, defaulting to[]inDEFAULT_REPO_CONFIG.parseCiWatchdog, parsesection.branches:[]."`ci_watchdog.branches` must be a list of non-empty strings".others/ownand wire it throughparseRepoConfig.matchWatchedBranch(patterns: string[], branch: string): number. It returns the index of the first pattern wherenew Bun.Glob(pattern).match(branch)is true, or-1. No glob helper exists in server code;Bun.Globis built in.repo-config.test.ts:[]; a non-list and[""]give the error string.matchWatchedBranch(["main","release/*"], "release/1.2")is1."release/1/x"is-1, and["release/**"]matches it.2. Branch records (new migration + db module; model on
db/forgejo-post-merge.tsand migration 019)db/migrations/020-forgejo-branch-watch.sql(auto-discovered):db/forgejo-branch-watch.ts:interface BranchWatch { id: number; repo: string; branch: string; sessionId: string | null; lastFailedSha: string; issueNumber: number | null; state: "open" | "resolved"; createdAt: string; updatedAt: string }.openBranchWatch(repo, branch, sha): BranchWatch(insert, stateopen).getBranchWatch(id): BranchWatch | null.getOpenBranchWatch(repo, branch): BranchWatch | null.listOpenBranchWatches(repo): BranchWatch[].setBranchWatchSession(id, sessionId).setBranchWatchFailedSha(id, sha).setBranchWatchIssue(id, number): boolean(UPDATE … WHERE id = ? AND issue_number IS NULL; true when a row changed).resolveBranchWatch(id)(stateresolved).updated_at. The repo key is lowercased as in the post-merge module.3. Session kind
forgejo_ci_watchdogbridge/session-policy.ts:"forgejo_ci_watchdog"toSessionTriggerKind.export interface ForgejoCiWatchdogScope { owner: string; repo: string; branch: string; watchId: number }toTriggerScopes.forgejoCiWatchdogPolicytoSESSION_POLICIESand acaseto each of the fiveswitch (trigger.kind)functions (policyToolNames,policyTurnTimeoutMs,decidePolicy,policySystemContext, therepoMismatchswitch).toolNames:CONVERSATION_TOOL_NAMESwithoutforgejo_create_pr_commentandforgejo_create_pr_thread_comment.turnTimeoutMs: TRIGGER_TURN_TIMEOUT_MS.repoMismatch: the shared function.decide(toolName, params, scope):rec = getBranchWatch(scope.watchId);reported = rec?.issueNumber ?? null.forgejo_create_issue_comment: ifreported === null, allow. Otherwise allow onlyparamNumber(params) === reported, and deny everything else with`this CI watchdog reports on #${reported}; comment only there`.forgejo_create_issue: allow ifreported === null. Otherwise deny with`this CI watchdog already reports on #${reported}; comment there instead of opening another issue`.toolNames→"default". Anything else → deny"not available in unattended Forgejo CI watchdog sessions".systemContext(scope):## Unattended Forgejo CI watchdog (${scope.branch}).READ_ACCESS_BULLET;WEB_ACCESS_BULLET;CI_BULLET.forgejo_search_issuesfor a report of the same failure and comment there withforgejo_create_issue_comment, otherwise open one withforgejo_create_issue; afterwards every report goes to that issue."forgejo/checkout.ts:CheckoutKind = "issue" | "pr" | "branch".createCheckoutalready detaches atrefs/remotes/origin/<baseBranch>for non-PR kinds. Callers passnumber: watchId,baseBranch: branch, so the path isbranch-<watchId>.bridge/in-process.tsresume (~357–375):trigger.kind === "forgejo_ci_watchdog", callrestoreCheckoutwithkind: "branch",number: trigger.scope.watchId,baseBranch: trigger.scope.branch.trigger.scope.numberis only read on kinds that have it.bridge/in-process.tstool_execution_endhook (~986): add amapWatchdogIssue(event, trigger.scope, sessionId)call whentoolNameisforgejo_create_issueorforgejo_create_issue_commentandtrigger?.kind === "forgejo_ci_watchdog". Model it onmapImplementerPr:result.details.number. Return whenisErroris set ornumberis not a number.if (setBranchWatchIssue(scope.watchId, number) && getForgejoSession(repo, number) === null) setForgejoSession(repo, number, sessionId).log.warn.4. Fan-out in
handleCommitStatus(forgejo/dispatcher.ts)DispatchApi(forgejo/dispatch-api.ts):listBranches(owner, repo, signal?): Promise<ApiResult<{ name: string; sha: string }[]>>, usingfetchAllPagesonrepoApiPath(owner, repo, "branches").nameand lowercasedcommit.id. Thecommit.idfield name is unverified; confirm againstGET /repos/{owner}/{repo}/brancheson the instance first. If the head SHA lives elsewhere (e.g.commit.sha), map that field instead.FakeApias a settablebrancheslist.watchedHeads(api, owner, repo, patterns, sha): Promise<ApiResult<string[]>>. It returns the names of branches whose head isshaand that match a pattern, ordered by first matching pattern index, then name.handleCommitStatus:const patterns = parsed.config.ciWatchdogBranches; const branches = patterns.length > 0;.&& !branchesto both "CI watchdog not configured" checks (~1165, ~1183).outcomecomputation above the green check.all checks passedreturn):branchesandlistOpenBranchWatches(row.repo)is non-empty, callwatchedHeads. On error,failedwith`could not list branches: ${status} ${message}`, pumping first ifqueued, like the pulls error.sessionId, insert abranch_ci_greenrow.donewith`queued ${n} branch recovery note(s) for ${sha7}`whenn > 0; otherwise keepdropped/all checks passed.othersTargets + ownTargets === 0:branches && atSha.length === 0 && records.length === 0, callwatchedHeads; on errorfailedas above.donewith`no open pull request or watched branch has head ${sha7}`.branch_ci_failedrow and finishdonewith`queued branch CI analysis for ${heads[0]} at ${sha7}`.`no open pull request has head ${sha7}`.deliveryId: row.deliveryId,event: "status",repo: row.repo,number: 0,isPull: false,commentAuthor: row.commentAuthor.payloadJson: JSON.stringify({ sender: { login: row.commentAuthor }, ci: { sha, checks }, watch: { branch, branches: heads } }).dedupeKey:sha256(JSON.stringify(["branch_ci_failed", repo.toLowerCase(), sha, outcome])), one per SHA and run.sha256(JSON.stringify(["branch_ci_green", repo.toLowerCase(), branch, sha])).PayloadFieldswith the newwatch?: { branch?: string; branches?: string[] }field.5. Gating, session resolution and seeds for branch rows (
forgejo/dispatcher.ts,forgejo/conversation-detect.ts)"branch-ci-failed" | "branch-ci-green"toReadyReasonand update its doc comment. Add"branch-ci-green"tocontinueOnly.gate():branchCi = row.action === "branch_ci_failed" || row.action === "branch_ci_green"and a branch calling the newresolveBranchWatchRow(job, payload): Promise<boolean>.reason = row.action === "branch_ci_failed" ? "branch-ci-failed" : "branch-ci-green".!branchCito the permission-check condition (~562).resolveBranchWatchRowfinishes the row and returns false on each failure:this.readRepoConfig(job)(null → return false).ciWatchdogBranches→dropped"CI watchdog not configured for branches".matchWatchedBranch(...) === -1forpayload.watch.branch→dropped`branch ${b} is no longer watched`.listBrancheserror →this.fail(job, \could not list branches: …`, "forgejo", null)`.dropped`branch ${b} no longer exists`.payload.ci.sha→dropped"head moved since CI finished".sessionId→dropped`no open watchdog report for ${b}`.postNoticereturns immediately whenjob.row.number === 0.surfaceFailureskipspostComment(goes straight tofallback) whenrow.number === 0.resolveSession: as its first statement, branch reasons returnthis.resolveBranchWatchSession(ready):rec = getOpenBranchWatch(repo, branch).rec?.sessionId, callreuseMapped(rec.sessionId, ready):branch-ci-failed, callsetBranchWatchFailedSha(rec.id, sha); then return.`the mapped session is unusable: ${reason}`; for failed, callresolveBranchWatch(rec.id)and fall through.sessionId:resolveBranchWatchit and fall through for failed; throw for green."the mapped session is unusable: no session is mapped any more".rec = openBranchWatch(repo, branch, sha).createCheckout({ workspacePath, owner, repo, kind: "branch", number: rec.id, baseBranch: branch }).{ kind: "forgejo_ci_watchdog", scope: { owner, repo, branch, watchId: rec.id } }.bridge.createSessionwith the same placement asresolveSession.resolveBranchWatch(rec.id), remove the checkout if made, and rethrow (same wording asresolveSession).setBranchWatchSession(rec.id, sessionId)andsetName(\CI watchdog: ${repo} ${branch}`)` (warn on failure).{ handle, broughtLive: true, kind, created: true, linkedIssue: null }.setForgejoSession(row.repo, 0, …).reuseMapped(~1477–1483): restructure the scope check soforgejo_ci_watchdogsessions are accepted:scope.branch === payload.watch.branch.forgejo_sessionsmapping vouches for the issue number, as for implementers.scope.numberonly for kinds that have it.runReadyseeds:rec = getOpenBranchWatch(row.repo, payload.watch.branch)afterresolveSession.branch-ci-failed→buildBranchCiFailureSeed;branch-ci-green→buildBranchCiGreenSeed."ok"forbranch-ci-green, callresolveBranchWatch(rec.id).conversation-detect.ts, next tobuildCiFailureSeed. ExtendCiFailurePayloadLikewithwatch?: { branch?: string; branches?: string[] }.issueNumber !== null, useorientationLines(o, { isPull: false, number: issueNumber }). Otherwise useorientationLines({ ...o, fresh: null }, { isPull: false, number: 0 }), which gives only the previous-turn line.buildBranchCiFailureSeed({ repo, payload, issueNumber, orientation })lines:`CI failed on ${repo} branch ${branch} at commit ${sha}`.`The commit is also the head of: ${others.join(", ")}`.`Trigger: every status check of the commit finished and at least one failed; the commit is not the head of an open pull request; the branch matches this repository's .omp/forgejo.yaml ci_watchdog.branches.`."Checks:"+formatCheckLines.issueNumber === null: "Read the failing checks' logs (forgejo_read_commit_status for run ids, then forgejo_list_run_jobs and forgejo_read_job_log) and the relevant code (your checkout is the branch as of when this session started; use forgejo_read_file_at_ref at the commit for exact contents). Search open issues with forgejo_search_issues for an existing report of the same failure. If one exists, comment there with your analysis; otherwise open one issue with forgejo_create_issue whose title names the branch and the failure and whose body has the cause, the failing checks with links, and suggested fixes."issueNumber !== null: "You already report on this branch in #N. Read its latest comments, then read the failing logs as above. If this is the same failure you already reported and nothing new was learned, end without posting; otherwise comment on #N with what changed."buildBranchCiGreenSeed({ repo, payload, issueNumber, orientation }):`CI passes again on ${repo} branch ${branch} at commit ${sha}`, then the orientation lines.6. Docs
ci_watchdog.branchesto the repo-config documentation whereverci_watchdog.others_prsis documented (grep -rn "others_prs" docs README* apps/server/src/templates— unverified location; if none documents it, skip).Critical files & anchors
apps/server/src/forgejo/dispatcher.ts:handleCommitStatus(~1136): fan-out and green handling.gate(~450, permission condition ~562).resolveSession/reuseMapped(~1391/~1465): branch-session path and scope check.postNotice(~1758) /surfaceFailure(~1778): number-0 guard.apps/server/src/bridge/session-policy.ts: the kind union (28),TriggerScopes(70), the five kind switches (~473–546), and the new policy.apps/server/src/bridge/in-process.ts: resumerestoreCheckout(~357) and thetool_execution_endmapping hook (~986).apps/server/src/forgejo/conversation-detect.ts: CI seeds (~485–552).apps/server/src/forgejo/dispatch-api.ts:listBranchesnext tolistOpenPulls(~62/~405).Verification
Run from
apps/server:bun test src/forgejo/repo-config.test.ts src/forgejo/dispatcher.test.ts src/bridge/session-policy.test.ts src/forgejo/checkout.test.ts, then the fullbun testand the repo's typecheck script. Fix the existing session-policy test that assertsforgejo_create_issueis allowed for every trigger, so it reflects the reported-issue rule for the new kind.New dispatcher tests use the existing CI fixtures (~2300–2420:
check,setup,enqueueStatus,run). Use configci_watchdog:\n branches:\n - main\n - release/*\n, and seth.api.branchessomainhas headSHA.branch_ci_failedrow is queued, then a session is created withtrigger.kind === "forgejo_ci_watchdog"andscope.branch === "main".CI failed on lucas/omp-deck branch main.queued branch CI analysis for main at 0123456.forgejo_branch_watchrow is open with that session id.updatedAtqueues a second row, which continues the same session (no new session inh.sessions).SHAis the head of an open PR, or has a waiting post-merge record → nobranch_ci_failedrow.mainhead is a different SHA → reasonno open pull request or watched branch has head 0123456.mainandrelease/1both atSHA→ one row withwatch.branch === "main"andwatch.branches === ["main","release/1"].branches→ unchanged behaviour (existing tests stay green).droppedhead moved since CI finished.branch_ci_greenrow runs in the same session; the prompt containsCI passes again; the record isresolved. A later failure creates a new session.Session-policy tests for
forgejo_ci_watchdog:issue_numbernull, comments on any number andforgejo_create_issueare allowed.setBranchWatchIssue(id, 7), a comment on 7 is allowed, a comment on 8 is denied with the "reports on #7" message, andforgejo_create_issueis denied.forgejo_create_pr_commentis denied.In-process mapping: if practical, cover
mapWatchdogIssuethrough the existing in-process test harness. Otherwise check it with a throwaway script: after a successfulforgejo_create_issueevent withdetails.number = 12,getBranchWatch(id).issueNumber === 12andgetForgejoSession(repo, 12)is the session. With a pre-existing mapping for 12, the mapping is unchanged.Assumptions & contingencies
commit.id(unverified — confirm on the instance before step 4; if it differs, map the actual field).