ReviewOS

also looking at this

stacks/buddy

feat(ci): let @buddy fix-ci find the run a comment cannot name

#1413
Merged glennmichael123 wants to merge feat/fix-ci-from-comment into main
10 files +697 -121
src/ci/fix.tsmodified+44-0
Changes to src/ci/fix.ts
@@ -60,6 +60,16 @@ export interface FixOptions {
6060 rerun?: () => Promise<boolean>
6161 /** Diagnose and report without changing anything */
6262 dryRun?: boolean
63 /**
64 * Diagnose without repairing, because the caller has nowhere to repair.
65 *
66 * Distinct from `dryRun`, which is a choice someone made. This is a
67 * constraint: answering an `@buddy fix-ci` comment runs on the default
68 * branch, so rewriting a lock file or letting the agent edit files would
69 * change the wrong tree entirely. Re-running a flake is still fine — that is
70 * an API call, not a change to a checkout.
71 */
72 analysisOnly?: boolean
6373 logger?: Logger
6474}
6575
@@ -108,6 +118,29 @@ function lockfileReport(regenerated: boolean, pushed: boolean): string {
108118 return 'Regenerated the lock file and pushed the result. No model was needed.'
109119}
110120
121/**
122 * Explain a diagnosis that could not be acted on from where it was made.
123 *
124 * Says which repair was available rather than only that none happened — a
125 * report that stops at "I cannot fix this here" reads as a limitation, where
126 * the useful version names the fix and who will apply it.
127 *
128 * @param failure - The classified failure
129 */
130function analysisOnlyNote(failure: ClassifiedFailure): string {
131 const shared = 'Repairs run on the pull request\'s branch, and answering a comment runs on the default branch, '
132 + 'so anything I changed here would land on the wrong tree. '
133
134 if (failure.kind === 'lockfile-drift') {
135 return `Regenerating the lock file would fix this, and no model would be needed. ${shared}`
136 + 'The `fix-ci` job will do it on the next failing run, or you can run '
137 + '`buddy fix-ci --run-id <id> --pr <number>` yourself.'
138 }
139
140 return `${shared}The \`fix-ci\` job runs on the failing run itself and can repair this; `
141 + 'this reply is the diagnosis it would start from.'
142}
143
111144export async function attemptFix(options: FixOptions): Promise<FixOutcome> {
112145 const logger = options.logger ?? getDefaultLogger()
113146 const failure = classifyFailure(options.log)
@@ -140,6 +173,17 @@ export async function attemptFix(options: FixOptions): Promise<FixOutcome> {
140173 }
141174 }
142175
176 // Everything below either writes to the workspace or hands it to the agent.
177 // A flake is the exception, and it is handled on its own further down.
178 if (options.analysisOnly && failure.kind !== 'flake') {
179 return {
180 failure,
181 action: 'reported',
182 fixed: false,
183 report: report(failure, analysisOnlyNote(failure)),
184 }
185 }
186
143187 if (failure.kind === 'lockfile-drift' && options.regenerateLockfile) {
144188 if (options.dryRun) {
145189 return {