Forgejo: CI watchdog for branches without a PR #146

Closed
opened 2026-10-29 13:24:23 +01:00 by omp-deck · 0 comments
Collaborator

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 to ci_failed / own_ci_failed rows for open PRs whose head is the SHA. Otherwise it finishes done with no open pull request has head <sha7>. Fully green runs finish dropped/all checks passed.
  • repo-config.ts parseCiWatchdog (~164) reads ci_watchdog.others_prs and own_prs but not branches.
  • webhook.ts classifyStatus (~298) stores status rows with number: 0, isPull: false. It reads only sha, state, repository, and sender. The code reads no branch field, and DispatchApi has no branch listing.
  • Only forgejo_create_pr results are mapped to a session mid-turn (bridge/in-process.ts mapImplementerPr ~797, hook ~986).

Decisions from grilling:

  • branches entries are globs only (Bun.Glob: * does not cross /, ** does). Absent or empty means disabled.
  • A commit maps to a branch only if it is that branch's current head. Older commits are dropped.
  • Skip a commit if it is the head of any open PR (any author, whatever the others_prs/own_prs config) or if it has a waiting post-merge record.
  • If the SHA is the head of several matching branches, run one analysis for the first match in config order. The seed lists all matching branches.
  • The new session kind is forgejo_ci_watchdog. It has read, CI, and search tools, forgejo_create_issue, and forgejo_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.
  • Issue mapping in forgejo_sessions: map the issue the session creates. Map the existing issue it comments on only if no session is mapped to it yet.
  • Each branch has a record (repo, branch, session, last failed SHA, reported issue, open/resolved). A new failure while the record is open continues that session. A fully green run at the head of a branch with an open record resumes the session once ("branch is green"). The session comments on the issue but does not close it, and the record becomes resolved. The next failure after that starts a new session.

Plan

Context

Add ci_watchdog.branches and 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 new forgejo_ci_watchdog session 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)

  • Add ciWatchdogBranches: string[] to RepoConfig, defaulting to [] in DEFAULT_REPO_CONFIG.
  • In parseCiWatchdog, parse section.branches:
    • Absent → [].
    • Anything other than an array of non-empty strings fails with exactly "`ci_watchdog.branches` must be a list of non-empty strings".
    • Return it alongside others/own and wire it through parseRepoConfig.
  • Export matchWatchedBranch(patterns: string[], branch: string): number. It returns the index of the first pattern where new Bun.Glob(pattern).match(branch) is true, or -1. No glob helper exists in server code; Bun.Glob is built in.
  • Tests in repo-config.test.ts:
    • The list is read; absent gives []; a non-list and [""] give the error string.
    • matchWatchedBranch(["main","release/*"], "release/1.2") is 1.
    • "release/1/x" is -1, and ["release/**"] matches it.

2. Branch records (new migration + db module; model on db/forgejo-post-merge.ts and migration 019)

  • db/migrations/020-forgejo-branch-watch.sql (auto-discovered):
    CREATE TABLE forgejo_branch_watch (
      id              INTEGER PRIMARY KEY AUTOINCREMENT,
      repo_key        TEXT NOT NULL,      -- lowercased owner/repo
      branch          TEXT NOT NULL,
      session_id      TEXT,               -- null until the session is created
      last_failed_sha TEXT NOT NULL,      -- lowercased 40-hex
      issue_number    INTEGER,            -- the one issue the session reports on; null until it posts
      state           TEXT NOT NULL CHECK (state IN ('open','resolved')),
      created_at      TEXT NOT NULL,
      updated_at      TEXT NOT NULL
    );
    CREATE UNIQUE INDEX idx_forgejo_branch_watch_open ON forgejo_branch_watch (repo_key, branch) WHERE state = 'open';
    
  • 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 }.
    • Functions:
      • openBranchWatch(repo, branch, sha): BranchWatch (insert, state open).
      • 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) (state resolved).
    • Every write bumps updated_at. The repo key is lowercased as in the post-merge module.

3. Session kind forgejo_ci_watchdog

  • bridge/session-policy.ts:
    • Add "forgejo_ci_watchdog" to SessionTriggerKind.
    • Add export interface ForgejoCiWatchdogScope { owner: string; repo: string; branch: string; watchId: number } to TriggerScopes.
    • Add forgejoCiWatchdogPolicy to SESSION_POLICIES and a case to each of the five switch (trigger.kind) functions (policyToolNames, policyTurnTimeoutMs, decidePolicy, policySystemContext, the repoMismatch switch).
    • Policy:
      • toolNames: CONVERSATION_TOOL_NAMES without forgejo_create_pr_comment and forgejo_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: if reported === null, allow. Otherwise allow only paramNumber(params) === reported, and deny everything else with `this CI watchdog reports on #${reported}; comment only there`.
      • forgejo_create_issue: allow if reported === null. Otherwise deny with `this CI watchdog already reports on #${reported}; comment there instead of opening another issue`.
      • Any other tool in toolNames → "default". Anything else → deny "not available in unattended Forgejo CI watchdog sessions".
    • systemContext(scope):
      • Heading: ## Unattended Forgejo CI watchdog (${scope.branch}).
      • Bullets: nobody watches the chat and the only output is a Forgejo issue or comment; READ_ACCESS_BULLET; WEB_ACCESS_BULLET; CI_BULLET.
      • "Report on exactly one issue: search open issues with forgejo_search_issues for a report of the same failure and comment there with forgejo_create_issue_comment, otherwise open one with forgejo_create_issue; afterwards every report goes to that issue."
      • "You cannot change code, branches, labels or issue state."
      • The same untrusted-data bullet as the conversation policy.
  • forgejo/checkout.ts:
    • CheckoutKind = "issue" | "pr" | "branch".
    • createCheckout already detaches at refs/remotes/origin/<baseBranch> for non-PR kinds. Callers pass number: watchId, baseBranch: branch, so the path is branch-<watchId>.
  • bridge/in-process.ts resume (~357–375):
    • For trigger.kind === "forgejo_ci_watchdog", call restoreCheckout with kind: "branch", number: trigger.scope.watchId, baseBranch: trigger.scope.branch.
    • Keep the existing logic for the other kinds, restructured so trigger.scope.number is only read on kinds that have it.
  • bridge/in-process.ts tool_execution_end hook (~986): add a mapWatchdogIssue(event, trigger.scope, sessionId) call when toolName is forgejo_create_issue or forgejo_create_issue_comment and trigger?.kind === "forgejo_ci_watchdog". Model it on mapImplementerPr:
    • Read result.details.number. Return when isError is set or number is not a number.
    • Call if (setBranchWatchIssue(scope.watchId, number) && getForgejoSession(repo, number) === null) setForgejoSession(repo, number, sessionId).
    • Wrap it in try/catch with a log.warn.

4. Fan-out in handleCommitStatus (forgejo/dispatcher.ts)

  • DispatchApi (forgejo/dispatch-api.ts):
    • Add listBranches(owner, repo, signal?): Promise<ApiResult<{ name: string; sha: string }[]>>, using fetchAllPages on repoApiPath(owner, repo, "branches").
    • Map name and lowercased commit.id. The commit.id field name is unverified; confirm against GET /repos/{owner}/{repo}/branches on the instance first. If the head SHA lives elsewhere (e.g. commit.sha), map that field instead.
    • Add the method to the dispatcher test FakeApi as a settable branches list.
  • Add a private helper watchedHeads(api, owner, repo, patterns, sha): Promise<ApiResult<string[]>>. It returns the names of branches whose head is sha and that match a pattern, ordered by first matching pattern index, then name.
  • Changes in handleCommitStatus:
    • const patterns = parsed.config.ciWatchdogBranches; const branches = patterns.length > 0;.
    • Add && !branches to both "CI watchdog not configured" checks (~1165, ~1183).
    • Hoist the outcome computation above the green check.
    • Green (replaces the bare all checks passed return):
      • If branches and listOpenBranchWatches(row.repo) is non-empty, call watchedHeads. On error, failed with `could not list branches: ${status} ${message}`, pumping first if queued, like the pulls error.
      • For each head branch with an open record that has a sessionId, insert a branch_ci_green row.
      • Finish done with `queued ${n} branch recovery note(s) for ${sha7}` when n > 0; otherwise keep dropped/all checks passed.
    • Red: when othersTargets + ownTargets === 0:
      • If branches && atSha.length === 0 && records.length === 0, call watchedHeads; on error failed as above.
      • If no branch matches, finish done with `no open pull request or watched branch has head ${sha7}`.
      • Otherwise insert one branch_ci_failed row and finish done with `queued branch CI analysis for ${heads[0]} at ${sha7}`.
      • In every other case keep `no open pull request has head ${sha7}`.
    • Row shape for both actions:
      • Fields: 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:
        • Failed: sha256(JSON.stringify(["branch_ci_failed", repo.toLowerCase(), sha, outcome])), one per SHA and run.
        • Green: sha256(JSON.stringify(["branch_ci_green", repo.toLowerCase(), branch, sha])).
    • Update the method's doc comment to cover branch rows.
  • Doc-comment PayloadFields with the new watch?: { branch?: string; branches?: string[] } field.

5. Gating, session resolution and seeds for branch rows (forgejo/dispatcher.ts, forgejo/conversation-detect.ts)

  • Add "branch-ci-failed" | "branch-ci-green" to ReadyReason and update its doc comment. Add "branch-ci-green" to continueOnly.
  • gate():
    • Add branchCi = row.action === "branch_ci_failed" || row.action === "branch_ci_green" and a branch calling the new resolveBranchWatchRow(job, payload): Promise<boolean>.
    • Set reason = row.action === "branch_ci_failed" ? "branch-ci-failed" : "branch-ci-green".
    • Add !branchCi to the permission-check condition (~562).
    • resolveBranchWatchRow finishes the row and returns false on each failure:
      • Use this.readRepoConfig(job) (null → return false).
      • Empty ciWatchdogBranches → dropped "CI watchdog not configured for branches".
      • matchWatchedBranch(...) === -1 for payload.watch.branch → dropped `branch ${b} is no longer watched`.
      • listBranches error → this.fail(job, \could not list branches: …`, "forgejo", null)`.
      • Branch missing → dropped `branch ${b} no longer exists`.
      • Head ≠ payload.ci.sha → dropped "head moved since CI finished".
      • For green: no open record with a sessionId → dropped `no open watchdog report for ${b}`.
  • Number-0 rows have no thread:
    • postNotice returns immediately when job.row.number === 0.
    • surfaceFailure skips postComment (goes straight to fallback) when row.number === 0.
  • resolveSession: as its first statement, branch reasons return this.resolveBranchWatchSession(ready):
    • Look up rec = getOpenBranchWatch(repo, branch).
    • If rec?.sessionId, call reuseMapped(rec.sessionId, ready):
      • Success: for branch-ci-failed, call setBranchWatchFailedSha(rec.id, sha); then return.
      • Unusable: for green, throw `the mapped session is unusable: ${reason}`; for failed, call resolveBranchWatch(rec.id) and fall through.
    • A record without sessionId: resolveBranchWatch it and fall through for failed; throw for green.
    • No record and green: throw "the mapped session is unusable: no session is mapped any more".
    • Create:
      • rec = openBranchWatch(repo, branch, sha).
      • createCheckout({ workspacePath, owner, repo, kind: "branch", number: rec.id, baseBranch: branch }).
      • Trigger { kind: "forgejo_ci_watchdog", scope: { owner, repo, branch, watchId: rec.id } }.
      • Call bridge.createSession with the same placement as resolveSession.
      • On checkout or session failure, resolveBranchWatch(rec.id), remove the checkout if made, and rethrow (same wording as resolveSession).
      • Then setBranchWatchSession(rec.id, sessionId) and setName(\CI watchdog: ${repo} ${branch}`)` (warn on failure).
      • Return { handle, broughtLive: true, kind, created: true, linkedIssue: null }.
      • Never call setForgejoSession(row.repo, 0, …).
  • reuseMapped (~1477–1483): restructure the scope check so forgejo_ci_watchdog sessions are accepted:
    • For branch reasons when scope.branch === payload.watch.branch.
    • For any other reason when same repo, because the forgejo_sessions mapping vouches for the issue number, as for implementers.
    • Read scope.number only for kinds that have it.
    • Replies on the reported issue then continue the watchdog session through the normal conversation path.
  • runReady seeds:
    • Read rec = getOpenBranchWatch(row.repo, payload.watch.branch) after resolveSession.
    • branch-ci-failed → buildBranchCiFailureSeed; branch-ci-green → buildBranchCiGreenSeed.
    • On outcome "ok" for branch-ci-green, call resolveBranchWatch(rec.id).
  • New exports in conversation-detect.ts, next to buildCiFailureSeed. Extend CiFailurePayloadLike with watch?: { branch?: string; branches?: string[] }.
    • Orientation: if issueNumber !== null, use orientationLines(o, { isPull: false, number: issueNumber }). Otherwise use orientationLines({ ...o, fresh: null }, { isPull: false, number: 0 }), which gives only the previous-turn line.
    • buildBranchCiFailureSeed({ repo, payload, issueNumber, orientation }) lines:
      1. `CI failed on ${repo} branch ${branch} at commit ${sha}`.
      2. If the payload lists more than one branch: `The commit is also the head of: ${others.join(", ")}`.
      3. `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.`.
      4. "Checks:" + formatCheckLines.
      5. Orientation lines.
      6. Task (as below).
    • Task when 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."
    • Task when 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 }):
      • Line `CI passes again on ${repo} branch ${branch} at commit ${sha}`, then the orientation lines.
      • Task with an issue: "Comment on #N that every check on passes at . Do not close the issue."
      • Task without an issue: "You never reported on an issue for this branch; end without posting."

6. Docs

  • Add ci_watchdog.branches to the repo-config documentation wherever ci_watchdog.others_prs is 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: resume restoreCheckout (~357) and the tool_execution_end mapping hook (~986).
  • apps/server/src/forgejo/conversation-detect.ts: CI seeds (~485–552).
  • apps/server/src/forgejo/dispatch-api.ts: listBranches next to listOpenPulls (~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 full bun test and the repo's typecheck script. Fix the existing session-policy test that asserts forgejo_create_issue is 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 config ci_watchdog:\n branches:\n - main\n - release/*\n, and set h.api.branches so main has head SHA.

  • Failure, no open PRs, no post-merge record:
    • Exactly one branch_ci_failed row is queued, then a session is created with trigger.kind === "forgejo_ci_watchdog" and scope.branch === "main".
    • The first prompt contains CI failed on lucas/omp-deck branch main.
    • The status row reason is queued branch CI analysis for main at 0123456.
    • A forgejo_branch_watch row is open with that session id.
  • The same finished run delivered twice queues one row. A re-run with a new updatedAt queues a second row, which continues the same session (no new session in h.sessions).
  • SHA is the head of an open PR, or has a waiting post-merge record → no branch_ci_failed row.
  • main head is a different SHA → reason no open pull request or watched branch has head 0123456.
  • main and release/1 both at SHA → one row with watch.branch === "main" and watch.branches === ["main","release/1"].
  • Config without branches → unchanged behaviour (existing tests stay green).
  • Head moves between queueing and gating → row dropped head moved since CI finished.
  • Green after an open record → a branch_ci_green row runs in the same session; the prompt contains CI passes again; the record is resolved. A later failure creates a new session.

Session-policy tests for forgejo_ci_watchdog:

  • With issue_number null, comments on any number and forgejo_create_issue are allowed.
  • After setBranchWatchIssue(id, 7), a comment on 7 is allowed, a comment on 8 is denied with the "reports on #7" message, and forgejo_create_issue is denied.
  • forgejo_create_pr_comment is denied.

In-process mapping: if practical, cover mapWatchdogIssue through the existing in-process test harness. Otherwise check it with a throwaway script: after a successful forgejo_create_issue event with details.number = 12, getBranchWatch(id).issueNumber === 12 and getForgejoSession(repo, 12) is the session. With a pre-existing mapping for 12, the mapping is unchanged.

Assumptions & contingencies

  • The Forgejo branches API exposes each branch's head SHA as commit.id (unverified — confirm on the instance before step 4; if it differs, map the actual field).
## 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 to `ci_failed` / `own_ci_failed` rows for open PRs whose head is the SHA. Otherwise it finishes `done` with `no open pull request has head <sha7>`. Fully green runs finish `dropped`/`all checks passed`. - `repo-config.ts` `parseCiWatchdog` (~164) reads `ci_watchdog.others_prs` and `own_prs` but not `branches`. - `webhook.ts` `classifyStatus` (~298) stores status rows with `number: 0`, `isPull: false`. It reads only `sha`, `state`, `repository`, and `sender`. The code reads no branch field, and `DispatchApi` has no branch listing. - Only `forgejo_create_pr` results are mapped to a session mid-turn (`bridge/in-process.ts` `mapImplementerPr` ~797, hook ~986). Decisions from grilling: - `branches` entries are globs only (`Bun.Glob`: `*` does not cross `/`, `**` does). Absent or empty means disabled. - A commit maps to a branch only if it is that branch's **current head**. Older commits are dropped. - Skip a commit if it is the head of any open PR (any author, whatever the `others_prs`/`own_prs` config) or if it has a waiting post-merge record. - If the SHA is the head of several matching branches, run **one** analysis for the first match in config order. The seed lists all matching branches. - The new session kind is `forgejo_ci_watchdog`. It has read, CI, and search tools, `forgejo_create_issue`, and `forgejo_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. - Issue mapping in `forgejo_sessions`: map the issue the session creates. Map the existing issue it comments on only if no session is mapped to it yet. - Each branch has a record (repo, branch, session, last failed SHA, reported issue, open/resolved). A new failure while the record is open continues that session. A fully green run at the head of a branch with an open record resumes the session once ("branch is green"). The session comments on the issue but does not close it, and the record becomes resolved. The next failure after that starts a new session. ## Plan ### Context Add `ci_watchdog.branches` and 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 new `forgejo_ci_watchdog` session 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`) - Add `ciWatchdogBranches: string[]` to `RepoConfig`, defaulting to `[]` in `DEFAULT_REPO_CONFIG`. - In `parseCiWatchdog`, parse `section.branches`: - Absent → `[]`. - Anything other than an array of non-empty strings fails with exactly ``"`ci_watchdog.branches` must be a list of non-empty strings"``. - Return it alongside `others`/`own` and wire it through `parseRepoConfig`. - Export `matchWatchedBranch(patterns: string[], branch: string): number`. It returns the index of the first pattern where `new Bun.Glob(pattern).match(branch)` is true, or `-1`. No glob helper exists in server code; `Bun.Glob` is built in. - Tests in `repo-config.test.ts`: - The list is read; absent gives `[]`; a non-list and `[""]` give the error string. - `matchWatchedBranch(["main","release/*"], "release/1.2")` is `1`. - `"release/1/x"` is `-1`, and `["release/**"]` matches it. **2. Branch records** (new migration + db module; model on `db/forgejo-post-merge.ts` and migration 019) - `db/migrations/020-forgejo-branch-watch.sql` (auto-discovered): ```sql CREATE TABLE forgejo_branch_watch ( id INTEGER PRIMARY KEY AUTOINCREMENT, repo_key TEXT NOT NULL, -- lowercased owner/repo branch TEXT NOT NULL, session_id TEXT, -- null until the session is created last_failed_sha TEXT NOT NULL, -- lowercased 40-hex issue_number INTEGER, -- the one issue the session reports on; null until it posts state TEXT NOT NULL CHECK (state IN ('open','resolved')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE UNIQUE INDEX idx_forgejo_branch_watch_open ON forgejo_branch_watch (repo_key, branch) WHERE state = 'open'; ``` - `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 }`. - Functions: - `openBranchWatch(repo, branch, sha): BranchWatch` (insert, state `open`). - `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)` (state `resolved`). - Every write bumps `updated_at`. The repo key is lowercased as in the post-merge module. **3. Session kind `forgejo_ci_watchdog`** - `bridge/session-policy.ts`: - Add `"forgejo_ci_watchdog"` to `SessionTriggerKind`. - Add `export interface ForgejoCiWatchdogScope { owner: string; repo: string; branch: string; watchId: number }` to `TriggerScopes`. - Add `forgejoCiWatchdogPolicy` to `SESSION_POLICIES` and a `case` to each of the five `switch (trigger.kind)` functions (`policyToolNames`, `policyTurnTimeoutMs`, `decidePolicy`, `policySystemContext`, the `repoMismatch` switch). - Policy: - `toolNames`: `CONVERSATION_TOOL_NAMES` without `forgejo_create_pr_comment` and `forgejo_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`: if `reported === null`, allow. Otherwise allow only `paramNumber(params) === reported`, and deny everything else with `` `this CI watchdog reports on #${reported}; comment only there` ``. - `forgejo_create_issue`: allow if `reported === null`. Otherwise deny with `` `this CI watchdog already reports on #${reported}; comment there instead of opening another issue` ``. - Any other tool in `toolNames` → `"default"`. Anything else → deny `"not available in unattended Forgejo CI watchdog sessions"`. - `systemContext(scope)`: - Heading: `## Unattended Forgejo CI watchdog (${scope.branch})`. - Bullets: nobody watches the chat and the only output is a Forgejo issue or comment; `READ_ACCESS_BULLET`; `WEB_ACCESS_BULLET`; `CI_BULLET`. - "Report on exactly one issue: search open issues with `forgejo_search_issues` for a report of the same failure and comment there with `forgejo_create_issue_comment`, otherwise open one with `forgejo_create_issue`; afterwards every report goes to that issue." - "You cannot change code, branches, labels or issue state." - The same untrusted-data bullet as the conversation policy. - `forgejo/checkout.ts`: - `CheckoutKind = "issue" | "pr" | "branch"`. - `createCheckout` already detaches at `refs/remotes/origin/<baseBranch>` for non-PR kinds. Callers pass `number: watchId`, `baseBranch: branch`, so the path is `branch-<watchId>`. - `bridge/in-process.ts` resume (~357–375): - For `trigger.kind === "forgejo_ci_watchdog"`, call `restoreCheckout` with `kind: "branch"`, `number: trigger.scope.watchId`, `baseBranch: trigger.scope.branch`. - Keep the existing logic for the other kinds, restructured so `trigger.scope.number` is only read on kinds that have it. - `bridge/in-process.ts` `tool_execution_end` hook (~986): add a `mapWatchdogIssue(event, trigger.scope, sessionId)` call when `toolName` is `forgejo_create_issue` or `forgejo_create_issue_comment` and `trigger?.kind === "forgejo_ci_watchdog"`. Model it on `mapImplementerPr`: - Read `result.details.number`. Return when `isError` is set or `number` is not a number. - Call `if (setBranchWatchIssue(scope.watchId, number) && getForgejoSession(repo, number) === null) setForgejoSession(repo, number, sessionId)`. - Wrap it in try/catch with a `log.warn`. **4. Fan-out in `handleCommitStatus`** (`forgejo/dispatcher.ts`) - `DispatchApi` (`forgejo/dispatch-api.ts`): - Add `listBranches(owner, repo, signal?): Promise<ApiResult<{ name: string; sha: string }[]>>`, using `fetchAllPages` on `repoApiPath(owner, repo, "branches")`. - Map `name` and lowercased `commit.id`. The `commit.id` field name is unverified; confirm against `GET /repos/{owner}/{repo}/branches` on the instance first. If the head SHA lives elsewhere (e.g. `commit.sha`), map that field instead. - Add the method to the dispatcher test `FakeApi` as a settable `branches` list. - Add a private helper `watchedHeads(api, owner, repo, patterns, sha): Promise<ApiResult<string[]>>`. It returns the names of branches whose head is `sha` and that match a pattern, ordered by first matching pattern index, then name. - Changes in `handleCommitStatus`: - `const patterns = parsed.config.ciWatchdogBranches; const branches = patterns.length > 0;`. - Add `&& !branches` to both "CI watchdog not configured" checks (~1165, ~1183). - Hoist the `outcome` computation above the green check. - **Green** (replaces the bare `all checks passed` return): - If `branches` and `listOpenBranchWatches(row.repo)` is non-empty, call `watchedHeads`. On error, `failed` with `` `could not list branches: ${status} ${message}` ``, pumping first if `queued`, like the pulls error. - For each head branch with an open record that has a `sessionId`, insert a `branch_ci_green` row. - Finish `done` with `` `queued ${n} branch recovery note(s) for ${sha7}` `` when `n > 0`; otherwise keep `dropped`/`all checks passed`. - **Red**: when `othersTargets + ownTargets === 0`: - If `branches && atSha.length === 0 && records.length === 0`, call `watchedHeads`; on error `failed` as above. - If no branch matches, finish `done` with `` `no open pull request or watched branch has head ${sha7}` ``. - Otherwise insert one `branch_ci_failed` row and finish `done` with `` `queued branch CI analysis for ${heads[0]} at ${sha7}` ``. - In every other case keep `` `no open pull request has head ${sha7}` ``. - Row shape for both actions: - Fields: `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`: - Failed: `sha256(JSON.stringify(["branch_ci_failed", repo.toLowerCase(), sha, outcome]))`, one per SHA and run. - Green: `sha256(JSON.stringify(["branch_ci_green", repo.toLowerCase(), branch, sha]))`. - Update the method's doc comment to cover branch rows. - Doc-comment `PayloadFields` with the new `watch?: { branch?: string; branches?: string[] }` field. **5. Gating, session resolution and seeds for branch rows** (`forgejo/dispatcher.ts`, `forgejo/conversation-detect.ts`) - Add `"branch-ci-failed" | "branch-ci-green"` to `ReadyReason` and update its doc comment. Add `"branch-ci-green"` to `continueOnly`. - `gate()`: - Add `branchCi = row.action === "branch_ci_failed" || row.action === "branch_ci_green"` and a branch calling the new `resolveBranchWatchRow(job, payload): Promise<boolean>`. - Set `reason = row.action === "branch_ci_failed" ? "branch-ci-failed" : "branch-ci-green"`. - Add `!branchCi` to the permission-check condition (~562). - `resolveBranchWatchRow` finishes the row and returns false on each failure: - Use `this.readRepoConfig(job)` (null → return false). - Empty `ciWatchdogBranches` → `dropped` `"CI watchdog not configured for branches"`. - `matchWatchedBranch(...) === -1` for `payload.watch.branch` → `dropped` `` `branch ${b} is no longer watched` ``. - `listBranches` error → `this.fail(job, \`could not list branches: …\`, "forgejo", null)`. - Branch missing → `dropped` `` `branch ${b} no longer exists` ``. - Head ≠ `payload.ci.sha` → `dropped` `"head moved since CI finished"`. - For green: no open record with a `sessionId` → `dropped` `` `no open watchdog report for ${b}` ``. - Number-0 rows have no thread: - `postNotice` returns immediately when `job.row.number === 0`. - `surfaceFailure` skips `postComment` (goes straight to `fallback`) when `row.number === 0`. - `resolveSession`: as its first statement, branch reasons return `this.resolveBranchWatchSession(ready)`: - Look up `rec = getOpenBranchWatch(repo, branch)`. - If `rec?.sessionId`, call `reuseMapped(rec.sessionId, ready)`: - Success: for `branch-ci-failed`, call `setBranchWatchFailedSha(rec.id, sha)`; then return. - Unusable: for green, throw `` `the mapped session is unusable: ${reason}` ``; for failed, call `resolveBranchWatch(rec.id)` and fall through. - A record without `sessionId`: `resolveBranchWatch` it and fall through for failed; throw for green. - No record and green: throw `"the mapped session is unusable: no session is mapped any more"`. - Create: - `rec = openBranchWatch(repo, branch, sha)`. - `createCheckout({ workspacePath, owner, repo, kind: "branch", number: rec.id, baseBranch: branch })`. - Trigger `{ kind: "forgejo_ci_watchdog", scope: { owner, repo, branch, watchId: rec.id } }`. - Call `bridge.createSession` with the same placement as `resolveSession`. - On checkout or session failure, `resolveBranchWatch(rec.id)`, remove the checkout if made, and rethrow (same wording as `resolveSession`). - Then `setBranchWatchSession(rec.id, sessionId)` and `setName(\`CI watchdog: ${repo} ${branch}\`)` (warn on failure). - Return `{ handle, broughtLive: true, kind, created: true, linkedIssue: null }`. - **Never** call `setForgejoSession(row.repo, 0, …)`. - `reuseMapped` (~1477–1483): restructure the scope check so `forgejo_ci_watchdog` sessions are accepted: - For branch reasons when `scope.branch === payload.watch.branch`. - For any other reason when same repo, because the `forgejo_sessions` mapping vouches for the issue number, as for implementers. - Read `scope.number` only for kinds that have it. - Replies on the reported issue then continue the watchdog session through the normal conversation path. - `runReady` seeds: - Read `rec = getOpenBranchWatch(row.repo, payload.watch.branch)` after `resolveSession`. - `branch-ci-failed` → `buildBranchCiFailureSeed`; `branch-ci-green` → `buildBranchCiGreenSeed`. - On outcome `"ok"` for `branch-ci-green`, call `resolveBranchWatch(rec.id)`. - New exports in `conversation-detect.ts`, next to `buildCiFailureSeed`. Extend `CiFailurePayloadLike` with `watch?: { branch?: string; branches?: string[] }`. - **Orientation**: if `issueNumber !== null`, use `orientationLines(o, { isPull: false, number: issueNumber })`. Otherwise use `orientationLines({ ...o, fresh: null }, { isPull: false, number: 0 })`, which gives only the previous-turn line. - `buildBranchCiFailureSeed({ repo, payload, issueNumber, orientation })` lines: 1. `` `CI failed on ${repo} branch ${branch} at commit ${sha}` ``. 2. If the payload lists more than one branch: `` `The commit is also the head of: ${others.join(", ")}` ``. 3. `` `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.` ``. 4. `"Checks:"` + `formatCheckLines`. 5. Orientation lines. 6. Task (as below). - Task when `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." - Task when `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 })`: - Line `` `CI passes again on ${repo} branch ${branch} at commit ${sha}` ``, then the orientation lines. - Task with an issue: "Comment on #N that every check on <branch> passes at <sha>. Do not close the issue." - Task without an issue: "You never reported on an issue for this branch; end without posting." **6. Docs** - Add `ci_watchdog.branches` to the repo-config documentation wherever `ci_watchdog.others_prs` is 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`: resume `restoreCheckout` (~357) and the `tool_execution_end` mapping hook (~986). - `apps/server/src/forgejo/conversation-detect.ts`: CI seeds (~485–552). - `apps/server/src/forgejo/dispatch-api.ts`: `listBranches` next to `listOpenPulls` (~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 full `bun test` and the repo's typecheck script. Fix the existing session-policy test that asserts `forgejo_create_issue` is 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 config `ci_watchdog:\n branches:\n - main\n - release/*\n`, and set `h.api.branches` so `main` has head `SHA`. - Failure, no open PRs, no post-merge record: - Exactly one `branch_ci_failed` row is queued, then a session is created with `trigger.kind === "forgejo_ci_watchdog"` and `scope.branch === "main"`. - The first prompt contains `CI failed on lucas/omp-deck branch main`. - The status row reason is `queued branch CI analysis for main at 0123456`. - A `forgejo_branch_watch` row is open with that session id. - The same finished run delivered twice queues one row. A re-run with a new `updatedAt` queues a second row, which continues the **same** session (no new session in `h.sessions`). - `SHA` is the head of an open PR, or has a waiting post-merge record → no `branch_ci_failed` row. - `main` head is a different SHA → reason `no open pull request or watched branch has head 0123456`. - `main` and `release/1` both at `SHA` → one row with `watch.branch === "main"` and `watch.branches === ["main","release/1"]`. - Config without `branches` → unchanged behaviour (existing tests stay green). - Head moves between queueing and gating → row `dropped` `head moved since CI finished`. - Green after an open record → a `branch_ci_green` row runs in the same session; the prompt contains `CI passes again`; the record is `resolved`. A later failure creates a new session. Session-policy tests for `forgejo_ci_watchdog`: - With `issue_number` null, comments on any number and `forgejo_create_issue` are allowed. - After `setBranchWatchIssue(id, 7)`, a comment on 7 is allowed, a comment on 8 is denied with the "reports on #7" message, and `forgejo_create_issue` is denied. - `forgejo_create_pr_comment` is denied. In-process mapping: if practical, cover `mapWatchdogIssue` through the existing in-process test harness. Otherwise check it with a throwaway script: after a successful `forgejo_create_issue` event with `details.number = 12`, `getBranchWatch(id).issueNumber === 12` and `getForgejoSession(repo, 12)` is the session. With a pre-existing mapping for 12, the mapping is unchanged. ### Assumptions & contingencies - The Forgejo branches API exposes each branch's head SHA as `commit.id` (unverified — confirm on the instance before step 4; if it differs, map the actual field).
lucas closed this issue 2026-10-30 13:49:47 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#146
No description provided.