ReviewOS

also looking at this

stacks/buddy

fix(ci): give the repair loop the guard it already had

#1407
Merged glennmichael123 wants to merge fix/wire-ci-repair-attempt-guard into main
10 files +229 -34
bin/cli.tsmodified+28-3
Changes to bin/cli.ts
@@ -3,7 +3,7 @@
33import type { GitProvider } from '../src/git/provider'
44import type { ReviewFinding } from '../src/review/findings'
55import type { ReviewFormat } from '../src/review/local'
6import type { BuddyConfig } from '../src/types'
6import type { BuddyConfig, PullRequest } from '../src/types'
77import fs from 'node:fs'
88import process from 'node:process'
99import { CLI } from '@stacksjs/clapp'
@@ -1354,10 +1354,22 @@ cli
13541354 }
13551355
13561356 const { attemptFix } = await import('../src/ci')
1357 const { parseFixAttempts, upsertFixAttempts } = await import('../src/ci/attempts')
13571358 const { createAiClient } = await import('../src/ai')
13581359
1360 // The guard in attemptFix can only stop a loop if it is told how many
1361 // attempts came before, and a CI job remembers nothing between runs.
1362 const prNumber = options.pr ? Number.parseInt(options.pr, 10) : undefined
1363 let pullRequest: PullRequest | undefined
1364 if (prNumber) {
1365 const open = await provider.getPullRequests('open')
1366 pullRequest = open.find(candidate => candidate.number === prNumber)
1367 }
1368 const priorAttempts = parseFixAttempts(pullRequest?.body)?.attempts ?? 0
1369
13591370 const outcome = await attemptFix({
13601371 log,
1372 priorAttempts,
13611373 workspace: process.cwd(),
13621374 baseBranch: config.repository?.baseBranch ?? 'main',
13631375 ai: createAiClient(config, logger),
@@ -1384,10 +1396,23 @@ cli
13841396
13851397 logger.info(`\n${outcome.report}\n`)
13861398
1387 const prNumber = options.pr ? Number.parseInt(options.pr, 10) : undefined
1388 if (prNumber && !options.dryRun)
1399 if (prNumber && !options.dryRun) {
13891400 await provider.createComment(prNumber, outcome.report)
13901401
1402 // Only a real attempt counts. Recording the ones the guard already
1403 // refused would march the counter up without anything being tried.
1404 if (outcome.action !== 'skipped' && pullRequest) {
1405 try {
1406 await provider.updatePullRequest(prNumber, {
1407 body: upsertFixAttempts(pullRequest.body, priorAttempts + 1),
1408 })
1409 }
1410 catch (error) {
1411 logger.warn(`Could not record the fix attempt on PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`)
1412 }
1413 }
1414 }
1415
13911416 logger.success(`✅ ${outcome.action}${outcome.fixed ? ' (fixed)' : ''}`)
13921417 }
13931418 catch (error) {
docs/api/configuration.mdmodified+3-3
Changes to docs/api/configuration.md
@@ -93,9 +93,9 @@ typo cannot reach a workflow run.
9393
9494`apiUrl` defaults to `GITHUB_API_URL` when set — GitHub Actions exports it on
9595both github.com and Enterprise Server runners — otherwise
96`https://api.github.com`. Set it explicitly for GitHub Enterprise Server, for
97example `https://github.acme.com/api/v3`. `serverUrl` defaults to
98`GITHUB_SERVER_URL`, otherwise `https://github.com`.
96`<https://api.github.com>`. Set it explicitly for GitHub Enterprise Server, for
97example `<https://github.acme.com/api/v3>`. `serverUrl` defaults to
98`GITHUB_SERVER_URL`, otherwise `<https://github.com>`.
9999
100100## Registries
101101
docs/cli/utility.mdmodified+4-4
Changes to docs/cli/utility.md
@@ -45,14 +45,14 @@ The repository comes from `repository.owner` and `repository.name` in `buddy.con
4545
4646#### Repository Actions settings
4747
48`https://github.com/<owner>/<repo>/settings/actions`
48`<https://github.com/><owner>/<repo>/settings/actions`
4949
5050- Workflow permissions — select "Read and write permissions"
5151- "Allow GitHub Actions to create and approve pull requests"
5252
5353#### Organization Actions settings
5454
55`https://github.com/organizations/<owner>/settings/actions`
55`<https://github.com/organizations/><owner>/settings/actions`
5656
5757- The same two settings one level up, which may override the repository's
5858
@@ -62,8 +62,8 @@ If neither the config nor the git remote identifies a repository, buddy prints b
6262
6363buddy does not open these, but setup points you at them:
6464
65- Personal access tokens — `https://github.com/settings/tokens`
66- Repository secrets — `https://github.com/<owner>/<repo>/settings/secrets/actions`
65- Personal access tokens — `<https://github.com/settings/tokens>`
66- Repository secrets — `<https://github.com/><owner>/<repo>/settings/secrets/actions`
6767
6868## schedule
6969
docs/features/ci-repair.mdmodified+13-7
Changes to docs/features/ci-repair.md
@@ -80,21 +80,27 @@ features:
8080 details: "Every run posts an outcome: what it classified, what it did, and — when it declined — why the fix was not the bot's to make."
8181---
8282
83## From the thread
83## From a failing run
8484
85```text
86@buddy fix-ci
87```
85Repair is driven by the run that failed, because the diagnosis is a function of
86that job's log. The workflow generated by `buddy setup` wires this up: when a
87run on a Buddy branch fails, the `fix-ci` job reads its log and reports back on
88the pull request.
8889
89The most common way to reach it. The command is permission-checked, so a commenter without write access cannot make Buddy push to your branch.
90A comment cannot say *which* run failed, so `@buddy fix-ci` in a thread replies
91pointing at that job rather than guessing.
9092
9193## From the CLI
9294
9395```bash
94buddy fix-ci --pr 128
95buddy fix-ci --pr 128 --dry-run # classify and report, change nothing
96buddy fix-ci --run-id 12345 --pr 128
97buddy fix-ci --run-id 12345 --pr 128 --dry-run # classify and report, change nothing
9698```
9799
100`--run-id` supplies the log to diagnose. `--pr` is where the report goes, and
101also where the attempt counter lives — without it Buddy cannot tell a first
102attempt from a fourth.
103
98104## From a workflow
99105
100106```yaml
docs/features/rebase.mdmodifiedformatting only+2-2
Changes to docs/features/rebase.md
@@ -91,11 +91,11 @@ The rebase handling lives in the unified workflow `buddy setup` writes to `.gith
9191name: Buddy
9292
9393on:
94 # Ticking the checkbox edits the PR body, which fires this event
94# Ticking the checkbox edits the PR body, which fires this event
9595 pull_request:
9696 types: [edited, opened, ready_for_review, synchronize, closed]
9797
98 # The dashboard's checkboxes are the same request, made on an issue
98# The dashboard's checkboxes are the same request, made on an issue
9999 issues:
100100 types: [edited, opened]
101101
docs/features/release-notes.mdmodified+2-2
Changes to docs/features/release-notes.md
@@ -76,8 +76,8 @@ link:
7676| --- | --- |
7777| `#123` | the same text, wrapped in code formatting |
7878| `fixes #456` | `fixes` followed by the number in code formatting |
79| `https://github.com/org/repo/pull/123` | `org/repo#123`, as code rather than a link |
80| `https://github.com/org/repo/issues/456` | `org/repo#456`, as code rather than a link |
79| `<https://github.com/org/repo/pull/123>` | `org/repo#123`, as code rather than a link |
80| `<https://github.com/org/repo/issues/456>` | `org/repo#456`, as code rather than a link |
8181
8282The information survives — you can still see which issue was fixed — but it no
8383longer cross-links into your tracker.
src/ci/attempts.tsadded+81-0
Changes to src/ci/attempts.ts
@@ -0,0 +1,81 @@
1/**
2 * How many times Buddy has already tried to repair a pull request.
3 *
4 * `attemptFix` refuses to keep trying past `maxAttempts`, but it can only do
5 * that if someone tells it how many attempts came before — and a CI job has no
6 * memory of its own. The count therefore lives on the pull request, the same
7 * place review state does, because it is the only store that survives the run.
8 */
9
10/** Marker carrying the attempt count, hidden in the pull request body. */
11const MARKER_REGEX = /<!--\s*buddy:fix-ci\s+v(\d+)\s*([\s\S]*?)-->/
12
13/** Schema version emitted by this build. */
14export const FIX_ATTEMPTS_VERSION = 1
15
16/** Attempt bookkeeping for one pull request. */
17export interface FixAttemptState {
18 /** Repairs attempted so far, successful or not */
19 attempts: number
20 /** ISO timestamp of the most recent attempt */
21 lastAttemptAt: string
22}
23
24/**
25 * Read the attempt count out of a pull request body.
26 *
27 * A missing or unreadable marker reads as zero attempts rather than as an
28 * error: the guard exists to stop a loop, and refusing to act because the
29 * bookkeeping is unparseable would be a worse failure than one extra try.
30 *
31 * @param body - Pull request body, possibly null
32 * @returns The recorded state, or `null` when absent or unreadable
33 */
34export function parseFixAttempts(body: string | null | undefined): FixAttemptState | null {
35 if (!body)
36 return null
37
38 const match = body.match(MARKER_REGEX)
39 if (!match)
40 return null
41
42 try {
43 const parsed = JSON.parse(match[2].trim()) as Partial<FixAttemptState>
44 const attempts = Number(parsed.attempts)
45 if (!Number.isFinite(attempts) || attempts < 0)
46 return null
47
48 return {
49 attempts: Math.floor(attempts),
50 lastAttemptAt: typeof parsed.lastAttemptAt === 'string' ? parsed.lastAttemptAt : new Date(0).toISOString(),
51 }
52 }
53 catch {
54 return null
55 }
56}
57
58/**
59 * Record an attempt on a pull request body.
60 *
61 * Counted per pull request rather than per commit, deliberately. Buddy's own
62 * repair pushes a commit, so keying the count to the head sha would reset it
63 * every time a fix landed and defeat the guard it exists to provide.
64 *
65 * @param body - Current pull request body
66 * @param attempts - Total attempts including the one just made
67 * @param now - Timestamp to record
68 * @returns The body with exactly one, current, marker
69 * @example
70 * ```ts
71 * const attempts = (parseFixAttempts(pr.body)?.attempts ?? 0) + 1
72 * await provider.updatePullRequest(number, { body: upsertFixAttempts(pr.body, attempts) })
73 * ```
74 */
75export function upsertFixAttempts(body: string | null | undefined, attempts: number, now: Date = new Date()): string {
76 const state: FixAttemptState = { attempts, lastAttemptAt: now.toISOString() }
77 const marker = `<!-- buddy:fix-ci v${FIX_ATTEMPTS_VERSION}\n${JSON.stringify(state)}\n-->`
78 const withoutMarker = (body ?? '').replace(MARKER_REGEX, '').trimEnd()
79
80 return `${withoutMarker}\n\n${marker}`
81}
src/commands/handlers.tsmodified+11-12
Changes to src/commands/handlers.ts
@@ -1,9 +1,7 @@
11import type { GitProvider } from '../git/provider'
22import type { BuddyConfig } from '../types'
33import type { CommandContext, CommandHandler, CommandOutcome } from './dispatcher'
4import process from 'node:process'
54import { createAiClient } from '../ai'
6import { attemptFix } from '../ci/fix'
75
86/** Everything the built-in handlers need to act. */
97export interface HandlerDeps {
@@ -90,16 +88,17 @@ export function createHandlers(deps: HandlerDeps): Record<string, CommandHandler
9088 },
9189
9290 async 'fix-ci'(context): Promise<CommandOutcome> {
93 const ai = createAiClient(deps.config, context.logger)
94 const outcome = await attemptFix({
95 log: '',
96 workspace: process.cwd(),
97 baseBranch: deps.config.repository?.baseBranch ?? 'main',
98 ai,
99 logger: context.logger,
100 })
101
102 return { handled: true, reply: outcome.report }
91 // This handler has no run to read. Diagnosis is entirely a function of
92 // the failing job's log, so calling attemptFix with an empty one only
93 // ever produced a confident-looking `unknown` classification with no
94 // evidence behind it. Saying so is more useful than that.
95 return {
96 handled: false,
97 reply: 'I diagnose a failure from its job log, and a comment does not tell me which run failed. '
98 + 'Buddy repairs failing checks automatically when the `fix-ci` job is enabled in your workflow — '
99 + 'it runs on the failed run itself and reports back here. '
100 + 'See https://buddy.sh/features/ci-repair',
101 }
103102 },
104103
105104 async remember(context): Promise<CommandOutcome> {
src/setup.tsmodified+6-1
Changes to src/setup.ts
@@ -1620,8 +1620,13 @@ ${generateComposerSetupSteps()}
16201620 - name: Install dependencies
16211621 run: bun install
16221622
1623 # --pr is what carries the diagnosis back to the pull request, and it is
1624 # also where the attempt counter lives: without it the anti-loop guard
1625 # has nothing to read and every failure looks like the first one.
1626 # A run triggered from a fork has no entry here, and the step is skipped.
16231627 - name: Attempt a fix
1624 run: bunx @buddysh/buddy fix-ci --run-id \${{ github.event.workflow_run.id }} --verbose
1628 if: \${{ github.event.workflow_run.pull_requests[0] }}
1629 run: bunx @buddysh/buddy fix-ci --run-id \${{ github.event.workflow_run.id }} --pr \${{ github.event.workflow_run.pull_requests[0].number }} --verbose
16251630 env:
16261631 GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
16271632 ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
test/ci-attempts.test.tsadded+79-0
Changes to test/ci-attempts.test.ts
@@ -0,0 +1,79 @@
1import { describe, expect, it } from 'bun:test'
2import { attemptFix } from '../src/ci/fix'
3import { parseFixAttempts, upsertFixAttempts } from '../src/ci/attempts'
4
5const TYPE_ERROR_LOG = `src/app.ts(12,5): error TS2322: Type 'string' is not assignable to type 'number'.`
6
7/**
8 * `attemptFix` has always refused to keep trying past `maxAttempts`, but no
9 * production caller passed `priorAttempts`, so every failure looked like the
10 * first one and the guard never engaged.
11 */
12describe('fix attempt tracking', () => {
13 describe('marker round-trip', () => {
14 it('success case - a recorded attempt reads back', () => {
15 const body = upsertFixAttempts('Original description.', 1)
16
17 expect(parseFixAttempts(body)?.attempts).toBe(1)
18 expect(body).toContain('Original description.')
19 })
20
21 it('success case - recording again replaces rather than stacks', () => {
22 const first = upsertFixAttempts('desc', 1)
23 const second = upsertFixAttempts(first, 2)
24
25 expect(second.match(/<!--\s*buddy:fix-ci/g)).toHaveLength(1)
26 expect(parseFixAttempts(second)?.attempts).toBe(2)
27 })
28
29 it('edge case - no marker means no attempts yet', () => {
30 expect(parseFixAttempts('just a description')).toBeNull()
31 expect(parseFixAttempts(null)).toBeNull()
32 expect(parseFixAttempts('')).toBeNull()
33 })
34
35 it('edge case - an unreadable marker reads as absent rather than throwing', () => {
36 // Refusing to act because the bookkeeping is corrupt would be a worse
37 // failure than one extra attempt.
38 expect(() => parseFixAttempts('<!-- buddy:fix-ci v1\n{bad\n-->')).not.toThrow()
39 expect(parseFixAttempts('<!-- buddy:fix-ci v1\n{bad\n-->')).toBeNull()
40 })
41
42 it('edge case - a negative count is rejected', () => {
43 expect(parseFixAttempts('<!-- buddy:fix-ci v1\n{"attempts":-3}\n-->')).toBeNull()
44 })
45
46 it('success case - survives alongside the review marker', () => {
47 // Both live in the pull request body; neither may eat the other.
48 const withReview = 'desc\n\n<!-- buddy:review v1\n{"reviewedSha":"abc","fingerprints":[]}\n-->'
49 const body = upsertFixAttempts(withReview, 1)
50
51 expect(body).toContain('buddy:review')
52 expect(parseFixAttempts(body)?.attempts).toBe(1)
53 })
54 })
55
56 describe('the guard the count feeds', () => {
57 const base = { workspace: process.cwd(), baseBranch: 'main' }
58
59 it('failure case - stops once the attempts are spent', async () => {
60 const outcome = await attemptFix({ ...base, log: TYPE_ERROR_LOG, priorAttempts: 3, maxAttempts: 3 })
61
62 expect(outcome.action).toBe('skipped')
63 expect(outcome.report).toContain('stopping rather than looping')
64 })
65
66 it('success case - a count below the ceiling still tries', async () => {
67 const outcome = await attemptFix({ ...base, log: TYPE_ERROR_LOG, priorAttempts: 1, maxAttempts: 3 })
68
69 expect(outcome.action).not.toBe('skipped')
70 })
71
72 it('failure case - a pre-existing failure on base is not this PR to fix', async () => {
73 const outcome = await attemptFix({ ...base, log: TYPE_ERROR_LOG, failsOnBase: true })
74
75 expect(outcome.action).toBe('skipped')
76 expect(outcome.report).toContain('base branch')
77 })
78 })
79})