ReviewOS

also looking at this

stacks/buddy

fix(ci): land the lock-file repair instead of describing it

#1408
Merged glennmichael123 wants to merge fix/persist-the-mechanical-lockfile-repair into main
6 files +193 -15
bin/cli.tsmodified+21-2
Changes to bin/cli.ts
@@ -1374,8 +1374,10 @@ cli
13741374 baseBranch: config.repository?.baseBranch ?? 'main',
13751375 ai: createAiClient(config, logger),
13761376 logger,
1377 dryRun: Boolean(options.dryRun),
13771378 regenerateLockfile: async () => {
1378 const { regenerateLockFile, detectRequiredPackageManagers } = await import('../src/utils/lock-file')
1379 const { regenerateLockFile, detectRequiredPackageManagers, getAllLockFilePaths } = await import('../src/utils/lock-file')
1380 const { commitAndPush } = await import('../src/utils/git')
13791381
13801382 // Detection keys off the manifests a lockfile is derived from, so a
13811383 // repository with several ecosystems regenerates each of them.
@@ -1390,7 +1392,24 @@ cli
13901392 logger.warn(`⚠️ ${result.message}`)
13911393 }
13921394
1393 return regenerated
1395 if (!regenerated)
1396 return { regenerated: false, pushed: false }
1397
1398 // Rewriting the file in a workspace that is about to be thrown away
1399 // repairs nothing. The job checked this branch out with credentials,
1400 // so the commit is a local one rather than a branch recreation.
1401 try {
1402 const pushed = await commitAndPush(
1403 getAllLockFilePaths(),
1404 'fix(deps): regenerate the lock file\n\nThe lock file had drifted from the manifest, which failed CI.',
1405 process.cwd(),
1406 )
1407 return { regenerated: true, pushed }
1408 }
1409 catch (error) {
1410 logger.warn(`⚠️ Could not push the regenerated lock file: ${error instanceof Error ? error.message : String(error)}`)
1411 return { regenerated: true, pushed: false }
1412 }
13941413 },
13951414 })
13961415
docs/features/ci-repair.mdmodified+13-1
Changes to docs/features/ci-repair.md
@@ -133,7 +133,7 @@ The log is matched against failure signatures before any model is involved. That
133133
134134| Kind | What it means | Repair |
135135| --- | --- | --- |
136| `lockfile-drift` | The lock file is out of step with its manifest | Regenerated mechanically |
136| `lockfile-drift` | The lock file is out of step with its manifest | Regenerated, committed and pushed |
137137| `flake` | Network, rate limit or runner problem | Worth one retry |
138138| `install` | Dependencies could not be resolved | Agent, or reported |
139139| `type-error` | The change does not type-check | Agent |
@@ -141,6 +141,18 @@ The log is matched against failure signatures before any model is involved. That
141141| `lint` | Lint or formatting violations | Agent |
142142| `unknown` | Nothing recognisable | Reported, with the interesting log lines |
143143
144### Re-running after a mechanical fix
145
146The lock-file repair commits to the branch and pushes it, but the pull request
147does not go green by itself. The job pushes with `GITHUB_TOKEN`, which keeps the
148commit attributed to the bot rather than to whoever owns a personal access
149token — and GitHub deliberately does not start a new workflow run for a push
150made with it.
151
152So the fix lands, and the checks need re-running: from the Actions tab, or by
153the next push to the branch. Buddy tells you which of the two happened in the
154comment it leaves.
155
144156With no AI provider configured, classification and the mechanical repairs still work. You lose the agent-driven fixes, not the diagnosis.
145157
146158## What it is allowed to do
src/ci/fix.tsmodified+53-10
Changes to src/ci/fix.ts
@@ -35,8 +35,15 @@ export interface FixOptions {
3535 maxAttempts?: number
3636 /** Client for agent-driven repair; absent means analysis only */
3737 ai?: AiClient | null
38 /** Run the mechanical repair for a stale lock file */
39 regenerateLockfile?: () => Promise<boolean>
38 /**
39 * Run the mechanical repair for a stale lock file.
40 *
41 * Returns what actually happened, because regenerating a file and landing it
42 * on the branch are different outcomes and the report distinguishes them.
43 */
44 regenerateLockfile?: () => Promise<LockfileRepair>
45 /** Diagnose and report without changing anything */
46 dryRun?: boolean
4047 logger?: Logger
4148}
4249
@@ -56,6 +63,35 @@ export interface FixOptions {
5663 * await provider.createComment(pr, outcome.report)
5764 * ```
5865 */
66/** What the mechanical lock-file repair managed to do. */
67export interface LockfileRepair {
68 /** The lock file was rewritten in the workspace */
69 regenerated: boolean
70 /** The rewritten file was committed and pushed to the branch */
71 pushed: boolean
72}
73
74/**
75 * Describe a lock-file repair without overstating it.
76 *
77 * This message claimed "and pushed the result" long before anything pushed
78 * anything, which is the worst kind of report: confident, specific and wrong.
79 *
80 * @param regenerated - Whether the file was rewritten
81 * @param pushed - Whether the rewrite reached the branch
82 */
83function lockfileReport(regenerated: boolean, pushed: boolean): string {
84 if (!regenerated)
85 return 'Tried to regenerate the lock file, but the install did not succeed.'
86
87 if (!pushed) {
88 return 'Regenerated the lock file, but could not push it to the branch. '
89 + 'The change is in the workspace only, so the run will still be red until someone commits it.'
90 }
91
92 return 'Regenerated the lock file and pushed the result. No model was needed.'
93}
94
5995export async function attemptFix(options: FixOptions): Promise<FixOutcome> {
6096 const logger = options.logger ?? getDefaultLogger()
6197 const failure = classifyFailure(options.log)
@@ -85,19 +121,26 @@ export async function attemptFix(options: FixOptions): Promise<FixOutcome> {
85121 }
86122
87123 if (failure.kind === 'lockfile-drift' && options.regenerateLockfile) {
124 if (options.dryRun) {
125 return {
126 failure,
127 action: 'mechanical-fix',
128 fixed: false,
129 report: report(failure, 'Regenerating the lock file would fix this. No model would be needed. '
130 + 'Nothing was changed, because this was a dry run.'),
131 }
132 }
133
88134 logger.info('🔧 Regenerating the lock file')
89 const regenerated = await options.regenerateLockfile()
135 const { regenerated, pushed } = await options.regenerateLockfile()
90136
91137 return {
92138 failure,
93139 action: 'mechanical-fix',
94 fixed: regenerated,
95 report: report(
96 failure,
97 regenerated
98 ? 'Regenerated the lock file and pushed the result. No model was needed.'
99 : 'Tried to regenerate the lock file, but the install did not succeed.',
100 ),
140 // Only a pushed fix has actually repaired the run. A regenerated file
141 // sitting in a workspace that is about to be discarded has not.
142 fixed: pushed,
143 report: report(failure, lockfileReport(regenerated, pushed)),
101144 }
102145 }
103146
src/setup.tsmodified+4-0
Changes to src/setup.ts
@@ -1606,6 +1606,10 @@ ${generateComposerSetupSteps()}
16061606 if: \${{ needs.determine-jobs.outputs.run_fixci == 'true' }}
16071607
16081608 steps:
1609 # GITHUB_TOKEN deliberately, not BUDDY_TOKEN: it keeps the repair commit
1610 # attributed to the bot rather than to whoever owns the PAT. The trade is
1611 # that a push made with it does not start another workflow run, so the
1612 # fix lands and the checks need re-running to go green.
16091613 - name: Checkout the failing branch
16101614 uses: actions/checkout@v4
16111615 with:
src/utils/git.tsmodified+40-0
Changes to src/utils/git.ts
@@ -51,3 +51,43 @@ export async function hasBranchDifferences(fileUpdates: SimpleFileUpdate[], bran
5151 }
5252 return false
5353}
54
55/**
56 * Commit the given paths and push them to the current branch.
57 *
58 * Used by the mechanical CI repair, which runs in a job that has already
59 * checked the failing branch out with credentials. Deliberately not
60 * `GitProvider.commitChanges`: that recreates a branch from its base, which is
61 * right for a dependency update Buddy owns end to end and catastrophic here,
62 * where the branch carries someone else's commits.
63 *
64 * @param paths - Repository-relative paths to stage
65 * @param message - Commit message
66 * @param cwd - Repository root (default: the current directory)
67 * @returns Whether a commit was made and pushed
68 * @example
69 * ```ts
70 * const pushed = await commitAndPush(['bun.lock'], 'fix(deps): regenerate the lock file')
71 * ```
72 */
73export async function commitAndPush(paths: string[], message: string, cwd?: string): Promise<boolean> {
74 if (paths.length === 0)
75 return false
76
77 await runGit(['add', '--', ...paths], cwd)
78
79 // Nothing staged means the regeneration produced an identical file, which is
80 // a success with nothing to record rather than a failure.
81 const staged = await runGit(['diff', '--cached', '--name-only'], cwd)
82 if (!staged.trim())
83 return false
84
85 await runGit(['commit', '-m', message], cwd)
86
87 // The branch is whatever the job checked out. Pushing HEAD to it by name
88 // avoids assuming an upstream is configured.
89 const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)).trim()
90 await runGit(['push', 'origin', `HEAD:${branch}`], cwd)
91
92 return true
93}
test/ci-fix.test.tsmodified+62-2
Changes to test/ci-fix.test.ts
@@ -104,7 +104,7 @@ describe('fix attempts', () => {
104104 log: LOCKFILE_LOG,
105105 regenerateLockfile: async () => {
106106 regenerated = true
107 return true
107 return { regenerated: true, pushed: true }
108108 },
109109 })
110110
@@ -130,7 +130,7 @@ describe('fix attempts', () => {
130130 failsOnBase: true,
131131 regenerateLockfile: async () => {
132132 regenerated = true
133 return true
133 return { regenerated: true, pushed: true }
134134 },
135135 })
136136
@@ -161,3 +161,63 @@ describe('fix attempts', () => {
161161 expect(outcome.report).toContain('error TS2322')
162162 })
163163})
164
165describe('the lock-file repair reports what it actually did', () => {
166 const base = { workspace: '/tmp', baseBranch: 'main' }
167
168 it('failure case - a regenerated file that was not pushed is not a fix', async () => {
169 // The report claimed "and pushed the result" long before anything pushed
170 // anything. A file rewritten in a workspace about to be discarded has
171 // repaired nothing, so the run is still red.
172 const outcome = await attemptFix({
173 ...base,
174 log: LOCKFILE_LOG,
175 regenerateLockfile: async () => ({ regenerated: true, pushed: false }),
176 })
177
178 expect(outcome.fixed).toBe(false)
179 expect(outcome.report).toContain('could not push')
180 expect(outcome.report).not.toContain('pushed the result')
181 })
182
183 it('success case - a pushed file is a fix, and says so', async () => {
184 const outcome = await attemptFix({
185 ...base,
186 log: LOCKFILE_LOG,
187 regenerateLockfile: async () => ({ regenerated: true, pushed: true }),
188 })
189
190 expect(outcome.fixed).toBe(true)
191 expect(outcome.report).toContain('pushed the result')
192 })
193
194 it('failure case - a failed install is reported as such', async () => {
195 const outcome = await attemptFix({
196 ...base,
197 log: LOCKFILE_LOG,
198 regenerateLockfile: async () => ({ regenerated: false, pushed: false }),
199 })
200
201 expect(outcome.fixed).toBe(false)
202 expect(outcome.report).toContain('did not succeed')
203 })
204
205 it('success case - a dry run diagnoses without touching the workspace', async () => {
206 // `--dry-run` used to regenerate lock files on disk regardless, because
207 // the callback was passed unconditionally and never consulted the flag.
208 let called = false
209 const outcome = await attemptFix({
210 ...base,
211 log: LOCKFILE_LOG,
212 dryRun: true,
213 regenerateLockfile: async () => {
214 called = true
215 return { regenerated: true, pushed: true }
216 },
217 })
218
219 expect(called).toBe(false)
220 expect(outcome.fixed).toBe(false)
221 expect(outcome.report).toContain('dry run')
222 })
223})