also looking at this
fix(ci): land the lock-file repair instead of describing it
#1408
6 files
+193
-15
| @@ -1374,8 +1374,10 @@ cli | ||
| 1374 | 1374 | baseBranch: config.repository?.baseBranch ?? 'main', |
| 1375 | 1375 | ai: createAiClient(config, logger), |
| 1376 | 1376 | logger, |
| 1377 | dryRun: Boolean(options.dryRun), | |
| 1377 | 1378 | 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') | |
| 1379 | 1381 | |
| 1380 | 1382 | // Detection keys off the manifests a lockfile is derived from, so a |
| 1381 | 1383 | // repository with several ecosystems regenerates each of them. |
| @@ -1390,7 +1392,24 @@ cli | ||
| 1390 | 1392 | logger.warn(`⚠️ ${result.message}`) |
| 1391 | 1393 | } |
| 1392 | 1394 | |
| 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 | } | |
| 1394 | 1413 | }, |
| 1395 | 1414 | }) |
| 1396 | 1415 | |
| @@ -133,7 +133,7 @@ The log is matched against failure signatures before any model is involved. That | ||
| 133 | 133 | |
| 134 | 134 | | Kind | What it means | Repair | |
| 135 | 135 | | --- | --- | --- | |
| 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 | | |
| 137 | 137 | | `flake` | Network, rate limit or runner problem | Worth one retry | |
| 138 | 138 | | `install` | Dependencies could not be resolved | Agent, or reported | |
| 139 | 139 | | `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 | ||
| 141 | 141 | | `lint` | Lint or formatting violations | Agent | |
| 142 | 142 | | `unknown` | Nothing recognisable | Reported, with the interesting log lines | |
| 143 | 143 | |
| 144 | ### Re-running after a mechanical fix | |
| 145 | ||
| 146 | The lock-file repair commits to the branch and pushes it, but the pull request | |
| 147 | does not go green by itself. The job pushes with `GITHUB_TOKEN`, which keeps the | |
| 148 | commit attributed to the bot rather than to whoever owns a personal access | |
| 149 | token — and GitHub deliberately does not start a new workflow run for a push | |
| 150 | made with it. | |
| 151 | ||
| 152 | So the fix lands, and the checks need re-running: from the Actions tab, or by | |
| 153 | the next push to the branch. Buddy tells you which of the two happened in the | |
| 154 | comment it leaves. | |
| 155 | ||
| 144 | 156 | With no AI provider configured, classification and the mechanical repairs still work. You lose the agent-driven fixes, not the diagnosis. |
| 145 | 157 | |
| 146 | 158 | ## What it is allowed to do |
| @@ -35,8 +35,15 @@ export interface FixOptions { | ||
| 35 | 35 | maxAttempts?: number |
| 36 | 36 | /** Client for agent-driven repair; absent means analysis only */ |
| 37 | 37 | 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 | |
| 40 | 47 | logger?: Logger |
| 41 | 48 | } |
| 42 | 49 | |
| @@ -56,6 +63,35 @@ export interface FixOptions { | ||
| 56 | 63 | * await provider.createComment(pr, outcome.report) |
| 57 | 64 | * ``` |
| 58 | 65 | */ |
| 66 | /** What the mechanical lock-file repair managed to do. */ | |
| 67 | export 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 | */ | |
| 83 | function 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 | ||
| 59 | 95 | export async function attemptFix(options: FixOptions): Promise<FixOutcome> { |
| 60 | 96 | const logger = options.logger ?? getDefaultLogger() |
| 61 | 97 | const failure = classifyFailure(options.log) |
| @@ -85,19 +121,26 @@ export async function attemptFix(options: FixOptions): Promise<FixOutcome> { | ||
| 85 | 121 | } |
| 86 | 122 | |
| 87 | 123 | 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 | ||
| 88 | 134 | logger.info('🔧 Regenerating the lock file') |
| 89 | const regenerated = await options.regenerateLockfile() | |
| 135 | const { regenerated, pushed } = await options.regenerateLockfile() | |
| 90 | 136 | |
| 91 | 137 | return { |
| 92 | 138 | failure, |
| 93 | 139 | 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)), | |
| 101 | 144 | } |
| 102 | 145 | } |
| 103 | 146 | |
| @@ -1606,6 +1606,10 @@ ${generateComposerSetupSteps()} | ||
| 1606 | 1606 | if: \${{ needs.determine-jobs.outputs.run_fixci == 'true' }} |
| 1607 | 1607 | |
| 1608 | 1608 | 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. | |
| 1609 | 1613 | - name: Checkout the failing branch |
| 1610 | 1614 | uses: actions/checkout@v4 |
| 1611 | 1615 | with: |
| @@ -51,3 +51,43 @@ export async function hasBranchDifferences(fileUpdates: SimpleFileUpdate[], bran | ||
| 51 | 51 | } |
| 52 | 52 | return false |
| 53 | 53 | } |
| 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 | */ | |
| 73 | export 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 | } | |
| @@ -104,7 +104,7 @@ describe('fix attempts', () => { | ||
| 104 | 104 | log: LOCKFILE_LOG, |
| 105 | 105 | regenerateLockfile: async () => { |
| 106 | 106 | regenerated = true |
| 107 | return true | |
| 107 | return { regenerated: true, pushed: true } | |
| 108 | 108 | }, |
| 109 | 109 | }) |
| 110 | 110 | |
| @@ -130,7 +130,7 @@ describe('fix attempts', () => { | ||
| 130 | 130 | failsOnBase: true, |
| 131 | 131 | regenerateLockfile: async () => { |
| 132 | 132 | regenerated = true |
| 133 | return true | |
| 133 | return { regenerated: true, pushed: true } | |
| 134 | 134 | }, |
| 135 | 135 | }) |
| 136 | 136 | |
| @@ -161,3 +161,63 @@ describe('fix attempts', () => { | ||
| 161 | 161 | expect(outcome.report).toContain('error TS2322') |
| 162 | 162 | }) |
| 163 | 163 | }) |
| 164 | ||
| 165 | describe('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 | }) | |