also looking at this
feat(ci): let buddy see whose failure it is, and retry the ones worth retrying
#1410
16 files
+1092
-14
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | #!/usr/bin/env bun |
| 2 | 2 | |
| 3 | import type { FailureKind } from '../src/ci/classify' | |
| 3 | 4 | import type { GitProvider } from '../src/git/provider' |
| 4 | 5 | import type { ReviewFinding } from '../src/review/findings' |
| 5 | 6 | import type { ReviewFormat } from '../src/review/local' |
| @@ -1355,7 +1356,7 @@ cli | ||
| 1355 | 1356 | return |
| 1356 | 1357 | } |
| 1357 | 1358 | |
| 1358 | const { attemptFix } = await import('../src/ci') | |
| 1359 | const { attemptFix, failsOnBaseBranch } = await import('../src/ci') | |
| 1359 | 1360 | const { parseFixAttempts, upsertFixAttempts } = await import('../src/ci/attempts') |
| 1360 | 1361 | const { createAiClient } = await import('../src/ai') |
| 1361 | 1362 | |
| @@ -1368,15 +1369,36 @@ cli | ||
| 1368 | 1369 | pullRequest = open.find(candidate => candidate.number === prNumber) |
| 1369 | 1370 | } |
| 1370 | 1371 | const priorAttempts = parseFixAttempts(pullRequest?.body)?.attempts ?? 0 |
| 1372 | const baseBranch = config.repository?.baseBranch ?? 'main' | |
| 1373 | ||
| 1374 | // Both of these are capability-gated rather than assumed: a provider | |
| 1375 | // that cannot read a run history still gets the full diagnosis, it just | |
| 1376 | // cannot tell an inherited failure from a new one. | |
| 1377 | const canReadRuns = supports(provider, 'ciRuns', 'listWorkflowRuns') | |
| 1378 | const canRerun = supports(provider, 'ciRuns', 'rerunWorkflowRun') | |
| 1371 | 1379 | |
| 1372 | 1380 | const outcome = await attemptFix({ |
| 1373 | 1381 | log, |
| 1374 | 1382 | priorAttempts, |
| 1375 | 1383 | workspace: process.cwd(), |
| 1376 | baseBranch: config.repository?.baseBranch ?? 'main', | |
| 1384 | baseBranch, | |
| 1377 | 1385 | ai: createAiClient(config, logger), |
| 1378 | 1386 | logger, |
| 1379 | 1387 | dryRun: Boolean(options.dryRun), |
| 1388 | ...(canReadRuns | |
| 1389 | ? { | |
| 1390 | checkBase: (kind: FailureKind) => failsOnBaseBranch( | |
| 1391 | { | |
| 1392 | listWorkflowRuns: (branch, listOptions) => provider.listWorkflowRuns!(branch, listOptions), | |
| 1393 | getWorkflowRunLogs: id => provider.getWorkflowRunLogs!(id), | |
| 1394 | }, | |
| 1395 | baseBranch, | |
| 1396 | kind, | |
| 1397 | logger, | |
| 1398 | ), | |
| 1399 | } | |
| 1400 | : {}), | |
| 1401 | ...(canRerun && runId ? { rerun: () => provider.rerunWorkflowRun!(runId) } : {}), | |
| 1380 | 1402 | regenerateLockfile: async () => { |
| 1381 | 1403 | const { regenerateLockFile, detectRequiredPackageManagers, getAllLockFilePaths } = await import('../src/utils/lock-file') |
| 1382 | 1404 | const { commitAndPush } = await import('../src/utils/git') |
| @@ -63,6 +63,7 @@ if (supports(provider, 'pinIssues', 'pinIssue')) | ||
| 63 | 63 | | `nativeAutoMerge` | `enableAutoMerge` | |
| 64 | 64 | | `commentReactions` | `reactToComment` | |
| 65 | 65 | | `ciLogs` | `getWorkflowRunLogs` | |
| 66 | | `ciRuns` | `listWorkflowRuns`, `rerunWorkflowRun` | | |
| 66 | 67 | | `teamReviewers` | Requesting review from a team | |
| 67 | 68 | | `draftPullRequests` | Opening a pull request as a draft | |
| 68 | 69 | | `permissionLookup` | `hasWriteAccess` | |
| @@ -81,6 +82,7 @@ if (supports(provider, 'pinIssues', 'pinIssue')) | ||
| 81 | 82 | | `nativeAutoMerge` | ✅ | ✅ (merge when pipeline succeeds) | ❌ | |
| 82 | 83 | | `commentReactions` | ✅ | ✅ (award emoji) | ❌ | |
| 83 | 84 | | `ciLogs` | ✅ | ✅ | ❌ | |
| 85 | | `ciRuns` | ✅ | ✅ (jobs) | ❌ | | |
| 84 | 86 | | `teamReviewers` | ✅ | ❌ | ❌ | |
| 85 | 87 | | `draftPullRequests` | ✅ | ✅ (title prefix) | ❌ | |
| 86 | 88 | | `permissionLookup` | ✅ | ✅ | ✅ | |
| @@ -88,6 +90,12 @@ if (supports(provider, 'pinIssues', 'pinIssue')) | ||
| 88 | 90 | | `reopenPullRequests` | ✅ | ✅ | ❌ | |
| 89 | 91 | | `labels` | ✅ | ✅ | ❌ | |
| 90 | 92 | |
| 93 | `ciLogs` and `ciRuns` are separate for GitLab's sake. Reading one run's log | |
| 94 | and reasoning about a branch's run history are the same API on GitHub and two | |
| 95 | different ones on GitLab, which has pipelines *and* jobs. `listWorkflowRuns` | |
| 96 | returns jobs there, deliberately: a job is what `getWorkflowRunLogs` reads, and | |
| 97 | an `id` that could not be handed straight back to that method would be a trap. | |
| 98 | ||
| 91 | 99 | Two of these rows exist *because* Bitbucket forced the distinction. A declined |
| 92 | 100 | Bitbucket pull request is final — there is no reopen — and Bitbucket has no |
| 93 | 101 | labels on pull requests or issues at all. Rather than have those operations |
| @@ -90,6 +90,28 @@ the pull request. | ||
| 90 | 90 | A comment cannot say *which* run failed, so `@buddy fix-ci` in a thread replies |
| 91 | 91 | pointing at that job rather than guessing. |
| 92 | 92 | |
| 93 | ### Whose failure is it | |
| 94 | ||
| 95 | Before anything else, Buddy asks whether the base branch is already failing the | |
| 96 | same way. Not *is the base red* — that would down tools on exactly the | |
| 97 | repositories that need the help most — but is it red **in the same way**: the | |
| 98 | newest run of each workflow on the base branch is classified from its own log, | |
| 99 | and compared against the failure at hand. | |
| 100 | ||
| 101 | If they match, Buddy declines and says so. Repairing an inherited failure on a | |
| 102 | pull request attributes someone else's regression to that change, and hides the | |
| 103 | regression where nobody is looking for it. | |
| 104 | ||
| 105 | Comparing classifications is an approximation, and it errs deliberately. Two | |
| 106 | unrelated type errors both classify as `type-error`, so a genuine regression on | |
| 107 | a base branch that already fails to type-check reads as inherited — Buddy | |
| 108 | declines and explains, rather than committing a guess about code it has already | |
| 109 | been told is broken. | |
| 110 | ||
| 111 | This needs the [`ciRuns` capability](/advanced/providers). A provider without it | |
| 112 | still gets the full diagnosis; it just cannot tell an inherited failure from a | |
| 113 | new one. | |
| 114 | ||
| 93 | 115 | ## From the CLI |
| 94 | 116 | |
| 95 | 117 | ```bash |
| @@ -134,24 +156,40 @@ The log is matched against failure signatures before any model is involved. That | ||
| 134 | 156 | | Kind | What it means | Repair | |
| 135 | 157 | | --- | --- | --- | |
| 136 | 158 | | `lockfile-drift` | The lock file is out of step with its manifest | Regenerated, committed and pushed | |
| 137 | | `flake` | Network, rate limit or runner problem | Worth one retry | | |
| 159 | | `flake` | Network, rate limit or runner problem | Failed jobs re-run | | |
| 138 | 160 | | `install` | Dependencies could not be resolved | Agent, or reported | |
| 139 | 161 | | `type-error` | The change does not type-check | Agent | |
| 140 | 162 | | `test-failure` | An assertion failed | Agent, or reported | |
| 141 | 163 | | `lint` | Lint or formatting violations | Agent | |
| 142 | 164 | | `unknown` | Nothing recognisable | Reported, with the interesting log lines | |
| 143 | 165 | |
| 144 | ### Re-running after a mechanical fix | |
| 166 | ### Re-running a flake | |
| 167 | ||
| 168 | A transient failure needs no repair, only another go at the same commit — so | |
| 169 | that is what Buddy does. It re-runs the failed jobs of the run it was handed | |
| 170 | and says so in its comment. This needs `actions: write`, which the generated | |
| 171 | workflow already grants; without it Buddy reports the diagnosis and points you | |
| 172 | at the Actions tab. | |
| 173 | ||
| 174 | The attempt counter is what keeps this from becoming a loop. A re-run that | |
| 175 | fails again brings Buddy straight back to the same pull request, and the count | |
| 176 | lives on the pull request precisely so it survives that. | |
| 177 | ||
| 178 | ### Why a mechanical fix does not go green by itself | |
| 145 | 179 | |
| 146 | 180 | 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. | |
| 181 | stays red. Two things combine to cause that, and neither has a way around it | |
| 182 | here: | |
| 183 | ||
| 184 | - The job pushes with `GITHUB_TOKEN`, which keeps the commit attributed to the | |
| 185 | bot rather than to whoever owns a personal access token — and GitHub | |
| 186 | deliberately does not start a workflow run for a push made with it. | |
| 187 | - Re-running the old run does not help either, because a re-run executes | |
| 188 | against the commit it *originally* ran on. That commit is the broken one. | |
| 151 | 189 | |
| 152 | 190 | So the fix lands, and the checks need re-running: from the Actions tab, or by |
| 153 | 191 | the next push to the branch. Buddy tells you which of the two happened in the |
| 154 | comment it leaves. | |
| 192 | comment it leaves. This is the one case the re-run above cannot cover. | |
| 155 | 193 | |
| 156 | 194 | With no AI provider configured, classification and the mechanical repairs still work. You lose the agent-driven fixes, not the diagnosis. |
| 157 | 195 | |
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * Whether a failing check is inherited from the base branch. | |
| 3 | * | |
| 4 | * `attemptFix` has always known how to decline a pre-existing failure, but | |
| 5 | * nothing ever told it about one: `failsOnBase` had no production caller, so | |
| 6 | * every base-branch breakage was diagnosed, and sometimes repaired, as though | |
| 7 | * the pull request had caused it. | |
| 8 | */ | |
| 9 | import type { ListWorkflowRunsOptions, WorkflowRun } from '../git/provider' | |
| 10 | import type { Logger } from '../utils/logger' | |
| 11 | import type { FailureKind } from './classify' | |
| 12 | import { formatError } from '../utils/errors' | |
| 13 | import { getDefaultLogger } from '../utils/logger' | |
| 14 | import { classifyFailure } from './classify' | |
| 15 | ||
| 16 | /** | |
| 17 | * The slice of a provider this check needs. | |
| 18 | * | |
| 19 | * Narrow on purpose: the check is pure reasoning over two reads, and taking a | |
| 20 | * whole `GitProvider` would make it awkward to test something that deserves | |
| 21 | * direct tests. | |
| 22 | */ | |
| 23 | export interface RunHistoryReader { | |
| 24 | listWorkflowRuns: (branch: string, options?: ListWorkflowRunsOptions) => Promise<WorkflowRun[]> | |
| 25 | getWorkflowRunLogs: (runId: number) => Promise<string | null> | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * Base-branch logs to read before giving up. | |
| 30 | * | |
| 31 | * Each one is an API round trip inside a repair run that has other work to do, | |
| 32 | * and a base branch red in more than three different ways is not a case worth | |
| 33 | * optimising for. | |
| 34 | */ | |
| 35 | const MAX_BASE_LOGS = 3 | |
| 36 | ||
| 37 | /** | |
| 38 | * Whether the base branch is already failing the same way. | |
| 39 | * | |
| 40 | * Not "is the base red" but "is the base red *in the same way*". A pull | |
| 41 | * request that adds a test to a repository whose lint is broken should still | |
| 42 | * have its own test failure diagnosed, and a bot that downed tools whenever | |
| 43 | * the base was red would be useless on exactly the repositories that need it. | |
| 44 | * | |
| 45 | * Comparing classifications is an approximation, and the direction it errs in | |
| 46 | * is deliberate. Two unrelated type errors both classify as `type-error`, so a | |
| 47 | * genuine regression on a base branch that already fails to type-check reads | |
| 48 | * as inherited — Buddy declines and says why, rather than committing a guess | |
| 49 | * about code it has already been told is broken. | |
| 50 | * | |
| 51 | * @param reader - Provider slice that can list runs and read their logs | |
| 52 | * @param baseBranch - Branch the pull request targets | |
| 53 | * @param kind - Failure classified from the pull request's own run | |
| 54 | * @param logger - Optional logger | |
| 55 | * @returns Whether the base branch fails the same way | |
| 56 | * @example | |
| 57 | * ```ts | |
| 58 | * const inherited = await failsOnBaseBranch(provider, 'main', failure.kind) | |
| 59 | * ``` | |
| 60 | */ | |
| 61 | export async function failsOnBaseBranch( | |
| 62 | reader: RunHistoryReader, | |
| 63 | baseBranch: string, | |
| 64 | kind: FailureKind, | |
| 65 | logger?: Logger, | |
| 66 | ): Promise<boolean> { | |
| 67 | const log = logger ?? getDefaultLogger() | |
| 68 | ||
| 69 | try { | |
| 70 | const runs = await reader.listWorkflowRuns(baseBranch, { status: 'completed', limit: 20 }) | |
| 71 | ||
| 72 | for (const run of latestFailurePerWorkflow(runs).slice(0, MAX_BASE_LOGS)) { | |
| 73 | const baseLog = await reader.getWorkflowRunLogs(run.id) | |
| 74 | if (!baseLog) | |
| 75 | continue | |
| 76 | ||
| 77 | if (classifyFailure(baseLog).kind === kind) { | |
| 78 | log.info(`🧭 ${baseBranch} already fails the same way (run ${run.id}) — not this pull request's to fix`) | |
| 79 | return true | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | return false | |
| 84 | } | |
| 85 | catch (error) { | |
| 86 | // An unreadable history is no evidence either way, and the useful default | |
| 87 | // is to carry on: refusing to repair a real failure because a list call | |
| 88 | // failed would be a worse outcome than one repair that turns out moot. | |
| 89 | log.debug(`Could not check ${baseBranch} for a pre-existing failure: ${formatError(error)}`) | |
| 90 | return false | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Reduce a run list to the newest failure of each distinct workflow. | |
| 96 | * | |
| 97 | * Providers interleave runs from every workflow, so the newest run overall | |
| 98 | * often belongs to something unrelated. Taking the newest *per workflow* | |
| 99 | * answers the question actually being asked — is this workflow red on base | |
| 100 | * right now — and drops a workflow whose latest run has since gone green. | |
| 101 | * | |
| 102 | * @param runs - Runs, most recent first | |
| 103 | * @returns The newest run of each workflow, kept only where it failed | |
| 104 | */ | |
| 105 | function latestFailurePerWorkflow(runs: WorkflowRun[]): WorkflowRun[] { | |
| 106 | const newest = new Map<string, WorkflowRun>() | |
| 107 | ||
| 108 | for (const run of runs) { | |
| 109 | if (!newest.has(run.name)) | |
| 110 | newest.set(run.name, run) | |
| 111 | } | |
| 112 | ||
| 113 | return [...newest.values()].filter(run => run.conclusion === 'failure') | |
| 114 | } | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { AiClient } from '../ai/types' |
| 2 | 2 | import type { Logger } from '../utils/logger' |
| 3 | import type { ClassifiedFailure } from './classify' | |
| 3 | import type { ClassifiedFailure, FailureKind } from './classify' | |
| 4 | 4 | import { runAgent } from '../agent/runner' |
| 5 | 5 | import { fixCiMode } from '../agent/modes' |
| 6 | 6 | import { getDefaultLogger } from '../utils/logger' |
| @@ -24,6 +24,14 @@ export interface FixOptions { | ||
| 24 | 24 | log: string |
| 25 | 25 | /** Whether the same failure occurs on the base branch */ |
| 26 | 26 | failsOnBase?: boolean |
| 27 | /** | |
| 28 | * Resolve whether the base branch fails the same way. | |
| 29 | * | |
| 30 | * A hook rather than a value because the answer needs the classification, | |
| 31 | * which is computed here — and because it costs API calls that are only | |
| 32 | * worth spending once there is a failure to compare against. | |
| 33 | */ | |
| 34 | checkBase?: (kind: FailureKind) => Promise<boolean> | |
| 27 | 35 | /** Repository workspace */ |
| 28 | 36 | workspace: string |
| 29 | 37 | /** Branch under repair */ |
| @@ -42,6 +50,14 @@ export interface FixOptions { | ||
| 42 | 50 | * on the branch are different outcomes and the report distinguishes them. |
| 43 | 51 | */ |
| 44 | 52 | regenerateLockfile?: () => Promise<LockfileRepair> |
| 53 | /** | |
| 54 | * Re-run the failing jobs. | |
| 55 | * | |
| 56 | * Only reached for a transient failure. A re-run happens against the | |
| 57 | * original commit, so it can clear a flake and can do nothing at all for a | |
| 58 | * commit that has since been repaired. | |
| 59 | */ | |
| 60 | rerun?: () => Promise<boolean> | |
| 45 | 61 | /** Diagnose and report without changing anything */ |
| 46 | 62 | dryRun?: boolean |
| 47 | 63 | logger?: Logger |
| @@ -101,7 +117,11 @@ export async function attemptFix(options: FixOptions): Promise<FixOutcome> { | ||
| 101 | 117 | |
| 102 | 118 | // A failure that reproduces on the base branch is pre-existing. Patching |
| 103 | 119 | // around it here would attribute someone else's breakage to this change. |
| 104 | if (options.failsOnBase) { | |
| 120 | // An explicit answer wins over the hook, so a caller that already knows | |
| 121 | // does not pay for the lookup. | |
| 122 | const failsOnBase = options.failsOnBase ?? (options.checkBase ? await options.checkBase(failure.kind) : false) | |
| 123 | ||
| 124 | if (failsOnBase) { | |
| 105 | 125 | return { |
| 106 | 126 | failure, |
| 107 | 127 | action: 'skipped', |
| @@ -145,11 +165,41 @@ export async function attemptFix(options: FixOptions): Promise<FixOutcome> { | ||
| 145 | 165 | } |
| 146 | 166 | |
| 147 | 167 | if (failure.kind === 'flake') { |
| 168 | if (options.dryRun) { | |
| 169 | return { | |
| 170 | failure, | |
| 171 | action: 'retry', | |
| 172 | fixed: false, | |
| 173 | report: report(failure, 'This looks transient, and re-running the failed jobs would likely clear it. ' | |
| 174 | + 'Nothing was changed, because this was a dry run.'), | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | // Without a re-run hook the diagnosis is all there is to offer, which is | |
| 179 | // what this branch did for every provider until one could be given. | |
| 180 | if (!options.rerun) { | |
| 181 | return { | |
| 182 | failure, | |
| 183 | action: 'retry', | |
| 184 | fixed: false, | |
| 185 | report: report(failure, 'This looks transient. Re-running the failed job is likely to clear it.'), | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | logger.info('🔁 Re-running the failed jobs') | |
| 190 | const rerun = await options.rerun() | |
| 191 | ||
| 148 | 192 | return { |
| 149 | 193 | failure, |
| 150 | 194 | action: 'retry', |
| 195 | // A re-run is not a repair. The jobs have been asked to run again and | |
| 196 | // may still fail; reporting that as fixed would be a guess dressed up | |
| 197 | // as a result. | |
| 151 | 198 | fixed: false, |
| 152 | report: report(failure, 'This looks transient. Re-running the failed job is likely to clear it.'), | |
| 199 | report: report(failure, rerun | |
| 200 | ? 'This looks transient, so I re-ran the failed jobs. No model was needed.' | |
| 201 | : 'This looks transient, but I could not re-run the failed jobs — `actions: write` is the usual reason. ' | |
| 202 | + 'Re-running them from the Actions tab is likely to clear it.'), | |
| 153 | 203 | } |
| 154 | 204 | } |
| 155 | 205 | |
| @@ -1,3 +1,5 @@ | ||
| 1 | export { failsOnBaseBranch } from './base' | |
| 2 | export type { RunHistoryReader } from './base' | |
| 1 | 3 | export { |
| 2 | 4 | classifyFailure, |
| 3 | 5 | describeFailure, |
| @@ -5,4 +7,4 @@ export { | ||
| 5 | 7 | } from './classify' |
| 6 | 8 | export type { ClassifiedFailure, FailureKind } from './classify' |
| 7 | 9 | export { attemptFix } from './fix' |
| 8 | export type { FixOptions, FixOutcome } from './fix' | |
| 10 | export type { FixOptions, FixOutcome, LockfileRepair } from './fix' | |
| @@ -91,6 +91,9 @@ export class BitbucketProvider implements GitProvider { | ||
| 91 | 91 | nativeAutoMerge: false, |
| 92 | 92 | commentReactions: false, |
| 93 | 93 | ciLogs: false, |
| 94 | // Bitbucket Pipelines is a separate product with its own API surface, | |
| 95 | // and this provider reads none of it. | |
| 96 | ciRuns: false, | |
| 94 | 97 | teamReviewers: false, |
| 95 | 98 | // Bitbucket has no draft state at all. |
| 96 | 99 | draftPullRequests: false, |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { FileChange, Issue, IssueOptions, PullRequest, PullRequestOptions } from '../types' |
| 2 | 2 | import type { Logger } from '../utils/logger' |
| 3 | import type { GitProvider, ProviderCapabilities } from './provider' | |
| 3 | import type { GitProvider, ListWorkflowRunsOptions, ProviderCapabilities, WorkflowRun } from './provider' | |
| 4 | 4 | import { Buffer } from 'node:buffer' |
| 5 | 5 | import { spawn } from 'node:child_process' |
| 6 | 6 | import process from 'node:process' |
| @@ -15,6 +15,36 @@ import { getDefaultLogger } from '../utils/logger' | ||
| 15 | 15 | // plus any 40+ char alphanumeric blob that looks like a credential. |
| 16 | 16 | const TOKEN_PATTERN = /\b(?:gh[pousr]_[A-Z0-9_]{20,}|github_pat_[A-Z0-9_]{20,}|[A-Z0-9]{40,})\b/gi |
| 17 | 17 | |
| 18 | /** | |
| 19 | * Map GitHub's run status onto the three this codebase uses. | |
| 20 | * | |
| 21 | * Anything unrecognised reads as `queued` rather than `completed`: an unknown | |
| 22 | * state must not be mistaken for a finished one. | |
| 23 | */ | |
| 24 | function runStatus(raw: unknown): WorkflowRun['status'] { | |
| 25 | return raw === 'completed' || raw === 'in_progress' ? raw : 'queued' | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * Map GitHub's conclusion vocabulary onto the four this codebase uses. | |
| 30 | * | |
| 31 | * `timed_out` and `action_required` collapse into `failure`, which is how | |
| 32 | * `getPullRequestChecksState` already treats them — a run that timed out did | |
| 33 | * not pass, and a caller reasoning about red builds should see it as red. | |
| 34 | */ | |
| 35 | function runConclusion(raw: unknown): WorkflowRun['conclusion'] { | |
| 36 | if (typeof raw !== 'string') | |
| 37 | return null | |
| 38 | ||
| 39 | if (raw === 'success' || raw === 'cancelled') | |
| 40 | return raw | |
| 41 | ||
| 42 | if (raw === 'failure' || raw === 'timed_out' || raw === 'action_required') | |
| 43 | return 'failure' | |
| 44 | ||
| 45 | return 'skipped' | |
| 46 | } | |
| 47 | ||
| 18 | 48 | function sanitizeStderr(stderr: string, token?: string): string { |
| 19 | 49 | let out = stderr.replace(TOKEN_PATTERN, '[REDACTED]') |
| 20 | 50 | if (token && token.length >= 8) |
| @@ -118,6 +148,7 @@ export class GitHubProvider implements GitProvider { | ||
| 118 | 148 | nativeAutoMerge: true, |
| 119 | 149 | commentReactions: true, |
| 120 | 150 | ciLogs: true, |
| 151 | ciRuns: true, | |
| 121 | 152 | teamReviewers: true, |
| 122 | 153 | draftPullRequests: true, |
| 123 | 154 | permissionLookup: true, |
| @@ -1129,6 +1160,96 @@ export class GitHubProvider implements GitProvider { | ||
| 1129 | 1160 | } |
| 1130 | 1161 | } |
| 1131 | 1162 | |
| 1163 | /** | |
| 1164 | * List recent workflow runs for a branch, most recent first. | |
| 1165 | * | |
| 1166 | * Resolves `[]` rather than throwing when the history cannot be read. The | |
| 1167 | * caller asking is deciding whether a failure is pre-existing, and an | |
| 1168 | * unreadable history is no evidence either way — treating it as evidence | |
| 1169 | * would either mask a real regression or refuse a repair that was wanted. | |
| 1170 | * | |
| 1171 | * @param branch - Branch to list runs for | |
| 1172 | * @param options - State filter and how many runs to return | |
| 1173 | * @returns Runs, most recent first; `[]` when unreadable | |
| 1174 | * @example | |
| 1175 | * ```ts | |
| 1176 | * const runs = await provider.listWorkflowRuns('main', { status: 'completed', limit: 10 }) | |
| 1177 | * ``` | |
| 1178 | */ | |
| 1179 | async listWorkflowRuns(branch: string, options: ListWorkflowRunsOptions = {}): Promise<WorkflowRun[]> { | |
| 1180 | const query = new URLSearchParams({ branch, per_page: String(options.limit ?? 20) }) | |
| 1181 | if (options.status) | |
| 1182 | query.set('status', options.status) | |
| 1183 | ||
| 1184 | try { | |
| 1185 | const data = await this.apiRequest( | |
| 1186 | `GET /repos/${this.owner}/${this.repo}/actions/runs?${query.toString()}`, | |
| 1187 | ) | |
| 1188 | ||
| 1189 | const runs = (data?.workflow_runs ?? []) as Array<Record<string, unknown>> | |
| 1190 | return runs.map(run => ({ | |
| 1191 | id: Number(run.id), | |
| 1192 | name: String(run.name ?? ''), | |
| 1193 | headSha: String(run.head_sha ?? ''), | |
| 1194 | headBranch: String(run.head_branch ?? branch), | |
| 1195 | status: runStatus(run.status), | |
| 1196 | conclusion: runConclusion(run.conclusion), | |
| 1197 | createdAt: new Date(String(run.created_at ?? new Date().toISOString())), | |
| 1198 | })) | |
| 1199 | } | |
| 1200 | catch (error) { | |
| 1201 | this.logger.debug(`Could not list workflow runs for ${branch}: ${formatError(error)}`) | |
| 1202 | return [] | |
| 1203 | } | |
| 1204 | } | |
| 1205 | ||
| 1206 | /** | |
| 1207 | * Re-run a workflow run. | |
| 1208 | * | |
| 1209 | * GitHub re-runs against the run's *original* commit, so this clears a | |
| 1210 | * failure that a retry can clear on its own — a flake. It is not a way to | |
| 1211 | * re-check a commit that has since been repaired: that commit needs a run of | |
| 1212 | * its own, and this endpoint will not produce one. | |
| 1213 | * | |
| 1214 | * Requires `actions: write`. A refusal is reported rather than thrown, | |
| 1215 | * because the caller is a repair run and losing it over a permission it only | |
| 1216 | * wanted opportunistically would be worse than not retrying. | |
| 1217 | * | |
| 1218 | * @param runId - Run to re-run | |
| 1219 | * @param failedJobsOnly - Re-run only the jobs that failed (default true) | |
| 1220 | * @returns Whether GitHub accepted the request | |
| 1221 | */ | |
| 1222 | async rerunWorkflowRun(runId: number, failedJobsOnly = true): Promise<boolean> { | |
| 1223 | // Deliberately not `apiRequest`: this endpoint answers 201 with an empty | |
| 1224 | // body, which that helper would try to parse as JSON. | |
| 1225 | const endpoint = failedJobsOnly ? 'rerun-failed-jobs' : 'rerun' | |
| 1226 | ||
| 1227 | try { | |
| 1228 | const response = await fetchWithTimeout( | |
| 1229 | `${this.apiUrl}/repos/${this.owner}/${this.repo}/actions/runs/${runId}/${endpoint}`, | |
| 1230 | { | |
| 1231 | method: 'POST', | |
| 1232 | headers: { | |
| 1233 | 'Authorization': `Bearer ${this.token}`, | |
| 1234 | 'Accept': 'application/vnd.github+json', | |
| 1235 | 'User-Agent': 'buddy', | |
| 1236 | }, | |
| 1237 | }, | |
| 1238 | ) | |
| 1239 | ||
| 1240 | if (!response.ok) { | |
| 1241 | this.logger.warn(`Could not re-run workflow run ${runId}: ${response.status} ${response.statusText}`) | |
| 1242 | return false | |
| 1243 | } | |
| 1244 | ||
| 1245 | return true | |
| 1246 | } | |
| 1247 | catch (error) { | |
| 1248 | this.logger.warn(`Could not re-run workflow run ${runId}: ${formatError(error)}`) | |
| 1249 | return false | |
| 1250 | } | |
| 1251 | } | |
| 1252 | ||
| 1132 | 1253 | /** |
| 1133 | 1254 | * Create or update a check run on a commit. |
| 1134 | 1255 | * |
| @@ -2,15 +2,56 @@ import type { FileChange, Issue, IssueOptions, PullRequest, PullRequestOptions } | ||
| 2 | 2 | import type { Logger } from '../utils/logger' |
| 3 | 3 | import type { |
| 4 | 4 | GitProvider, |
| 5 | ListWorkflowRunsOptions, | |
| 5 | 6 | ProviderBranch, |
| 6 | 7 | ProviderCapabilities, |
| 7 | 8 | ReviewSubmission, |
| 8 | 9 | ReviewSubmissionResult, |
| 10 | WorkflowRun, | |
| 9 | 11 | } from './provider' |
| 10 | 12 | import { formatError } from '../utils/errors' |
| 11 | 13 | import { fetchWithTimeout } from '../utils/http' |
| 12 | 14 | import { getDefaultLogger } from '../utils/logger' |
| 13 | 15 | |
| 16 | /** GitLab's CI job, as much of it as buddy reads. */ | |
| 17 | interface GitLabJob { | |
| 18 | id: number | |
| 19 | name?: string | |
| 20 | status?: string | |
| 21 | ref?: string | |
| 22 | created_at?: string | |
| 23 | commit?: { id?: string } | |
| 24 | } | |
| 25 | ||
| 26 | /** | |
| 27 | * Map GitLab's job status onto the three run states this codebase uses. | |
| 28 | * | |
| 29 | * `manual` and `skipped` count as finished: nothing further will happen to | |
| 30 | * them without a person, so treating them as pending would leave a caller | |
| 31 | * waiting forever. | |
| 32 | */ | |
| 33 | function jobStatus(raw: string | undefined): WorkflowRun['status'] { | |
| 34 | if (raw === 'running') | |
| 35 | return 'in_progress' | |
| 36 | ||
| 37 | if (raw === 'success' || raw === 'failed' || raw === 'canceled' || raw === 'skipped' || raw === 'manual') | |
| 38 | return 'completed' | |
| 39 | ||
| 40 | return 'queued' | |
| 41 | } | |
| 42 | ||
| 43 | /** Map GitLab's job status onto a conclusion, or `null` while unfinished. */ | |
| 44 | function jobConclusion(raw: string | undefined): WorkflowRun['conclusion'] { | |
| 45 | switch (raw) { | |
| 46 | case 'success': return 'success' | |
| 47 | case 'failed': return 'failure' | |
| 48 | case 'canceled': return 'cancelled' | |
| 49 | case 'skipped': | |
| 50 | case 'manual': return 'skipped' | |
| 51 | default: return null | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 14 | 55 | /** GitLab's merge request, as much of it as buddy reads. */ |
| 15 | 56 | interface GitLabMergeRequest { |
| 16 | 57 | iid: number |
| @@ -102,6 +143,8 @@ export class GitLabProvider implements GitProvider { | ||
| 102 | 143 | nativeAutoMerge: true, |
| 103 | 144 | commentReactions: true, |
| 104 | 145 | ciLogs: true, |
| 146 | // Jobs, not pipelines — see `listWorkflowRuns`. | |
| 147 | ciRuns: true, | |
| 105 | 148 | teamReviewers: false, |
| 106 | 149 | draftPullRequests: true, |
| 107 | 150 | permissionLookup: true, |
| @@ -598,6 +641,72 @@ export class GitLabProvider implements GitProvider { | ||
| 598 | 641 | .catch(() => null) |
| 599 | 642 | } |
| 600 | 643 | |
| 644 | /** | |
| 645 | * List recent CI jobs for a branch, most recent first. | |
| 646 | * | |
| 647 | * Jobs rather than pipelines, deliberately. `getWorkflowRunLogs` reads a | |
| 648 | * job trace, so returning pipeline ids here would hand callers an `id` that | |
| 649 | * looks interchangeable and is not — the log read would quietly 404, or | |
| 650 | * worse, hit an unrelated job that happens to share the number. | |
| 651 | * | |
| 652 | * GitLab cannot filter `/jobs` by ref, so the branch filter is applied here. | |
| 653 | * | |
| 654 | * @param branch - Branch to list jobs for | |
| 655 | * @param options - State filter and how many jobs to return | |
| 656 | * @returns Jobs, most recent first; `[]` when unreadable | |
| 657 | */ | |
| 658 | async listWorkflowRuns(branch: string, options: ListWorkflowRunsOptions = {}): Promise<WorkflowRun[]> { | |
| 659 | const limit = options.limit ?? 20 | |
| 660 | ||
| 661 | try { | |
| 662 | // Over-fetch, because the ref filter is applied client-side and a busy | |
| 663 | // project's most recent jobs may all belong to other branches. | |
| 664 | const jobs = await this.request<GitLabJob[]>( | |
| 665 | 'GET', | |
| 666 | `/projects/${this.projectId}/jobs?per_page=${Math.min(limit * 5, 100)}`, | |
| 667 | ) | |
| 668 | ||
| 669 | return (jobs ?? []) | |
| 670 | .filter(job => job.ref === branch) | |
| 671 | .map(job => ({ | |
| 672 | id: job.id, | |
| 673 | name: job.name ?? '', | |
| 674 | headSha: job.commit?.id ?? '', | |
| 675 | headBranch: job.ref ?? branch, | |
| 676 | status: jobStatus(job.status), | |
| 677 | conclusion: jobConclusion(job.status), | |
| 678 | createdAt: new Date(job.created_at ?? new Date().toISOString()), | |
| 679 | })) | |
| 680 | .filter(run => !options.status || run.status === options.status) | |
| 681 | .slice(0, limit) | |
| 682 | } | |
| 683 | catch (error) { | |
| 684 | this.logger.debug(`Could not list CI jobs for ${branch}: ${formatError(error)}`) | |
| 685 | return [] | |
| 686 | } | |
| 687 | } | |
| 688 | ||
| 689 | /** | |
| 690 | * Retry a CI job. | |
| 691 | * | |
| 692 | * GitLab retries against the job's original commit, same as GitHub, so this | |
| 693 | * clears a failure a retry can clear on its own rather than re-checking a | |
| 694 | * commit that has since been repaired. | |
| 695 | * | |
| 696 | * @param runId - Job to retry | |
| 697 | * @returns Whether GitLab accepted the request | |
| 698 | */ | |
| 699 | async rerunWorkflowRun(runId: number): Promise<boolean> { | |
| 700 | try { | |
| 701 | await this.request('POST', `/projects/${this.projectId}/jobs/${runId}/retry`) | |
| 702 | return true | |
| 703 | } | |
| 704 | catch (error) { | |
| 705 | this.logger.warn(`Could not retry job ${runId}: ${formatError(error)}`) | |
| 706 | return false | |
| 707 | } | |
| 708 | } | |
| 709 | ||
| 601 | 710 | // -- Housekeeping -------------------------------------------------------- |
| 602 | 711 | |
| 603 | 712 | async getBuddyBranches(): Promise<ProviderBranch[]> { |
| @@ -52,6 +52,14 @@ export interface ProviderCapabilities { | ||
| 52 | 52 | commentReactions: boolean |
| 53 | 53 | /** Read CI logs for a failed run (`getWorkflowRunLogs`) */ |
| 54 | 54 | ciLogs: boolean |
| 55 | /** | |
| 56 | * Enumerate and re-run CI runs (`listWorkflowRuns`, `rerunWorkflowRun`). | |
| 57 | * | |
| 58 | * Separate from `ciLogs` because reading one run's log and reasoning about | |
| 59 | * a branch's run history are different APIs — GitLab exposes the first | |
| 60 | * against a job and the second against that job's siblings. | |
| 61 | */ | |
| 62 | ciRuns: boolean | |
| 55 | 63 | /** Request review from a team as well as individuals */ |
| 56 | 64 | teamReviewers: boolean |
| 57 | 65 | /** Open a pull request in a draft/WIP state */ |
| @@ -87,6 +95,7 @@ export const NO_CAPABILITIES: ProviderCapabilities = { | ||
| 87 | 95 | nativeAutoMerge: false, |
| 88 | 96 | commentReactions: false, |
| 89 | 97 | ciLogs: false, |
| 98 | ciRuns: false, | |
| 90 | 99 | teamReviewers: false, |
| 91 | 100 | draftPullRequests: false, |
| 92 | 101 | permissionLookup: false, |
| @@ -95,6 +104,38 @@ export const NO_CAPABILITIES: ProviderCapabilities = { | ||
| 95 | 104 | labels: false, |
| 96 | 105 | } |
| 97 | 106 | |
| 107 | /** | |
| 108 | * A CI run as the provider reports it. | |
| 109 | * | |
| 110 | * "Run" is GitHub's word. GitLab has pipelines *and* jobs, and this maps onto | |
| 111 | * jobs, because a job is what `getWorkflowRunLogs` reads there — an `id` that | |
| 112 | * could not be handed straight back to that method would be a trap. | |
| 113 | */ | |
| 114 | export interface WorkflowRun { | |
| 115 | /** Provider-side identifier, accepted by `getWorkflowRunLogs` */ | |
| 116 | id: number | |
| 117 | /** Workflow, pipeline or job name */ | |
| 118 | name: string | |
| 119 | /** Commit the run was started against */ | |
| 120 | headSha: string | |
| 121 | /** Branch the run was started on */ | |
| 122 | headBranch: string | |
| 123 | /** Whether the run has finished */ | |
| 124 | status: 'queued' | 'in_progress' | 'completed' | |
| 125 | /** How it finished, or `null` while it is still running */ | |
| 126 | conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | null | |
| 127 | /** When the run was created */ | |
| 128 | createdAt: Date | |
| 129 | } | |
| 130 | ||
| 131 | /** Narrowing for {@link GitProvider.listWorkflowRuns}. */ | |
| 132 | export interface ListWorkflowRunsOptions { | |
| 133 | /** Only runs in this state */ | |
| 134 | status?: WorkflowRun['status'] | |
| 135 | /** Most recent runs to return (default 20) */ | |
| 136 | limit?: number | |
| 137 | } | |
| 138 | ||
| 98 | 139 | /** A branch buddy created, as the provider reports it. */ |
| 99 | 140 | export interface ProviderBranch { |
| 100 | 141 | name: string |
| @@ -226,6 +267,25 @@ export interface GitProvider { | ||
| 226 | 267 | /** Requires `ciLogs`; null when logs are unavailable or expired */ |
| 227 | 268 | getWorkflowRunLogs?: (runId: number) => Promise<string | null> |
| 228 | 269 | |
| 270 | /** | |
| 271 | * Requires `ciRuns`; most recent first. | |
| 272 | * | |
| 273 | * Resolves `[]` rather than throwing when the history cannot be read. The | |
| 274 | * caller asking is deciding whether a failure is pre-existing, and an | |
| 275 | * unreadable history is no evidence either way — raising it as an error | |
| 276 | * would fail a repair run over a question it only asked in passing. | |
| 277 | */ | |
| 278 | listWorkflowRuns?: (branch: string, options?: ListWorkflowRunsOptions) => Promise<WorkflowRun[]> | |
| 279 | ||
| 280 | /** | |
| 281 | * Requires `ciRuns`; resolves false when the platform refused. | |
| 282 | * | |
| 283 | * Re-runs against the run's *original* commit, so this clears a failure that | |
| 284 | * a retry can clear on its own. It is not a way to re-check a commit that | |
| 285 | * has since been repaired — that needs a new run, not this one again. | |
| 286 | */ | |
| 287 | rerunWorkflowRun?: (runId: number, failedJobsOnly?: boolean) => Promise<boolean> | |
| 288 | ||
| 229 | 289 | // -- Housekeeping (requires `branchHousekeeping`) ------------------------- |
| 230 | 290 | |
| 231 | 291 | /** Branches buddy created */ |
| @@ -28,11 +28,13 @@ export type { | ||
| 28 | 28 | CheckRunResult, |
| 29 | 29 | GitProvider, |
| 30 | 30 | GitProviderName, |
| 31 | ListWorkflowRunsOptions, | |
| 31 | 32 | ProviderBranch, |
| 32 | 33 | ProviderCapabilities, |
| 33 | 34 | ProviderConfig, |
| 34 | 35 | ReviewSubmission, |
| 35 | 36 | ReviewSubmissionResult, |
| 37 | WorkflowRun, | |
| 36 | 38 | } from './git/provider' |
| 37 | 39 | export { diagnose, renderDoctorReport } from './doctor' |
| 38 | 40 | export type { DoctorCheck, DoctorEnvironment, DoctorReport } from './doctor' |
| @@ -10,10 +10,12 @@ export type { | ||
| 10 | 10 | CheckRunResult, |
| 11 | 11 | GitProvider, |
| 12 | 12 | GitProviderName, |
| 13 | ListWorkflowRunsOptions, | |
| 13 | 14 | ProviderBranch, |
| 14 | 15 | ProviderCapabilities, |
| 15 | 16 | ReviewSubmission, |
| 16 | 17 | ReviewSubmissionResult, |
| 18 | WorkflowRun, | |
| 17 | 19 | } from './git/provider' |
| 18 | 20 | |
| 19 | 21 | /** Advisory severity, ordered from least to most severe. */ |
| @@ -0,0 +1,205 @@ | ||
| 1 | import type { RunHistoryReader } from '../src/ci/base' | |
| 2 | import type { WorkflowRun } from '../src/git/provider' | |
| 3 | import { describe, expect, it } from 'bun:test' | |
| 4 | import { failsOnBaseBranch as check } from '../src/ci/base' | |
| 5 | import { Logger } from '../src/utils/logger' | |
| 6 | ||
| 7 | /** The check under test, silenced so a passing suite stays readable. */ | |
| 8 | function failsOnBaseBranch( | |
| 9 | reader: RunHistoryReader, | |
| 10 | baseBranch: string, | |
| 11 | kind: Parameters<typeof check>[2], | |
| 12 | ): Promise<boolean> { | |
| 13 | return check(reader, baseBranch, kind, Logger.silent()) | |
| 14 | } | |
| 15 | ||
| 16 | const TYPE_ERROR = `src/app.ts(12,5): error TS2322: Type 'string' is not assignable to type 'number'.` | |
| 17 | const LOCKFILE_DRIFT = `error: lockfile had changes, but lockfile is frozen` | |
| 18 | ||
| 19 | /** A run with sensible defaults, overridable per test. */ | |
| 20 | function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun { | |
| 21 | return { | |
| 22 | id: 1, | |
| 23 | name: 'CI', | |
| 24 | headSha: 'abc123', | |
| 25 | headBranch: 'main', | |
| 26 | status: 'completed', | |
| 27 | conclusion: 'failure', | |
| 28 | createdAt: new Date('2026-01-01T00:00:00Z'), | |
| 29 | ...overrides, | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | /** A reader over a fixed run list and a log lookup. */ | |
| 34 | function reader(runs: WorkflowRun[], logs: Record<number, string | null> = {}): RunHistoryReader { | |
| 35 | return { | |
| 36 | listWorkflowRuns: async () => runs, | |
| 37 | getWorkflowRunLogs: async id => logs[id] ?? null, | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | /** | |
| 42 | * `failsOnBase` was declared, documented and consumed by `attemptFix`, but no | |
| 43 | * production caller ever computed it — so a pull request against a broken base | |
| 44 | * branch had that breakage diagnosed, and sometimes repaired, as its own. | |
| 45 | */ | |
| 46 | describe('failsOnBaseBranch', () => { | |
| 47 | describe('what counts as inherited', () => { | |
| 48 | it('success case - the same failure on base is not this pull request\'s', async () => { | |
| 49 | const inherited = await failsOnBaseBranch( | |
| 50 | reader([run({ id: 7 })], { 7: TYPE_ERROR }), | |
| 51 | 'main', | |
| 52 | 'type-error', | |
| 53 | ) | |
| 54 | ||
| 55 | expect(inherited).toBe(true) | |
| 56 | }) | |
| 57 | ||
| 58 | it('failure case - a different failure on base does not excuse this one', async () => { | |
| 59 | // The base being red is not the question. A pull request that breaks | |
| 60 | // type-checking still owns that, even on a branch whose lock file has | |
| 61 | // drifted — and a bot that downed tools whenever base was red would be | |
| 62 | // useless on exactly the repositories that need it most. | |
| 63 | const inherited = await failsOnBaseBranch( | |
| 64 | reader([run({ id: 7 })], { 7: LOCKFILE_DRIFT }), | |
| 65 | 'main', | |
| 66 | 'type-error', | |
| 67 | ) | |
| 68 | ||
| 69 | expect(inherited).toBe(false) | |
| 70 | }) | |
| 71 | ||
| 72 | it('failure case - a green base is not inherited breakage', async () => { | |
| 73 | const inherited = await failsOnBaseBranch( | |
| 74 | reader([run({ id: 7, conclusion: 'success' })], { 7: TYPE_ERROR }), | |
| 75 | 'main', | |
| 76 | 'type-error', | |
| 77 | ) | |
| 78 | ||
| 79 | expect(inherited).toBe(false) | |
| 80 | }) | |
| 81 | ||
| 82 | it('edge case - a cancelled run is not a failure', async () => { | |
| 83 | const inherited = await failsOnBaseBranch( | |
| 84 | reader([run({ id: 7, conclusion: 'cancelled' })], { 7: TYPE_ERROR }), | |
| 85 | 'main', | |
| 86 | 'type-error', | |
| 87 | ) | |
| 88 | ||
| 89 | expect(inherited).toBe(false) | |
| 90 | }) | |
| 91 | }) | |
| 92 | ||
| 93 | describe('picking which runs to trust', () => { | |
| 94 | it('success case - only the newest run of each workflow counts', async () => { | |
| 95 | // A workflow that failed and has since gone green is not failing now. | |
| 96 | // Reading the older run would keep declining repairs on a base branch | |
| 97 | // that was fixed hours ago. | |
| 98 | const inherited = await failsOnBaseBranch( | |
| 99 | reader( | |
| 100 | [ | |
| 101 | run({ id: 9, name: 'CI', conclusion: 'success' }), | |
| 102 | run({ id: 8, name: 'CI', conclusion: 'failure' }), | |
| 103 | ], | |
| 104 | { 8: TYPE_ERROR, 9: TYPE_ERROR }, | |
| 105 | ), | |
| 106 | 'main', | |
| 107 | 'type-error', | |
| 108 | ) | |
| 109 | ||
| 110 | expect(inherited).toBe(false) | |
| 111 | }) | |
| 112 | ||
| 113 | it('success case - a second workflow still red is found', async () => { | |
| 114 | // Providers interleave runs from every workflow, so the newest run | |
| 115 | // overall often belongs to something unrelated to the failure at hand. | |
| 116 | const inherited = await failsOnBaseBranch( | |
| 117 | reader( | |
| 118 | [ | |
| 119 | run({ id: 9, name: 'Docs', conclusion: 'success' }), | |
| 120 | run({ id: 8, name: 'CI', conclusion: 'failure' }), | |
| 121 | ], | |
| 122 | { 8: TYPE_ERROR }, | |
| 123 | ), | |
| 124 | 'main', | |
| 125 | 'type-error', | |
| 126 | ) | |
| 127 | ||
| 128 | expect(inherited).toBe(true) | |
| 129 | }) | |
| 130 | ||
| 131 | it('edge case - reads at most three base logs', async () => { | |
| 132 | const reads: number[] = [] | |
| 133 | const runs = [1, 2, 3, 4, 5].map(id => run({ id, name: `w${id}` })) | |
| 134 | ||
| 135 | const inherited = await failsOnBaseBranch( | |
| 136 | { | |
| 137 | listWorkflowRuns: async () => runs, | |
| 138 | getWorkflowRunLogs: async (id) => { | |
| 139 | reads.push(id) | |
| 140 | return LOCKFILE_DRIFT | |
| 141 | }, | |
| 142 | }, | |
| 143 | 'main', | |
| 144 | 'type-error', | |
| 145 | ) | |
| 146 | ||
| 147 | expect(inherited).toBe(false) | |
| 148 | expect(reads).toHaveLength(3) | |
| 149 | }) | |
| 150 | }) | |
| 151 | ||
| 152 | describe('when the history cannot be read', () => { | |
| 153 | it('edge case - an unreadable log does not block the remaining runs', async () => { | |
| 154 | const inherited = await failsOnBaseBranch( | |
| 155 | reader( | |
| 156 | [run({ id: 8, name: 'Lint' }), run({ id: 9, name: 'CI' })], | |
| 157 | { 8: null, 9: TYPE_ERROR }, | |
| 158 | ), | |
| 159 | 'main', | |
| 160 | 'type-error', | |
| 161 | ) | |
| 162 | ||
| 163 | expect(inherited).toBe(true) | |
| 164 | }) | |
| 165 | ||
| 166 | it('failure case - a provider error reads as no evidence, not as breakage', async () => { | |
| 167 | // Declining to repair a real failure because a list call failed would be | |
| 168 | // a worse outcome than one repair that turns out to have been moot. | |
| 169 | const inherited = await failsOnBaseBranch( | |
| 170 | { | |
| 171 | listWorkflowRuns: async () => { throw new Error('403 Forbidden') }, | |
| 172 | getWorkflowRunLogs: async () => null, | |
| 173 | }, | |
| 174 | 'main', | |
| 175 | 'type-error', | |
| 176 | ) | |
| 177 | ||
| 178 | expect(inherited).toBe(false) | |
| 179 | }) | |
| 180 | ||
| 181 | it('edge case - no runs at all is not breakage', async () => { | |
| 182 | expect(await failsOnBaseBranch(reader([]), 'main', 'type-error')).toBe(false) | |
| 183 | }) | |
| 184 | }) | |
| 185 | ||
| 186 | describe('what it asks the provider for', () => { | |
| 187 | it('success case - asks only for completed runs on the base branch', async () => { | |
| 188 | let branch = '' | |
| 189 | let options: unknown | |
| 190 | ||
| 191 | await failsOnBaseBranch( | |
| 192 | { | |
| 193 | listWorkflowRuns: async (b, o) => { branch = b; options = o; return [] }, | |
| 194 | getWorkflowRunLogs: async () => null, | |
| 195 | }, | |
| 196 | 'release/v2', | |
| 197 | 'type-error', | |
| 198 | ) | |
| 199 | ||
| 200 | expect(branch).toBe('release/v2') | |
| 201 | // An in-progress run has no conclusion to compare against yet. | |
| 202 | expect(options).toMatchObject({ status: 'completed' }) | |
| 203 | }) | |
| 204 | }) | |
| 205 | }) | |
| @@ -221,3 +221,131 @@ describe('the lock-file repair reports what it actually did', () => { | ||
| 221 | 221 | expect(outcome.report).toContain('dry run') |
| 222 | 222 | }) |
| 223 | 223 | }) |
| 224 | ||
| 225 | /** | |
| 226 | * The flake branch reported that "re-running the failed job is likely to clear | |
| 227 | * it" and then re-ran nothing, because no provider could be asked to. It can | |
| 228 | * be now, so the branch does what its own report claims. | |
| 229 | */ | |
| 230 | describe('retrying a flake', () => { | |
| 231 | const base = { workspace: '/tmp', baseBranch: 'main' } | |
| 232 | ||
| 233 | it('success case - re-runs the failed jobs', async () => { | |
| 234 | let rerun = false | |
| 235 | const outcome = await attemptFix({ | |
| 236 | ...base, | |
| 237 | log: FLAKE_LOG, | |
| 238 | rerun: async () => { | |
| 239 | rerun = true | |
| 240 | return true | |
| 241 | }, | |
| 242 | }) | |
| 243 | ||
| 244 | expect(rerun).toBe(true) | |
| 245 | expect(outcome.action).toBe('retry') | |
| 246 | expect(outcome.report).toContain('re-ran the failed jobs') | |
| 247 | }) | |
| 248 | ||
| 249 | it('success case - a re-run is not reported as a repair', async () => { | |
| 250 | // The jobs were asked to run again and may still fail. Reporting that as | |
| 251 | // fixed would be a guess dressed up as a result. | |
| 252 | const outcome = await attemptFix({ ...base, log: FLAKE_LOG, rerun: async () => true }) | |
| 253 | ||
| 254 | expect(outcome.fixed).toBe(false) | |
| 255 | }) | |
| 256 | ||
| 257 | it('failure case - a refused re-run says so and points at the Actions tab', async () => { | |
| 258 | const outcome = await attemptFix({ ...base, log: FLAKE_LOG, rerun: async () => false }) | |
| 259 | ||
| 260 | expect(outcome.action).toBe('retry') | |
| 261 | expect(outcome.report).toContain('could not re-run') | |
| 262 | expect(outcome.report).toContain('Actions tab') | |
| 263 | }) | |
| 264 | ||
| 265 | it('success case - a dry run re-runs nothing', async () => { | |
| 266 | let rerun = false | |
| 267 | const outcome = await attemptFix({ | |
| 268 | ...base, | |
| 269 | log: FLAKE_LOG, | |
| 270 | dryRun: true, | |
| 271 | rerun: async () => { | |
| 272 | rerun = true | |
| 273 | return true | |
| 274 | }, | |
| 275 | }) | |
| 276 | ||
| 277 | expect(rerun).toBe(false) | |
| 278 | expect(outcome.report).toContain('dry run') | |
| 279 | }) | |
| 280 | ||
| 281 | it('edge case - a provider that cannot re-run still gets the diagnosis', async () => { | |
| 282 | const outcome = await attemptFix({ ...base, log: FLAKE_LOG }) | |
| 283 | ||
| 284 | expect(outcome.action).toBe('retry') | |
| 285 | expect(outcome.report).toContain('transient') | |
| 286 | }) | |
| 287 | }) | |
| 288 | ||
| 289 | /** | |
| 290 | * `failsOnBase` could only ever be answered by a caller that already knew. | |
| 291 | * `checkBase` lets one work it out, which is what makes the guard reachable. | |
| 292 | */ | |
| 293 | describe('asking whether the base branch already fails', () => { | |
| 294 | const base = { workspace: '/tmp', baseBranch: 'main' } | |
| 295 | ||
| 296 | it('success case - the hook is asked with the classification', async () => { | |
| 297 | // The comparison is per-kind, so the hook cannot be called before the | |
| 298 | // failure at hand has been classified. | |
| 299 | let asked: string | undefined | |
| 300 | await attemptFix({ | |
| 301 | ...base, | |
| 302 | log: LOCKFILE_LOG, | |
| 303 | checkBase: async (kind) => { | |
| 304 | asked = kind | |
| 305 | return false | |
| 306 | }, | |
| 307 | regenerateLockfile: async () => ({ regenerated: true, pushed: true }), | |
| 308 | }) | |
| 309 | ||
| 310 | expect(asked).toBe('lockfile-drift') | |
| 311 | }) | |
| 312 | ||
| 313 | it('success case - an inherited failure is declined before any repair', async () => { | |
| 314 | let regenerated = false | |
| 315 | const outcome = await attemptFix({ | |
| 316 | ...base, | |
| 317 | log: LOCKFILE_LOG, | |
| 318 | checkBase: async () => true, | |
| 319 | regenerateLockfile: async () => { | |
| 320 | regenerated = true | |
| 321 | return { regenerated: true, pushed: true } | |
| 322 | }, | |
| 323 | }) | |
| 324 | ||
| 325 | expect(regenerated).toBe(false) | |
| 326 | expect(outcome.action).toBe('skipped') | |
| 327 | expect(outcome.report).toContain('base branch') | |
| 328 | }) | |
| 329 | ||
| 330 | it('success case - an explicit answer skips the lookup', async () => { | |
| 331 | // A caller that already knows should not pay for the API calls. | |
| 332 | let asked = false | |
| 333 | await attemptFix({ | |
| 334 | ...base, | |
| 335 | log: TYPE_ERROR_LOG, | |
| 336 | failsOnBase: false, | |
| 337 | checkBase: async () => { | |
| 338 | asked = true | |
| 339 | return true | |
| 340 | }, | |
| 341 | }) | |
| 342 | ||
| 343 | expect(asked).toBe(false) | |
| 344 | }) | |
| 345 | ||
| 346 | it('edge case - no hook at all leaves the guard closed', async () => { | |
| 347 | const outcome = await attemptFix({ ...base, log: FLAKE_LOG }) | |
| 348 | ||
| 349 | expect(outcome.action).not.toBe('skipped') | |
| 350 | }) | |
| 351 | }) | |
| @@ -0,0 +1,211 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { GitHubProvider } from '../src/git/github-provider' | |
| 3 | import { GitLabProvider } from '../src/git/gitlab-provider' | |
| 4 | import { Logger } from '../src/utils/logger' | |
| 5 | ||
| 6 | const realFetch = globalThis.fetch | |
| 7 | ||
| 8 | afterEach(() => { | |
| 9 | globalThis.fetch = realFetch | |
| 10 | }) | |
| 11 | ||
| 12 | /** Record every request and answer each with a canned body. */ | |
| 13 | function stub(body: unknown, status = 200): { urls: string[], methods: string[] } { | |
| 14 | const urls: string[] = [] | |
| 15 | const methods: string[] = [] | |
| 16 | ||
| 17 | globalThis.fetch = (async (input: string, init?: RequestInit) => { | |
| 18 | urls.push(String(input)) | |
| 19 | methods.push(init?.method ?? 'GET') | |
| 20 | return new Response(typeof body === 'string' ? body : JSON.stringify(body), { | |
| 21 | status, | |
| 22 | headers: { 'content-type': 'application/json' }, | |
| 23 | }) | |
| 24 | }) as unknown as typeof fetch | |
| 25 | ||
| 26 | return { urls, methods } | |
| 27 | } | |
| 28 | ||
| 29 | function github(): GitHubProvider { | |
| 30 | return new GitHubProvider('token', 'owner', 'repo', false, undefined, 'https://api.github.test', Logger.silent()) | |
| 31 | } | |
| 32 | ||
| 33 | function gitlab(): GitLabProvider { | |
| 34 | return new GitLabProvider('token', 'group', 'repo', 'https://gitlab.test/api/v4', Logger.silent()) | |
| 35 | } | |
| 36 | ||
| 37 | describe('listing workflow runs', () => { | |
| 38 | describe('github', () => { | |
| 39 | it('success case - maps a run onto the shared shape', async () => { | |
| 40 | stub({ | |
| 41 | workflow_runs: [{ | |
| 42 | id: 42, | |
| 43 | name: 'CI', | |
| 44 | head_sha: 'abc123', | |
| 45 | head_branch: 'main', | |
| 46 | status: 'completed', | |
| 47 | conclusion: 'failure', | |
| 48 | created_at: '2026-01-01T00:00:00Z', | |
| 49 | }], | |
| 50 | }) | |
| 51 | ||
| 52 | const [run] = await github().listWorkflowRuns('main') | |
| 53 | ||
| 54 | expect(run).toEqual({ | |
| 55 | id: 42, | |
| 56 | name: 'CI', | |
| 57 | headSha: 'abc123', | |
| 58 | headBranch: 'main', | |
| 59 | status: 'completed', | |
| 60 | conclusion: 'failure', | |
| 61 | createdAt: new Date('2026-01-01T00:00:00Z'), | |
| 62 | }) | |
| 63 | }) | |
| 64 | ||
| 65 | it('success case - asks for the branch and the state requested', async () => { | |
| 66 | const { urls } = stub({ workflow_runs: [] }) | |
| 67 | await github().listWorkflowRuns('release/v2', { status: 'completed', limit: 5 }) | |
| 68 | ||
| 69 | expect(urls[0]).toContain('branch=release%2Fv2') | |
| 70 | expect(urls[0]).toContain('status=completed') | |
| 71 | expect(urls[0]).toContain('per_page=5') | |
| 72 | }) | |
| 73 | ||
| 74 | it('success case - a timed-out run reads as a failure', async () => { | |
| 75 | // `getPullRequestChecksState` already treats these as failures, and a | |
| 76 | // caller reasoning about red builds should see the same thing here. | |
| 77 | stub({ workflow_runs: [{ id: 1, status: 'completed', conclusion: 'timed_out' }] }) | |
| 78 | ||
| 79 | expect((await github().listWorkflowRuns('main'))[0].conclusion).toBe('failure') | |
| 80 | }) | |
| 81 | ||
| 82 | it('success case - a run still going has no conclusion', async () => { | |
| 83 | stub({ workflow_runs: [{ id: 1, status: 'in_progress', conclusion: null }] }) | |
| 84 | const [run] = await github().listWorkflowRuns('main') | |
| 85 | ||
| 86 | expect(run.status).toBe('in_progress') | |
| 87 | expect(run.conclusion).toBeNull() | |
| 88 | }) | |
| 89 | ||
| 90 | it('edge case - an unknown status is not mistaken for a finished one', async () => { | |
| 91 | stub({ workflow_runs: [{ id: 1, status: 'requested', conclusion: null }] }) | |
| 92 | ||
| 93 | expect((await github().listWorkflowRuns('main'))[0].status).toBe('queued') | |
| 94 | }) | |
| 95 | ||
| 96 | it('failure case - an unreadable history is no evidence rather than an error', async () => { | |
| 97 | // The caller is deciding whether a failure is pre-existing. Throwing | |
| 98 | // would fail a repair run over a question it only asked in passing. | |
| 99 | stub({ message: 'Not Found' }, 404) | |
| 100 | ||
| 101 | expect(await github().listWorkflowRuns('main')).toEqual([]) | |
| 102 | }) | |
| 103 | }) | |
| 104 | ||
| 105 | describe('gitlab', () => { | |
| 106 | it('success case - returns jobs, whose ids getWorkflowRunLogs accepts', async () => { | |
| 107 | // Pipelines would be the obvious mapping and the wrong one: the log read | |
| 108 | // on GitLab is a job trace, so a pipeline id here would be an `id` that | |
| 109 | // looks interchangeable and is not. | |
| 110 | const { urls } = stub([{ | |
| 111 | id: 77, | |
| 112 | name: 'test', | |
| 113 | status: 'failed', | |
| 114 | ref: 'main', | |
| 115 | created_at: '2026-01-01T00:00:00Z', | |
| 116 | commit: { id: 'abc123' }, | |
| 117 | }]) | |
| 118 | ||
| 119 | const [run] = await gitlab().listWorkflowRuns('main') | |
| 120 | ||
| 121 | expect(urls[0]).toContain('/jobs') | |
| 122 | expect(run.id).toBe(77) | |
| 123 | expect(run.conclusion).toBe('failure') | |
| 124 | expect(run.headSha).toBe('abc123') | |
| 125 | }) | |
| 126 | ||
| 127 | it('success case - filters to the branch asked for', async () => { | |
| 128 | // GitLab cannot filter `/jobs` by ref, so it has to happen here. | |
| 129 | stub([ | |
| 130 | { id: 1, status: 'failed', ref: 'other' }, | |
| 131 | { id: 2, status: 'failed', ref: 'main' }, | |
| 132 | ]) | |
| 133 | ||
| 134 | const runs = await gitlab().listWorkflowRuns('main') | |
| 135 | ||
| 136 | expect(runs.map(run => run.id)).toEqual([2]) | |
| 137 | }) | |
| 138 | ||
| 139 | it('success case - a manual job counts as finished', async () => { | |
| 140 | // Nothing further happens to it without a person, so treating it as | |
| 141 | // pending would leave a caller waiting forever. | |
| 142 | stub([{ id: 1, status: 'manual', ref: 'main' }]) | |
| 143 | const [run] = await gitlab().listWorkflowRuns('main') | |
| 144 | ||
| 145 | expect(run.status).toBe('completed') | |
| 146 | expect(run.conclusion).toBe('skipped') | |
| 147 | }) | |
| 148 | ||
| 149 | it('success case - honours the state filter', async () => { | |
| 150 | stub([ | |
| 151 | { id: 1, status: 'running', ref: 'main' }, | |
| 152 | { id: 2, status: 'success', ref: 'main' }, | |
| 153 | ]) | |
| 154 | ||
| 155 | const runs = await gitlab().listWorkflowRuns('main', { status: 'completed' }) | |
| 156 | ||
| 157 | expect(runs.map(run => run.id)).toEqual([2]) | |
| 158 | }) | |
| 159 | ||
| 160 | it('failure case - an unreadable history is no evidence rather than an error', async () => { | |
| 161 | stub({ message: '500' }, 500) | |
| 162 | ||
| 163 | expect(await gitlab().listWorkflowRuns('main')).toEqual([]) | |
| 164 | }) | |
| 165 | }) | |
| 166 | }) | |
| 167 | ||
| 168 | describe('re-running a failed run', () => { | |
| 169 | it('success case - github re-runs only the failed jobs by default', async () => { | |
| 170 | const { urls, methods } = stub({}, 201) | |
| 171 | ||
| 172 | expect(await github().rerunWorkflowRun(99)).toBe(true) | |
| 173 | expect(methods[0]).toBe('POST') | |
| 174 | expect(urls[0]).toContain('/actions/runs/99/rerun-failed-jobs') | |
| 175 | }) | |
| 176 | ||
| 177 | it('success case - github can be asked for the whole run', async () => { | |
| 178 | const { urls } = stub({}, 201) | |
| 179 | await github().rerunWorkflowRun(99, false) | |
| 180 | ||
| 181 | expect(urls[0]).toEndWith('/actions/runs/99/rerun') | |
| 182 | }) | |
| 183 | ||
| 184 | it('failure case - a refusal is reported rather than thrown', async () => { | |
| 185 | // `actions: write` is the usual reason, and losing the repair run over a | |
| 186 | // permission it only wanted opportunistically would be worse. | |
| 187 | stub({ message: 'Resource not accessible by integration' }, 403) | |
| 188 | ||
| 189 | expect(await github().rerunWorkflowRun(99)).toBe(false) | |
| 190 | }) | |
| 191 | ||
| 192 | it('edge case - an empty 201 body is a success, not a parse error', async () => { | |
| 193 | globalThis.fetch = (async () => new Response('', { status: 201 })) as unknown as typeof fetch | |
| 194 | ||
| 195 | expect(await github().rerunWorkflowRun(99)).toBe(true) | |
| 196 | }) | |
| 197 | ||
| 198 | it('success case - gitlab retries the job', async () => { | |
| 199 | const { urls, methods } = stub({}, 201) | |
| 200 | ||
| 201 | expect(await gitlab().rerunWorkflowRun(77)).toBe(true) | |
| 202 | expect(methods[0]).toBe('POST') | |
| 203 | expect(urls[0]).toContain('/jobs/77/retry') | |
| 204 | }) | |
| 205 | ||
| 206 | it('failure case - gitlab reports a refusal rather than throwing', async () => { | |
| 207 | stub({ message: 'forbidden' }, 403) | |
| 208 | ||
| 209 | expect(await gitlab().rerunWorkflowRun(77)).toBe(false) | |
| 210 | }) | |
| 211 | }) | |
| @@ -34,6 +34,7 @@ export function runProviderConformance(name: string, create: () => GitProvider): | ||
| 34 | 34 | 'nativeAutoMerge', |
| 35 | 35 | 'commentReactions', |
| 36 | 36 | 'ciLogs', |
| 37 | 'ciRuns', | |
| 37 | 38 | 'teamReviewers', |
| 38 | 39 | 'draftPullRequests', |
| 39 | 40 | 'permissionLookup', |
| @@ -59,6 +60,8 @@ export function runProviderConformance(name: string, create: () => GitProvider): | ||
| 59 | 60 | ['nativeAutoMerge', 'enableAutoMerge'], |
| 60 | 61 | ['commentReactions', 'reactToComment'], |
| 61 | 62 | ['ciLogs', 'getWorkflowRunLogs'], |
| 63 | ['ciRuns', 'listWorkflowRuns'], | |
| 64 | ['ciRuns', 'rerunWorkflowRun'], | |
| 62 | 65 | ['permissionLookup', 'hasWriteAccess'], |
| 63 | 66 | ['branchHousekeeping', 'cleanupStaleBranches'], |
| 64 | 67 | ['reopenPullRequests', 'reopenPullRequest'], |