diff --git a/.github/workflows/label-pr-review-state-fork-review-followup.yml b/.github/workflows/label-pr-review-state-fork-review-followup.yml new file mode 100644 index 0000000000..7df5fda00a --- /dev/null +++ b/.github/workflows/label-pr-review-state-fork-review-followup.yml @@ -0,0 +1,116 @@ +name: Label PR review state fork review follow-up + +on: + # Fork pull_request_review runs of "Label PR review state" get a read-only + # token and skip their label writes (issue #2046). When such a run + # completes, this privileged follow-up resolves the fork PRs and dispatches + # a workflow_dispatch run of "Label PR review state", which reconciles them + # and writes the computed labels. The follow-up lives in its own workflow + # because GitHub rejects a workflow that lists itself in + # workflow_run.workflows (issue #2086). It takes no artifact input and + # never checks out code, so no untrusted PR code or artifacts are touched. + workflow_run: + workflows: ["Label PR review state"] + types: [completed] + +permissions: + # This privileged workflow only reads PR metadata and dispatches the + # reconciliation workflow. All unspecified permissions, including contents, + # are none; no fork code or configuration is checked out or executed. + actions: write + pull-requests: read + +# Each completed run gets its own group: one run can resolve several PRs, and +# a shared key would let an unrelated completion supersede queued dispatches. +concurrency: + group: label-pr-review-state-fork-review-followup-${{ github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + dispatch: + name: Zoo Code / dispatch fork review reconciliation + # Loop guard (issue #2046): follow up only on runs triggered by + # pull_request_review. The runs this workflow dispatches have event + # 'workflow_dispatch', so their completion is filtered out here and + # the chain stops after one hop. + if: github.event.workflow_run.event == 'pull_request_review' + runs-on: ubuntu-latest + steps: + - name: Resolve fork PRs and dispatch reconciliation + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + retries: 3 + script: | + const { owner, repo } = context.repo; + const run = context.payload.workflow_run; + + // Mirrors the job-level `if`: only runs triggered by + // pull_request_review need a follow-up, so the chain + // stops after one hop. + if (run?.event !== 'pull_request_review') { + core.info(`Ignoring workflow_run with event '${run?.event}'`); + return; + } + + // Same-repository review runs already ran with a privileged + // token and wrote their labels; only fork runs need the + // follow-up. + if (run.head_repository && run.head_repository.fork === false) { + core.info('Ignoring same-repository pull_request_review run'); + return; + } + + // Resolve which PRs the completed run targeted. Fork runs + // backfill pull_requests empty, so fall back to the exact head + // owner, branch, and SHA lookup used by the resolve job of + // label-pr-review-state.yml. Keep both copies in sync. + let prNumbers = []; + if (run.pull_requests?.length > 0) { + prNumbers = run.pull_requests.map(pr => pr.number); + } else { + const headOwner = run.head_repository?.owner?.login; + if (headOwner && run.head_branch && run.head_sha) { + const candidates = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + head: `${headOwner}:${run.head_branch}`, + per_page: 100, + }); + const baseRepository = `${owner}/${repo}`.toLowerCase(); + prNumbers = candidates + .filter(pr => + pr.head?.sha === run.head_sha && + pr.base?.repo?.full_name?.toLowerCase() === baseRepository + ) + .map(pr => pr.number); + core.info( + `Resolved workflow_run ${run.id ?? '(unknown)'} to ` + + `${prNumbers.length} PR(s) by exact head owner, branch, and SHA` + ); + } + } + + // Each dispatch starts a privileged workflow_dispatch run of the + // reconciliation workflow for one PR. Its completion re-triggers + // this workflow, but the loop guard above filters it out. + // A failed dispatch must not skip the PRs after it: collect the + // failures and fail the step once, naming each PR. + const failures = []; + for (const prNumber of prNumbers) { + try { + await github.rest.actions.createWorkflowDispatch({ + owner, + repo, + workflow_id: 'label-pr-review-state.yml', + ref: 'main', + inputs: { pull_request_number: String(prNumber) }, + }); + core.info(`Dispatched label-pr-review-state.yml for PR #${prNumber}`); + } catch (error) { + failures.push(`#${prNumber}: ${error.message}`); + } + } + if (failures.length > 0) { + core.setFailed(`Failed to dispatch ${failures.length} PR(s): ${failures.join('; ')}`); + } diff --git a/.github/workflows/label-pr-review-state.yml b/.github/workflows/label-pr-review-state.yml index 174ac970c9..8a86c18207 100644 --- a/.github/workflows/label-pr-review-state.yml +++ b/.github/workflows/label-pr-review-state.yml @@ -38,21 +38,11 @@ on: issue_comment: types: [created, edited] # Fork pull_request_review runs get a read-only token and skip their label - # writes. This workflow lists itself so such a run's completion re-triggers - # it with a privileged token that writes the computed labels (issue #2046). - # The resolve job accepts a workflow_run of this workflow only when the - # completed run was itself a pull_request_review run, so the chain stops - # after one hop. The privileged follow-up takes no artifact input and never - # checks out code, so no untrusted PR code or artifacts are touched. + # writes (issue #2046). The privileged follow-up for those runs lives in + # label-pr-review-state-fork-review-followup.yml: GitHub rejects a workflow + # that lists itself in workflow_run.workflows (issue #2086). workflow_run: - workflows: - [ - "Code QA Roo Code", - "E2E Tests (Mocked)", - "Webview Visual Regression", - "CodeQL Advanced", - "Label PR review state", - ] + workflows: ["Code QA Roo Code", "E2E Tests (Mocked)", "Webview Visual Regression", "CodeQL Advanced"] types: [completed] permissions: @@ -104,17 +94,9 @@ concurrency: jobs: resolve: name: Zoo Code / resolve PRs to reconcile - # The second clause is the loop guard for the self workflow_run - # follow-up (issue #2046): accept a workflow_run of this workflow only - # when the completed run was triggered by pull_request_review. A run - # triggered by workflow_run has event 'workflow_run', so the next level - # is filtered out and the chain stops after one hop. if: >- - (github.event_name != 'issue_comment' || - (github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')) && - (github.event_name != 'workflow_run' || - github.event.workflow_run.name != 'Label PR review state' || - github.event.workflow_run.event == 'pull_request_review') + github.event_name != 'issue_comment' || + (github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]') runs-on: ubuntu-latest outputs: pr_numbers: ${{ steps.resolve.outputs.pr_numbers }} @@ -152,27 +134,12 @@ jobs: return; } - // Loop guard for the self workflow_run follow-up (issue #2046), - // mirroring the job-level `if`: accept a workflow_run of this - // workflow only when the completed run was triggered by - // pull_request_review. A run triggered by workflow_run has event - // 'workflow_run', so the chain stops after one hop. - if (context.eventName === 'workflow_run' && - context.payload.workflow_run?.name === 'Label PR review state' && - context.payload.workflow_run?.event !== 'pull_request_review') { - core.info( - 'Ignoring workflow_run of this workflow not triggered by a ' + - 'pull_request_review run' - ); - core.setOutput('pr_numbers', '[]'); - return; - } - // Resolve which PRs this event targets. Single-PR events carry the // number in the payload; sweep triggers (hourly schedule, pushes to // main) list every open PR; workflow_run events use the run's // associated PRs, with a fork-safe backfill by exact head owner, - // branch, and SHA when GitHub left pull_requests empty. + // branch, and SHA when GitHub left pull_requests empty. Keep the + // backfill in sync with label-pr-review-state-fork-review-followup.yml. let prNumbers = []; if (context.payload.pull_request?.number) { prNumbers = [context.payload.pull_request.number]; @@ -238,9 +205,6 @@ jobs: if: >- (github.event_name != 'issue_comment' || (github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')) && - (github.event_name != 'workflow_run' || - github.event.workflow_run.name != 'Label PR review state' || - github.event.workflow_run.event == 'pull_request_review') && needs.resolve.outputs.pr_numbers != '[]' strategy: fail-fast: false diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index 387d8548e1..397494b252 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -58,13 +58,14 @@ export default [ // Tests inside __tests__/ directories must not traverse 3+ levels above // the test file. That escapes the src/ package root and makes Turbo // invisible to cache changes in those files. - // pr-review-state-workflow.test.ts and stale-pr-clock-workflow.test.ts are the - // intentional exceptions: they read root-level config and run in a dedicated - // non-Turbo CI step. + // The workflow test files below are the intentional exceptions: they read + // root-level config and run in a dedicated non-Turbo CI step. files: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.spec.ts"], ignores: [ "**/services/__tests__/pr-review-state-workflow.test.ts", "**/services/__tests__/stale-pr-clock-workflow.test.ts", + "**/services/__tests__/fork-review-followup-workflow.test.ts", + "**/services/__tests__/workflow-self-listen.test.ts", ], rules: { "no-restricted-syntax": [ diff --git a/src/services/__tests__/fork-review-followup-workflow.test.ts b/src/services/__tests__/fork-review-followup-workflow.test.ts new file mode 100644 index 0000000000..6c35158e21 --- /dev/null +++ b/src/services/__tests__/fork-review-followup-workflow.test.ts @@ -0,0 +1,201 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { describe, expect, it, vi } from "vitest" +import { parse } from "yaml" + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..") +const workflow = parse( + fs.readFileSync( + path.join(repositoryRoot, ".github/workflows/label-pr-review-state-fork-review-followup.yml"), + "utf8", + ), +) +const dispatchScript = workflow.jobs.dispatch.steps[0].with.script as string + +const SHA = "a".repeat(40) +const OLD_SHA = "b".repeat(40) + +interface HarnessOptions { + workflowRunEvent?: string + fork?: boolean + associatedPrNumbers?: number[] + fallback?: "match" | "sha-mismatch" | "base-mismatch" | "none" + headRepository?: null + headSha?: string + failDispatchFor?: number +} + +function buildForkPr() { + return { + number: 1437, + state: "open", + head: { sha: SHA, repo: { full_name: "contributor/Zoo-Code" } }, + base: { ref: "main", repo: { full_name: "Zoo-Code-Org/Zoo-Code" } }, + } +} + +/** Executes the embedded github-script follow-up against deterministic GitHub API doubles. */ +async function runFollowup(options: HarnessOptions = {}) { + const fork = options.fork ?? true + const pr = buildForkPr() + const listPullRequests = vi.fn(async ({ state }: { state?: string }) => { + if (state !== "open") return [] + if (options.fallback === "match") return [pr] + if (options.fallback === "sha-mismatch") return [{ ...pr, head: { ...pr.head, sha: OLD_SHA } }] + if (options.fallback === "base-mismatch") { + return [{ ...pr, base: { ...pr.base, repo: { full_name: "someone-else/Zoo-Code" } } }] + } + return [] + }) + const createWorkflowDispatch = vi.fn(async ({ inputs }: { inputs: { pull_request_number: string } }) => { + if (Number(inputs.pull_request_number) === options.failDispatchFor) throw new Error("dispatch rejected") + return { status: 204 } + }) + const github = { + paginate: vi.fn(async (target: unknown, args: unknown) => { + if (typeof target !== "function") throw new Error("Unexpected paginate target") + return target(args) + }), + rest: { + pulls: { list: listPullRequests }, + actions: { createWorkflowDispatch }, + }, + } + const context = { + eventName: "workflow_run", + repo: { owner: "Zoo-Code-Org", repo: "Zoo-Code" }, + payload: { + workflow_run: { + id: 987654, + event: options.workflowRunEvent ?? "pull_request_review", + pull_requests: (options.associatedPrNumbers ?? []).map((number) => ({ number })), + head_repository: + options.headRepository === null + ? null + : { + owner: { login: fork ? "contributor" : "Zoo-Code-Org" }, + fork, + }, + head_branch: "feature/test", + head_sha: options.headSha ?? SHA, + }, + }, + } + const core = { + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + } + + await new AsyncFunction("github", "context", "core", dispatchScript)(github, context, core) + + return { listPullRequests, createWorkflowDispatch, info: core.info, setFailed: core.setFailed } +} + +describe("fork review follow-up workflow (#2046, #2086)", () => { + it("listens to the label workflow without listing itself", () => { + expect(workflow.name).toBe("Label PR review state fork review follow-up") + expect(workflow.on.workflow_run.workflows).toEqual(["Label PR review state"]) + expect(workflow.on.workflow_run.workflows).not.toContain(workflow.name) + expect(String(workflow.jobs.dispatch.if).trim()).toBe( + "github.event.workflow_run.event == 'pull_request_review'", + ) + }) + + it("dispatches an input and workflow that the label workflow declares", () => { + const label = parse( + fs.readFileSync(path.join(repositoryRoot, ".github/workflows/label-pr-review-state.yml"), "utf8"), + ) + const call = /createWorkflowDispatch\(\{[\s\S]*?workflow_id: '([^']+)'[\s\S]*?inputs: \{ (\w+):/.exec( + dispatchScript, + ) + + expect(call?.[1]).toBe("label-pr-review-state.yml") + expect(Object.keys(label.on.workflow_dispatch.inputs)).toContain(call?.[2]) + expect(workflow.on.workflow_run.workflows).toEqual([label.name]) + }) + + it("dispatches a privileged reconciliation for the fork PR a read-only review run targeted", async () => { + // Fork runs backfill pull_requests empty, so the follow-up resolves the + // PR by exact head owner, branch, and SHA. + const result = await runFollowup({ associatedPrNumbers: [], fallback: "match" }) + + expect(result.listPullRequests).toHaveBeenCalledWith( + expect.objectContaining({ head: "contributor:feature/test", state: "open" }), + ) + expect(result.createWorkflowDispatch).toHaveBeenCalledTimes(1) + expect(result.createWorkflowDispatch).toHaveBeenCalledWith({ + owner: "Zoo-Code-Org", + repo: "Zoo-Code", + workflow_id: "label-pr-review-state.yml", + ref: "main", + inputs: { pull_request_number: "1437" }, + }) + expect(result.setFailed).not.toHaveBeenCalled() + }) + + it("dispatches directly for runs whose pull_requests are populated", async () => { + const result = await runFollowup({ associatedPrNumbers: [1437] }) + + expect(result.listPullRequests).not.toHaveBeenCalled() + expect(result.createWorkflowDispatch).toHaveBeenCalledTimes(1) + }) + + it("dispatches every PR a run targets and reports a failed dispatch without skipping the rest", async () => { + const both = await runFollowup({ associatedPrNumbers: [1, 2] }) + + expect(both.createWorkflowDispatch).toHaveBeenCalledTimes(2) + expect(both.setFailed).not.toHaveBeenCalled() + + const partial = await runFollowup({ associatedPrNumbers: [1, 2], failDispatchFor: 1 }) + + expect(partial.createWorkflowDispatch).toHaveBeenCalledTimes(2) + expect(partial.setFailed).toHaveBeenCalledWith(expect.stringContaining("#1: dispatch rejected")) + }) + + it("ignores a completed run that was not triggered by pull_request_review", async () => { + // The runs this workflow dispatches complete with event + // 'workflow_dispatch'; filtering them out stops the chain after one hop. + for (const workflowRunEvent of ["schedule", "push", "workflow_dispatch", "pull_request_target"]) { + const result = await runFollowup({ workflowRunEvent, fallback: "match" }) + + expect(result.listPullRequests).not.toHaveBeenCalled() + expect(result.createWorkflowDispatch).not.toHaveBeenCalled() + } + }) + + it("ignores a same-repository review run, which already wrote its labels", async () => { + const result = await runFollowup({ fork: false, associatedPrNumbers: [1437] }) + + expect(result.listPullRequests).not.toHaveBeenCalled() + expect(result.createWorkflowDispatch).not.toHaveBeenCalled() + }) + + it("dispatches nothing when no open PR matches the run's exact head", async () => { + const result = await runFollowup({ fallback: "sha-mismatch" }) + + expect(result.listPullRequests).toHaveBeenCalledTimes(1) + expect(result.createWorkflowDispatch).not.toHaveBeenCalled() + }) + + it("dispatches nothing when the matching PR targets another base repository", async () => { + const result = await runFollowup({ fallback: "base-mismatch" }) + + expect(result.listPullRequests).toHaveBeenCalledTimes(1) + expect(result.createWorkflowDispatch).not.toHaveBeenCalled() + }) + + it("dispatches nothing when the run has no head repository or head SHA to resolve", async () => { + for (const options of [{ headRepository: null }, { headSha: "" }] as const) { + const result = await runFollowup({ ...options, fallback: "match" }) + + expect(result.listPullRequests).not.toHaveBeenCalled() + expect(result.createWorkflowDispatch).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/services/__tests__/pr-review-state-workflow.test.ts b/src/services/__tests__/pr-review-state-workflow.test.ts index abc14bccab..e258ce64ff 100644 --- a/src/services/__tests__/pr-review-state-workflow.test.ts +++ b/src/services/__tests__/pr-review-state-workflow.test.ts @@ -35,8 +35,6 @@ interface HarnessOptions { workflowRunFallback?: "match" | "sha-mismatch" | "base-mismatch" | "none" workflowRunHeadBranch?: string workflowRunMissing?: "repository" | "branch" | "sha" - workflowRunName?: string - workflowRunEvent?: string workflowDispatchPrNumber?: number prNumber?: number runNumber?: number @@ -177,11 +175,8 @@ function buildEventPayload(eventName: string, options: HarnessOptions) { : eventName === "workflow_run" ? { workflow_run: { - // Defaults model a listed CI workflow so existing - // workflow_run tests stay outside the self-run loop - // guard (issue #2046). - name: options.workflowRunName ?? "Code QA Roo Code", - event: options.workflowRunEvent ?? "pull_request", + name: "Code QA Roo Code", + event: "pull_request", pull_requests: options.workflowRunAssociated === false ? [] @@ -2166,99 +2161,15 @@ describe("PR review-state workflow", () => { expect(resolution.prNumbers()).toEqual([]) }) - describe("fork review self-follow-up (#2046)", () => { - it("writes labels for a fork PR in the trusted run that follows its read-only review run", async () => { - // The workflow lists itself under workflow_run so a fork - // pull_request_review run's completion re-triggers it with a - // privileged token; both jobs gate that self-run on the completed - // run's event. - expect(workflow.on.workflow_run.workflows).toContain("Label PR review state") - expect(String(workflow.jobs.resolve.if)).toContain( - "github.event.workflow_run.event == 'pull_request_review'", - ) - expect(String(workflow.jobs.reconcile.if)).toContain( - "github.event.workflow_run.event == 'pull_request_review'", - ) - - // The fork review run computes the right label but writes nothing. - const readOnly = await runWorkflow({ eventName: "pull_request_review", fork: true }) - - expect(readOnly.addLabels).not.toHaveBeenCalled() - expect(readOnly.removeLabel).not.toHaveBeenCalled() - expect(readOnly.setFailed).not.toHaveBeenCalled() - - // Its completion re-triggers this workflow as a trusted workflow_run. - // Fork runs backfill pull_requests empty, so the resolve job falls - // back to the exact head owner, branch, and SHA lookup. - const resolution = await runResolve({ - eventName: "workflow_run", - fork: true, - workflowRunAssociated: false, - workflowRunFallback: "match", - workflowRunName: "Label PR review state", - workflowRunEvent: "pull_request_review", - }) - - expect(resolution.listPullRequests).toHaveBeenCalledWith( - expect.objectContaining({ head: "contributor:feature/test", state: "open" }), - ) - expect(resolution.prNumbers()).toEqual([1437]) - - // The trusted follow-up shard writes the label the read-only run - // computed but could not write. The reconcile script reads the PR - // from the matrix number and ignores the workflow_run name and - // event, so the default workflow_run payload is enough here. - const result = await runWorkflow({ eventName: "workflow_run", fork: true }) - - expect(result.addLabels).toHaveBeenCalledWith( - expect.objectContaining({ labels: ["coderabbit-review-active"] }), - ) - expect(result.setFailed).not.toHaveBeenCalled() - }) - - it("ignores a self workflow_run triggered by a non-review run", async () => { - // Every other run of this workflow (hourly sweep, push to main, - // dispatch, pull_request_target) also completes and re-triggers it; - // those follow-ups carry no new review state and are filtered out. - for (const workflowRunEvent of ["schedule", "push", "workflow_dispatch", "pull_request_target"]) { - const resolution = await runResolve({ - eventName: "workflow_run", - workflowRunName: "Label PR review state", - workflowRunEvent, - }) - - expect(resolution.listPullRequests).not.toHaveBeenCalled() - expect(resolution.getPullRequest).not.toHaveBeenCalled() - expect(resolution.prNumbers()).toEqual([]) - } - }) - - it("does not apply the self-run guard to other workflows that report event workflow_run", async () => { - // Only the 'Label PR review state' name is guarded. A listed CI - // workflow with event 'workflow_run' must still resolve its PR. - const resolution = await runResolve({ - eventName: "workflow_run", - workflowRunName: "Code QA Roo Code", - workflowRunEvent: "workflow_run", - }) - - expect(resolution.prNumbers()).toEqual([1437]) - }) - - it("stops the self workflow_run chain after one hop", async () => { - // The trusted follow-up run completes with event 'workflow_run'. The - // guard requires 'pull_request_review', so its own completion cannot - // queue another run. - const resolution = await runResolve({ - eventName: "workflow_run", - fork: true, - workflowRunName: "Label PR review state", - workflowRunEvent: "workflow_run", - }) - - expect(resolution.listPullRequests).not.toHaveBeenCalled() - expect(resolution.getPullRequest).not.toHaveBeenCalled() - expect(resolution.prNumbers()).toEqual([]) + describe("fork review follow-up handoff (#2046, #2086)", () => { + it("does not listen to itself; the privileged fork review follow-up lives in its own workflow", () => { + // GitHub rejects a workflow that lists itself in + // workflow_run.workflows (issue #2086), so the follow-up for fork + // pull_request_review runs lives in + // label-pr-review-state-fork-review-followup.yml (issue #2046). + expect(workflow.on.workflow_run.workflows).not.toContain("Label PR review state") + expect(String(workflow.jobs.resolve.if)).not.toContain("github.event.workflow_run.name") + expect(String(workflow.jobs.reconcile.if)).not.toContain("github.event.workflow_run.name") }) }) diff --git a/src/services/__tests__/workflow-self-listen.test.ts b/src/services/__tests__/workflow-self-listen.test.ts new file mode 100644 index 0000000000..3f2a362b94 --- /dev/null +++ b/src/services/__tests__/workflow-self-listen.test.ts @@ -0,0 +1,29 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { describe, expect, it } from "vitest" +import { parse } from "yaml" + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..") +const workflowsDir = path.join(repositoryRoot, ".github/workflows") +const workflowFiles = fs + .readdirSync(workflowsDir) + .filter((file) => file.endsWith(".yml") || file.endsWith(".yaml")) + .sort() + +describe("workflow_run self-listen guard (#2086)", () => { + // GitHub rejects a workflow whose workflow_run trigger lists the workflow's + // own name ("Workflow '' cannot listen to itself"), and actionlint + // does not catch it, so keep a dedicated check over every workflow file. + it.each(workflowFiles)("%s does not list its own name in workflow_run.workflows", (file) => { + const workflow = parse(fs.readFileSync(path.join(workflowsDir, file), "utf8")) + const listenedWorkflows = workflow.on?.workflow_run?.workflows + + if (typeof workflow.name !== "string" || !Array.isArray(listenedWorkflows)) { + return + } + + expect(listenedWorkflows).not.toContain(workflow.name) + }) +}) diff --git a/src/vitest.workflow.config.ts b/src/vitest.workflow.config.ts index 7464a9271d..8b852f6de4 100644 --- a/src/vitest.workflow.config.ts +++ b/src/vitest.workflow.config.ts @@ -9,6 +9,8 @@ export default mergeConfig( include: [ "services/__tests__/pr-review-state-workflow.test.ts", "services/__tests__/stale-pr-clock-workflow.test.ts", + "services/__tests__/fork-review-followup-workflow.test.ts", + "services/__tests__/workflow-self-listen.test.ts", ], coverage: { reportsDirectory: "coverage/workflow",