ReviewOS

also looking at this

stacks/buddy

feat(ci): let buddy see whose failure it is, and retry the ones worth retrying

#1410
Merged glennmichael123 wants to merge fix/ci-workflow-runs into main
16 files +1092 -14
bin/cli.tsmodified+24-2
Changes to bin/cli.ts
@@ -1,5 +1,6 @@
11#!/usr/bin/env bun
22
3import type { FailureKind } from '../src/ci/classify'
34import type { GitProvider } from '../src/git/provider'
45import type { ReviewFinding } from '../src/review/findings'
56import type { ReviewFormat } from '../src/review/local'
@@ -1355,7 +1356,7 @@ cli
13551356 return
13561357 }
13571358
1358 const { attemptFix } = await import('../src/ci')
1359 const { attemptFix, failsOnBaseBranch } = await import('../src/ci')
13591360 const { parseFixAttempts, upsertFixAttempts } = await import('../src/ci/attempts')
13601361 const { createAiClient } = await import('../src/ai')
13611362
@@ -1368,15 +1369,36 @@ cli
13681369 pullRequest = open.find(candidate => candidate.number === prNumber)
13691370 }
13701371 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')
13711379
13721380 const outcome = await attemptFix({
13731381 log,
13741382 priorAttempts,
13751383 workspace: process.cwd(),
1376 baseBranch: config.repository?.baseBranch ?? 'main',
1384 baseBranch,
13771385 ai: createAiClient(config, logger),
13781386 logger,
13791387 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) } : {}),
13801402 regenerateLockfile: async () => {
13811403 const { regenerateLockFile, detectRequiredPackageManagers, getAllLockFilePaths } = await import('../src/utils/lock-file')
13821404 const { commitAndPush } = await import('../src/utils/git')
docs/advanced/providers.mdmodified+8-0
Changes to docs/advanced/providers.md
@@ -63,6 +63,7 @@ if (supports(provider, 'pinIssues', 'pinIssue'))
6363| `nativeAutoMerge` | `enableAutoMerge` |
6464| `commentReactions` | `reactToComment` |
6565| `ciLogs` | `getWorkflowRunLogs` |
66| `ciRuns` | `listWorkflowRuns`, `rerunWorkflowRun` |
6667| `teamReviewers` | Requesting review from a team |
6768| `draftPullRequests` | Opening a pull request as a draft |
6869| `permissionLookup` | `hasWriteAccess` |
@@ -81,6 +82,7 @@ if (supports(provider, 'pinIssues', 'pinIssue'))
8182| `nativeAutoMerge` | ✅ | ✅ (merge when pipeline succeeds) | ❌ |
8283| `commentReactions` | ✅ | ✅ (award emoji) | ❌ |
8384| `ciLogs` | ✅ | ✅ | ❌ |
85| `ciRuns` | ✅ | ✅ (jobs) | ❌ |
8486| `teamReviewers` | ✅ | ❌ | ❌ |
8587| `draftPullRequests` | ✅ | ✅ (title prefix) | ❌ |
8688| `permissionLookup` | ✅ | ✅ | ✅ |
@@ -88,6 +90,12 @@ if (supports(provider, 'pinIssues', 'pinIssue'))
8890| `reopenPullRequests` | ✅ | ✅ | ❌ |
8991| `labels` | ✅ | ✅ | ❌ |
9092
93`ciLogs` and `ciRuns` are separate for GitLab's sake. Reading one run's log
94and reasoning about a branch's run history are the same API on GitHub and two
95different ones on GitLab, which has pipelines *and* jobs. `listWorkflowRuns`
96returns jobs there, deliberately: a job is what `getWorkflowRunLogs` reads, and
97an `id` that could not be handed straight back to that method would be a trap.
98
9199Two of these rows exist *because* Bitbucket forced the distinction. A declined
92100Bitbucket pull request is final — there is no reopen — and Bitbucket has no
93101labels on pull requests or issues at all. Rather than have those operations
docs/features/ci-repair.mdmodified+45-7
Changes to docs/features/ci-repair.md
@@ -90,6 +90,28 @@ the pull request.
9090A comment cannot say *which* run failed, so `@buddy fix-ci` in a thread replies
9191pointing at that job rather than guessing.
9292
93### Whose failure is it
94
95Before anything else, Buddy asks whether the base branch is already failing the
96same way. Not *is the base red* — that would down tools on exactly the
97repositories that need the help most — but is it red **in the same way**: the
98newest run of each workflow on the base branch is classified from its own log,
99and compared against the failure at hand.
100
101If they match, Buddy declines and says so. Repairing an inherited failure on a
102pull request attributes someone else's regression to that change, and hides the
103regression where nobody is looking for it.
104
105Comparing classifications is an approximation, and it errs deliberately. Two
106unrelated type errors both classify as `type-error`, so a genuine regression on
107a base branch that already fails to type-check reads as inherited — Buddy
108declines and explains, rather than committing a guess about code it has already
109been told is broken.
110
111This needs the [`ciRuns` capability](/advanced/providers). A provider without it
112still gets the full diagnosis; it just cannot tell an inherited failure from a
113new one.
114
93115## From the CLI
94116
95117```bash
@@ -134,24 +156,40 @@ The log is matched against failure signatures before any model is involved. That
134156| Kind | What it means | Repair |
135157| --- | --- | --- |
136158| `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 |
138160| `install` | Dependencies could not be resolved | Agent, or reported |
139161| `type-error` | The change does not type-check | Agent |
140162| `test-failure` | An assertion failed | Agent, or reported |
141163| `lint` | Lint or formatting violations | Agent |
142164| `unknown` | Nothing recognisable | Reported, with the interesting log lines |
143165
144### Re-running after a mechanical fix
166### Re-running a flake
167
168A transient failure needs no repair, only another go at the same commit — so
169that is what Buddy does. It re-runs the failed jobs of the run it was handed
170and says so in its comment. This needs `actions: write`, which the generated
171workflow already grants; without it Buddy reports the diagnosis and points you
172at the Actions tab.
173
174The attempt counter is what keeps this from becoming a loop. A re-run that
175fails again brings Buddy straight back to the same pull request, and the count
176lives on the pull request precisely so it survives that.
177
178### Why a mechanical fix does not go green by itself
145179
146180The 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.
181stays red. Two things combine to cause that, and neither has a way around it
182here:
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.
151189
152190So the fix lands, and the checks need re-running: from the Actions tab, or by
153191the next push to the branch. Buddy tells you which of the two happened in the
154comment it leaves.
192comment it leaves. This is the one case the re-run above cannot cover.
155193
156194With no AI provider configured, classification and the mechanical repairs still work. You lose the agent-driven fixes, not the diagnosis.
157195
src/ci/base.tsadded+114-0
Changes to src/ci/base.ts
@@ -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 */
9import type { ListWorkflowRunsOptions, WorkflowRun } from '../git/provider'
10import type { Logger } from '../utils/logger'
11import type { FailureKind } from './classify'
12import { formatError } from '../utils/errors'
13import { getDefaultLogger } from '../utils/logger'
14import { 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 */
23export 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 */
35const 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 */
61export 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 */
105function 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}
src/ci/fix.tsmodified+53-3
Changes to src/ci/fix.ts
@@ -1,6 +1,6 @@
11import type { AiClient } from '../ai/types'
22import type { Logger } from '../utils/logger'
3import type { ClassifiedFailure } from './classify'
3import type { ClassifiedFailure, FailureKind } from './classify'
44import { runAgent } from '../agent/runner'
55import { fixCiMode } from '../agent/modes'
66import { getDefaultLogger } from '../utils/logger'
@@ -24,6 +24,14 @@ export interface FixOptions {
2424 log: string
2525 /** Whether the same failure occurs on the base branch */
2626 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>
2735 /** Repository workspace */
2836 workspace: string
2937 /** Branch under repair */
@@ -42,6 +50,14 @@ export interface FixOptions {
4250 * on the branch are different outcomes and the report distinguishes them.
4351 */
4452 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>
4561 /** Diagnose and report without changing anything */
4662 dryRun?: boolean
4763 logger?: Logger
@@ -101,7 +117,11 @@ export async function attemptFix(options: FixOptions): Promise<FixOutcome> {
101117
102118 // A failure that reproduces on the base branch is pre-existing. Patching
103119 // 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) {
105125 return {
106126 failure,
107127 action: 'skipped',
@@ -145,11 +165,41 @@ export async function attemptFix(options: FixOptions): Promise<FixOutcome> {
145165 }
146166
147167 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
148192 return {
149193 failure,
150194 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.
151198 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.'),
153203 }
154204 }
155205
src/ci/index.tsmodified+3-1
Changes to src/ci/index.ts
@@ -1,3 +1,5 @@
1export { failsOnBaseBranch } from './base'
2export type { RunHistoryReader } from './base'
13export {
24 classifyFailure,
35 describeFailure,
@@ -5,4 +7,4 @@ export {
57} from './classify'
68export type { ClassifiedFailure, FailureKind } from './classify'
79export { attemptFix } from './fix'
8export type { FixOptions, FixOutcome } from './fix'
10export type { FixOptions, FixOutcome, LockfileRepair } from './fix'
src/git/bitbucket-provider.tsmodified+3-0
Changes to src/git/bitbucket-provider.ts
@@ -91,6 +91,9 @@ export class BitbucketProvider implements GitProvider {
9191 nativeAutoMerge: false,
9292 commentReactions: false,
9393 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,
9497 teamReviewers: false,
9598 // Bitbucket has no draft state at all.
9699 draftPullRequests: false,
src/git/github-provider.tsmodified+122-1
Changes to src/git/github-provider.ts
@@ -1,6 +1,6 @@
11import type { FileChange, Issue, IssueOptions, PullRequest, PullRequestOptions } from '../types'
22import type { Logger } from '../utils/logger'
3import type { GitProvider, ProviderCapabilities } from './provider'
3import type { GitProvider, ListWorkflowRunsOptions, ProviderCapabilities, WorkflowRun } from './provider'
44import { Buffer } from 'node:buffer'
55import { spawn } from 'node:child_process'
66import process from 'node:process'
@@ -15,6 +15,36 @@ import { getDefaultLogger } from '../utils/logger'
1515// plus any 40+ char alphanumeric blob that looks like a credential.
1616const TOKEN_PATTERN = /\b(?:gh[pousr]_[A-Z0-9_]{20,}|github_pat_[A-Z0-9_]{20,}|[A-Z0-9]{40,})\b/gi
1717
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 */
24function 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 */
35function 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
1848function sanitizeStderr(stderr: string, token?: string): string {
1949 let out = stderr.replace(TOKEN_PATTERN, '[REDACTED]')
2050 if (token && token.length >= 8)
@@ -118,6 +148,7 @@ export class GitHubProvider implements GitProvider {
118148 nativeAutoMerge: true,
119149 commentReactions: true,
120150 ciLogs: true,
151 ciRuns: true,
121152 teamReviewers: true,
122153 draftPullRequests: true,
123154 permissionLookup: true,
@@ -1129,6 +1160,96 @@ export class GitHubProvider implements GitProvider {
11291160 }
11301161 }
11311162
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
11321253 /**
11331254 * Create or update a check run on a commit.
11341255 *
src/git/gitlab-provider.tsmodified+109-0
Changes to src/git/gitlab-provider.ts
@@ -2,15 +2,56 @@ import type { FileChange, Issue, IssueOptions, PullRequest, PullRequestOptions }
22import type { Logger } from '../utils/logger'
33import type {
44 GitProvider,
5 ListWorkflowRunsOptions,
56 ProviderBranch,
67 ProviderCapabilities,
78 ReviewSubmission,
89 ReviewSubmissionResult,
10 WorkflowRun,
911} from './provider'
1012import { formatError } from '../utils/errors'
1113import { fetchWithTimeout } from '../utils/http'
1214import { getDefaultLogger } from '../utils/logger'
1315
16/** GitLab's CI job, as much of it as buddy reads. */
17interface 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 */
33function 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. */
44function 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
1455/** GitLab's merge request, as much of it as buddy reads. */
1556interface GitLabMergeRequest {
1657 iid: number
@@ -102,6 +143,8 @@ export class GitLabProvider implements GitProvider {
102143 nativeAutoMerge: true,
103144 commentReactions: true,
104145 ciLogs: true,
146 // Jobs, not pipelines — see `listWorkflowRuns`.
147 ciRuns: true,
105148 teamReviewers: false,
106149 draftPullRequests: true,
107150 permissionLookup: true,
@@ -598,6 +641,72 @@ export class GitLabProvider implements GitProvider {
598641 .catch(() => null)
599642 }
600643
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
601710 // -- Housekeeping --------------------------------------------------------
602711
603712 async getBuddyBranches(): Promise<ProviderBranch[]> {
src/git/provider.tsmodified+60-0
Changes to src/git/provider.ts
@@ -52,6 +52,14 @@ export interface ProviderCapabilities {
5252 commentReactions: boolean
5353 /** Read CI logs for a failed run (`getWorkflowRunLogs`) */
5454 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
5563 /** Request review from a team as well as individuals */
5664 teamReviewers: boolean
5765 /** Open a pull request in a draft/WIP state */
@@ -87,6 +95,7 @@ export const NO_CAPABILITIES: ProviderCapabilities = {
8795 nativeAutoMerge: false,
8896 commentReactions: false,
8997 ciLogs: false,
98 ciRuns: false,
9099 teamReviewers: false,
91100 draftPullRequests: false,
92101 permissionLookup: false,
@@ -95,6 +104,38 @@ export const NO_CAPABILITIES: ProviderCapabilities = {
95104 labels: false,
96105}
97106
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 */
114export 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}. */
132export 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
98139/** A branch buddy created, as the provider reports it. */
99140export interface ProviderBranch {
100141 name: string
@@ -226,6 +267,25 @@ export interface GitProvider {
226267 /** Requires `ciLogs`; null when logs are unavailable or expired */
227268 getWorkflowRunLogs?: (runId: number) => Promise<string | null>
228269
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
229289 // -- Housekeeping (requires `branchHousekeeping`) -------------------------
230290
231291 /** Branches buddy created */
src/index.tsmodified+2-0
Changes to src/index.ts
@@ -28,11 +28,13 @@ export type {
2828 CheckRunResult,
2929 GitProvider,
3030 GitProviderName,
31 ListWorkflowRunsOptions,
3132 ProviderBranch,
3233 ProviderCapabilities,
3334 ProviderConfig,
3435 ReviewSubmission,
3536 ReviewSubmissionResult,
37 WorkflowRun,
3638} from './git/provider'
3739export { diagnose, renderDoctorReport } from './doctor'
3840export type { DoctorCheck, DoctorEnvironment, DoctorReport } from './doctor'
src/types.tsmodified+2-0
Changes to src/types.ts
@@ -10,10 +10,12 @@ export type {
1010 CheckRunResult,
1111 GitProvider,
1212 GitProviderName,
13 ListWorkflowRunsOptions,
1314 ProviderBranch,
1415 ProviderCapabilities,
1516 ReviewSubmission,
1617 ReviewSubmissionResult,
18 WorkflowRun,
1719} from './git/provider'
1820
1921/** Advisory severity, ordered from least to most severe. */
test/ci-base.test.tsadded+205-0
Changes to test/ci-base.test.ts
@@ -0,0 +1,205 @@
1import type { RunHistoryReader } from '../src/ci/base'
2import type { WorkflowRun } from '../src/git/provider'
3import { describe, expect, it } from 'bun:test'
4import { failsOnBaseBranch as check } from '../src/ci/base'
5import { Logger } from '../src/utils/logger'
6
7/** The check under test, silenced so a passing suite stays readable. */
8function 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
16const TYPE_ERROR = `src/app.ts(12,5): error TS2322: Type 'string' is not assignable to type 'number'.`
17const LOCKFILE_DRIFT = `error: lockfile had changes, but lockfile is frozen`
18
19/** A run with sensible defaults, overridable per test. */
20function 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. */
34function 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 */
46describe('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})
test/ci-fix.test.tsmodified+128-0
Changes to test/ci-fix.test.ts
@@ -221,3 +221,131 @@ describe('the lock-file repair reports what it actually did', () => {
221221 expect(outcome.report).toContain('dry run')
222222 })
223223})
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 */
230describe('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 */
293describe('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})
test/ci-runs.test.tsadded+211-0
Changes to test/ci-runs.test.ts
@@ -0,0 +1,211 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { GitHubProvider } from '../src/git/github-provider'
3import { GitLabProvider } from '../src/git/gitlab-provider'
4import { Logger } from '../src/utils/logger'
5
6const realFetch = globalThis.fetch
7
8afterEach(() => {
9 globalThis.fetch = realFetch
10})
11
12/** Record every request and answer each with a canned body. */
13function 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
29function github(): GitHubProvider {
30 return new GitHubProvider('token', 'owner', 'repo', false, undefined, 'https://api.github.test', Logger.silent())
31}
32
33function gitlab(): GitLabProvider {
34 return new GitLabProvider('token', 'group', 'repo', 'https://gitlab.test/api/v4', Logger.silent())
35}
36
37describe('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
168describe('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})
test/git/provider-conformance.tsmodified+3-0
Changes to test/git/provider-conformance.ts
@@ -34,6 +34,7 @@ export function runProviderConformance(name: string, create: () => GitProvider):
3434 'nativeAutoMerge',
3535 'commentReactions',
3636 'ciLogs',
37 'ciRuns',
3738 'teamReviewers',
3839 'draftPullRequests',
3940 'permissionLookup',
@@ -59,6 +60,8 @@ export function runProviderConformance(name: string, create: () => GitProvider):
5960 ['nativeAutoMerge', 'enableAutoMerge'],
6061 ['commentReactions', 'reactToComment'],
6162 ['ciLogs', 'getWorkflowRunLogs'],
63 ['ciRuns', 'listWorkflowRuns'],
64 ['ciRuns', 'rerunWorkflowRun'],
6265 ['permissionLookup', 'hasWriteAccess'],
6366 ['branchHousekeeping', 'cleanupStaleBranches'],
6467 ['reopenPullRequests', 'reopenPullRequest'],