From f4d551220b2315131e15d38de8321295f334fc6e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:43:54 -0700 Subject: [PATCH 1/7] feat(agent-actions): cancel in-flight CI runs when a PR is auto-closed for the contributor cap Closing a PR for exceeding the per-contributor open-item cap currently leaves its in-flight Actions runs burning CI minutes for nothing. Adds an opt-in contributorCapCancelCi setting (per-repo, or an install-wide CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var fallback) that lists and cancels a closed PR's in-progress/queued workflow runs at its head SHA right after a contributor_cap close succeeds. Requires the actions:write permission (added to the self-host App manifest); an installation that hasn't re-approved it degrades gracefully -- the cancellation attempt is skipped and logged via a dedicated audit event, and never blocks or fails the close itself, which has already succeeded by the time this hook runs. Off by default; zero behavior change until a repo (or the install-wide env var) opts in. Closes #2462. --- .env.example | 6 + .gittensory.yml.example | 7 + .../routes/docs.self-hosting-github-app.tsx | 41 ++++ migrations/0097_contributor_cap_cancel_ci.sql | 5 + src/db/repositories.ts | 5 + src/db/schema.ts | 5 + src/env.d.ts | 5 + src/github/app.ts | 115 +++++++++ src/openapi/schemas.ts | 1 + src/queue/processors.ts | 3 + src/selfhost/setup-wizard.ts | 5 + src/services/agent-action-executor.ts | 54 +++- src/services/agent-approval-queue.ts | 4 + src/signals/focus-manifest.ts | 10 + src/types.ts | 8 + test/unit/focus-manifest.test.ts | 20 ++ test/unit/github-app.test.ts | 211 ++++++++++++++++ test/unit/queue.test.ts | 230 ++++++++++++++++++ test/unit/setup-wizard-docs-parity.test.ts | 1 + 19 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 migrations/0097_contributor_cap_cancel_ci.sql diff --git a/.env.example b/.env.example index aab6d14e89..ea34ae6363 100644 --- a/.env.example +++ b/.env.example @@ -161,6 +161,12 @@ GITTENSORY_REVIEW_DRAFT=false # # your own login here right after first-run setup. Also exempts these # # logins from the agent's own-PR auto-close rules (fleet-operator # # identity) and lets them bypass per-repo MCP scope. +# CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT=false # install-wide default for the per-repo contributorCapCancelCi +# # setting: cancel in-flight CI runs when a PR is auto-closed for +# # exceeding contributorOpenPrCap. A repo's own configured value +# # always takes precedence. Requires the App installation to have +# # granted the actions:write permission -- degrades gracefully +# # (skipped, logged) when it hasn't. Off by default. # MCP_READ_REPO_ALLOWLIST= # scopes the shared GITTENSORY_MCP_TOKEN identity's READ-only MCP # # tools (repo context, issue quality, watch subscriptions) to these # # owner/repo entries (comma/whitespace-separated). FAIL-CLOSED: diff --git a/.gittensory.yml.example b/.gittensory.yml.example index eba7d13840..3d141479af 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -328,6 +328,13 @@ settings: # Label applied to a PR/issue closed for exceeding a cap above. String. Default: over-contributor-limit. # contributorCapLabel: over-contributor-limit + # Cancel in-flight CI runs when a PR is auto-closed for exceeding contributorOpenPrCap above (#2462). + # Requires the App installation to have granted the `actions: write` permission -- degrades gracefully + # (skipped + logged, the close itself still succeeds) when it hasn't. Bool or omit/null to fall back to + # the CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var. Default: null (falls back to the env var, itself off + # by default). + # contributorCapCancelCi: true + # Review-request nagging cooldown (#2463, anti-abuse): throttle a non-owner/non-admin/non-bot # contributor who repeatedly pings @gittensory for review on the same PR/issue. "hold" replies with a # cooldown notice and takes no further action; "close" closes the PR (issues degrade to "hold" until diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx index b309db729d..a54f3a3843 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx @@ -98,11 +98,52 @@ SELFHOST_SETUP_TOKEN=change-this-long-random-value # unlocks /setup for a fresh
  • Commit statuses: read.
  • Metadata: read.
  • +
  • + Actions: write — lets a repo opt into cancelling a closed PR's in-flight CI runs (the{" "} + contributorCapCancelCi setting). Off by default and never required: a repo + that doesn't enable it, or an installation that hasn't re-approved this permission on an + existing App, sees no behavior change — the cancellation attempt is skipped and logged, + never blocking the close itself. +
  • Events: pull request, pull request review, push, issues, check suite, check run, and status.

    +

    Re-approving a permission bump on an existing App

    +

    + A future release can widen this permission list (most recently, Actions: write for the + opt-in CI-cancellation feature). GitHub does not silently grant a new + permission to an App that's already installed — the operator who owns the App must + explicitly re-approve it, the same one-time consent step as the original install. +

    +

    + Until you re-approve, the self-host keeps working exactly as before: any feature that needs + the new permission degrades gracefully (skipped and logged, never a hard failure) rather + than erroring. There's no forced upgrade window. +

    +

    To re-approve:

    +
      +
    1. + Open your App's settings page —{" "} + https://github.com/settings/apps/<your-app-slug>/permissions{" "} + (organization Apps:{" "} + + https://github.com/organizations/<org>/settings/apps/<your-app-slug>/permissions + + ). +
    2. +
    3. + GitHub shows a diff between the App's currently-granted permissions and what the App + manifest now requests. Review it, then save — GitHub sends the installation owner a + request to accept the new grant. +
    4. +
    5. + Accept the request (as the installation owner, on each installed org/account). The new + permission takes effect immediately; no App reinstall or webhook resubscription needed. +
    6. +
    +

    Direct App env

    { + const body = (await response.json().catch(() => null)) as { message?: unknown } | null; + return typeof body?.message === "string" ? body.message : ""; +} + +function actionsPermissionMissingResult(message: string): { kind: "permission_missing"; warning: string } { + return { + kind: "permission_missing", + warning: `GitHub App Actions: write permission is missing (${message || "resource not accessible by integration"}). Enable it in the GitHub App settings and re-approve the installation.`, + }; +} + +type ActionsRunFetchOptions = { + headers: HeadersInit; + githubRateLimitAdmission: true; + githubRateLimitAdmissionKey: GitHubRateLimitAdmissionKey; +}; + +// Split out of cancelInFlightWorkflowRunsForHeadSha (a named function, not an inline for-of body) so v8's +// per-branch coverage tracking attributes hits correctly across repeated loop iterations with early returns +// -- an inline loop body with early `return`s inside a `for` inside an `async function` can under-report the +// "condition false" side of a branch even when it demonstrably executes (confirmed via a live debug trace). +async function listWorkflowRunIdsForStatus( + repoPath: string, + headSha: string, + status: "in_progress" | "queued", + fetchOptions: ActionsRunFetchOptions, +): Promise<{ kind: "ids"; ids: number[] } | { kind: "permission_missing"; warning: string } | { kind: "error"; warning: string }> { + const response = await timeoutFetch(`https://api.github.com/repos/${repoPath}/actions/runs?head_sha=${encodeURIComponent(headSha)}&status=${status}`, fetchOptions); + if (response.ok) { + const payload = (await response.json()) as { workflow_runs?: Array<{ id: number }> }; + return { kind: "ids", ids: (payload.workflow_runs ?? []).map((run) => run.id) }; + } + const message = await actionsApiErrorMessage(response); + if (response.status === 403 && isActionsPermissionMissingMessage(message)) { + return actionsPermissionMissingResult(message); + } + return { kind: "error", warning: `Failed to list workflow runs (${response.status}): ${message || "unknown error"}` }; +} + +// Same extraction rationale as listWorkflowRunIdsForStatus above. +async function cancelOneWorkflowRun( + repoPath: string, + runId: number, + fetchOptions: ActionsRunFetchOptions, +): Promise<{ kind: "cancelled" } | { kind: "permission_missing"; warning: string } | { kind: "not_permission_error" }> { + const response = await timeoutFetch(`https://api.github.com/repos/${repoPath}/actions/runs/${runId}/cancel`, { ...fetchOptions, method: "POST" }); + // 202 = cancellation accepted; 409 = already completed/cancelling -- both are non-failures here (the run is + // no longer going to keep burning minutes either way). Only a genuine 403 signals a scope gap. + if (response.ok || response.status === 409) return { kind: "cancelled" }; + if (response.status !== 403) return { kind: "not_permission_error" }; + const message = await actionsApiErrorMessage(response); + if (!isActionsPermissionMissingMessage(message)) return { kind: "not_permission_error" }; + return actionsPermissionMissingResult(message); +} + +/** List then cancel every in-progress/queued Actions run at a PR's head SHA (#2462): a PR auto-closed for + * exceeding the per-contributor open-item cap should also stop burning CI minutes on its in-flight runs. + * Needs `actions: write` (list needs `actions: read`, effectively granted alongside write) -- an + * installation that hasn't granted it gets a typed `permission_missing` result, never a thrown error, so + * this can run as a best-effort side effect AFTER a close has already succeeded without risking that + * success being misrecorded as a failure. Greenfield: no existing Actions-API wrapper to extend. */ +export async function cancelInFlightWorkflowRunsForHeadSha( + env: Env, + installationId: number, + repoFullName: string, + headSha: string, +): Promise { + const [owner, repo] = repoFullName.split("/"); + if (!owner || !repo) return { kind: "error", warning: `Invalid repository full name: ${repoFullName}` }; + const repoPath = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + try { + const token = await createInstallationToken(env, installationId); + const fetchOptions: ActionsRunFetchOptions = { + headers: githubHeaders(`Bearer ${token}`), + githubRateLimitAdmission: true, + githubRateLimitAdmissionKey: githubRateLimitAdmissionKeyForInstallation(installationId), + }; + const runIds = new Set(); + for (const status of ["in_progress", "queued"] as const) { + const listed = await listWorkflowRunIdsForStatus(repoPath, headSha, status, fetchOptions); + if (listed.kind !== "ids") return listed; + for (const id of listed.ids) runIds.add(id); + } + if (runIds.size === 0) return { kind: "cancelled", cancelledCount: 0, totalFound: 0 }; + let cancelledCount = 0; + for (const runId of runIds) { + const result = await cancelOneWorkflowRun(repoPath, runId, fetchOptions); + if (result.kind === "cancelled") cancelledCount += 1; + else if (result.kind === "permission_missing") return result; + } + return { kind: "cancelled", cancelledCount, totalFound: runIds.size }; + } catch (error) { + return { kind: "error", warning: error instanceof Error ? error.message : "unknown error" }; + } +} + // The App JWT is valid ~9 min (iat backdated 60s, exp +540s). Re-signing (RS256) it on EVERY call is wasteful CPU // AND defeats response caching of App-level reads (/app/installations/{id}): the rotating JWT changes the // auth-scoped response-cache key on every call, so the metadata cache class never hits for its heaviest caller diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 4df156907d..b275a75833 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -653,6 +653,7 @@ export const RepositorySettingsSchema = z contributorOpenPrCap: z.number().int().positive().nullable().optional(), contributorOpenIssueCap: z.number().int().positive().nullable().optional(), contributorCapLabel: z.string().optional(), + contributorCapCancelCi: z.boolean().nullable().optional(), reviewNagPolicy: z.enum(["off", "hold", "close"]).optional(), reviewNagMaxPings: z.number().int().positive().optional(), reviewNagCooldownDays: z.number().int().positive().max(MAX_REVIEW_NAG_COOLDOWN_DAYS).optional(), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 3159e97d43..a03685fa3e 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -2200,6 +2200,9 @@ async function runAgentMaintenancePlanAndExecute( agentDryRun: settings.agentDryRun, installationPermissions, authorLogin: pr.authorLogin, + // CI-run cancellation on a contributor_cap close (#2462): the repo's own explicit setting always wins; + // null/undefined (unset) falls back to the install-wide CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var. + contributorCapCancelCi: settings.contributorCapCancelCi ?? env.CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT === "true", }, breakerOnPlan, ); diff --git a/src/selfhost/setup-wizard.ts b/src/selfhost/setup-wizard.ts index da37092a9b..86e8108ee8 100644 --- a/src/selfhost/setup-wizard.ts +++ b/src/selfhost/setup-wizard.ts @@ -53,6 +53,11 @@ export function buildManifest(origin: string, state: string): Record | null | undefined; // PR author login — surfaced as the "Submitter" in the per-repo Discord action notification. authorLogin?: string | null | undefined; + // CI-run cancellation on a contributor_cap close (#2462, anti-abuse): the CALLER resolves this (repo setting + // ?? the CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var) before building the context — the executor itself has no + // settings access, only whatever ctx carries, mirroring how agentPaused/agentDryRun are already threaded in. + contributorCapCancelCi?: boolean | undefined; }; export type AgentActionOutcome = { @@ -177,6 +181,14 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE try { await performAction(env, ctx, action); await audit("completed", action.reason); + // CI-run cancellation on a contributor_cap close (#2462, anti-abuse): stop burning CI minutes on a PR + // that was just closed for exceeding the contributor cap. Best-effort, AFTER the close already + // succeeded -- cancelInFlightWorkflowRunsForHeadSha never throws, so a missing actions:write grant (or + // any other failure here) can never retroactively turn this already-successful close into a recorded + // "error" by escaping into the catch block below. + if (action.actionClass === "close" && action.closeKind === "contributor_cap" && ctx.contributorCapCancelCi && ctx.headSha) { + await recordContributorCapCiCancelOutcome(env, ctx, ctx.headSha); + } // Re-approval idempotency: record the head SHA we just approved so the planner skips re-approving this // exact commit on the next sweep (a GitHub App's own approval does not reliably flip reviewDecision to // APPROVED, so reviewDecision alone can't dedup). A new commit clears the match → the bot approves it. @@ -215,6 +227,46 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE return outcomes; } +/** CI-run cancellation on a contributor_cap close (#2462): runs cancelInFlightWorkflowRunsForHeadSha and + * records exactly one of two audit outcomes, mirroring the established `github_app.*_permission_missing` + * convention (processors.ts's check-run/gate-check permission-missing audits) so a fleet-wide actions:write + * scope gap surfaces the same way those already do. Never throws -- both recordAuditEvent calls are + * best-effort (`.catch(() => undefined)`), since a failure to WRITE the audit record must not retroactively + * affect the close this already ran after. */ +async function recordContributorCapCiCancelOutcome(env: Env, ctx: AgentActionExecutionContext, headSha: string): Promise { + const targetKey = `${ctx.repoFullName}#${ctx.pullNumber}`; + const outcome = await cancelInFlightWorkflowRunsForHeadSha(env, ctx.installationId, ctx.repoFullName, headSha); + if (outcome.kind === "cancelled") { + await recordAuditEvent(env, { + eventType: "github_app.contributor_cap_ci_cancelled", + actor: AGENT_ACTOR, + targetKey, + outcome: "completed", + detail: `cancelled ${outcome.cancelledCount} of ${outcome.totalFound} in-flight workflow run(s)`, + metadata: { repoFullName: ctx.repoFullName, headSha, cancelledCount: outcome.cancelledCount, totalFound: outcome.totalFound }, + }).catch(() => undefined); + return; + } + console.error( + JSON.stringify({ + level: "error", + event: "contributor_cap_ci_cancel_failed", + reason: outcome.kind, + repository: ctx.repoFullName, + pullNumber: ctx.pullNumber, + message: outcome.warning, + }), + ); + await recordAuditEvent(env, { + eventType: "github_app.contributor_cap_ci_cancel_permission_missing", + actor: AGENT_ACTOR, + targetKey, + outcome: "error", + detail: outcome.warning, + metadata: { repoFullName: ctx.repoFullName, headSha, reason: outcome.kind }, + }).catch(() => undefined); +} + export type IssueActionExecutionContext = { installationId: number; repoFullName: string; diff --git a/src/services/agent-approval-queue.ts b/src/services/agent-approval-queue.ts index c3cd06907e..259b03e3c8 100644 --- a/src/services/agent-approval-queue.ts +++ b/src/services/agent-approval-queue.ts @@ -294,6 +294,10 @@ export async function decidePendingAgentAction(env: Env, input: { id: string; de agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, installationPermissions: installation ? installation.permissions : null, + // CI-run cancellation on a contributor_cap close (#2462): a contributor_cap close CAN be staged for + // approval (close autonomy = auto_with_approval), so the accept-replay path needs this resolved the + // same way the live webhook path does (src/queue/processors.ts) for the cancel hook to fire here too. + contributorCapCancelCi: settings.contributorCapCancelCi ?? env.CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT === "true", }, plan, ); diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 51beb9608e..89c07f164e 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -167,6 +167,7 @@ export type FocusManifestSettings = Partial< | "contributorOpenPrCap" | "contributorOpenIssueCap" | "contributorCapLabel" + | "contributorCapCancelCi" | "reviewNagPolicy" | "reviewNagMaxPings" | "reviewNagCooldownDays" @@ -964,6 +965,15 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) } const contributorCapLabel = normalizeOptionalString(r.contributorCapLabel, "settings.contributorCapLabel", warnings); if (contributorCapLabel !== null) out.contributorCapLabel = contributorCapLabel; + // CI-run cancellation on a contributor_cap close (#2462): an explicit yml `null` is load-bearing (clears a + // DB-configured value back to "unset", falling through to the CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var), + // matching contributorOpenPrCap's own null-vs-omitted distinction above. + if (r.contributorCapCancelCi === null) { + out.contributorCapCancelCi = null; + } else { + const contributorCapCancelCi = normalizeOptionalBoolean(r.contributorCapCancelCi, "settings.contributorCapCancelCi", warnings); + if (contributorCapCancelCi !== null) out.contributorCapCancelCi = contributorCapCancelCi; + } // Review-request nagging cooldown (#2463): throttle a contributor repeatedly pinging @gittensory for review. const reviewNagPolicy = normalizeOptionalEnum(r.reviewNagPolicy, "settings.reviewNagPolicy", ["off", "hold", "close"] as const, warnings); if (reviewNagPolicy !== null) out.reviewNagPolicy = reviewNagPolicy; diff --git a/src/types.ts b/src/types.ts index 82f4f12b8e..ec8841c096 100644 --- a/src/types.ts +++ b/src/types.ts @@ -690,6 +690,14 @@ export type RepositorySettings = { * disposition works regardless of the label a repo sets. Always populated by the DB layer; optional so * existing settings fixtures/callers need not be touched. */ contributorCapLabel?: string | undefined; + /** Cancel in-flight CI runs on a contributor_cap close (#2462, anti-abuse): when true, after a PR is + * auto-closed for exceeding {@link contributorOpenPrCap}, gittensory lists and cancels that PR's + * in-progress/queued Actions runs at its head SHA. Requires the App installation to have granted + * `actions: write` -- degrades gracefully (skipped + logged, never blocks the close) when it hasn't. + * `null`/undefined (the DB-layer default) means "unset" and falls back to the + * `CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT` env var -- unlike most boolean toggles, this one is nullable so an + * explicit `false` (opt back out) is distinguishable from "not configured" for that fallback. */ + contributorCapCancelCi?: boolean | null | undefined; /** Review-request nagging cooldown (#2463, anti-abuse): throttle a contributor repeatedly pinging * `@gittensory` (any command) on this repo. `"off"` (default) is a no-op; `"hold"` posts a deterministic * cooldown reply and takes no further action; `"close"` additionally closes the thread (PR threads only in diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 7a7c5f1261..15d6e36ab0 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -1496,6 +1496,26 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(invalid.warnings.some((w) => /settings\.commandRateLimitWindowHours/.test(w))).toBe(true); }); + it("parses + resolves contributorCapCancelCi from the settings: block, overlaying the DB (#2462)", () => { + const manifest = parseFocusManifest({ settings: { contributorCapCancelCi: true } }); + expect(manifest.settings.contributorCapCancelCi).toBe(true); + const eff = resolveEffectiveSettings({ contributorCapCancelCi: null } as unknown as RepositorySettings, manifest); + expect(eff.contributorCapCancelCi).toBe(true); + // An explicit yml `false` also sets it (distinct from `null`, which clears back to unset below). + const disabled = parseFocusManifest({ settings: { contributorCapCancelCi: false } }); + expect(disabled.settings.contributorCapCancelCi).toBe(false); + // An explicit yml `null` clears a DB-configured value back to unset (load-bearing null). + const cleared = resolveEffectiveSettings({ contributorCapCancelCi: true } as unknown as RepositorySettings, parseFocusManifest({ settings: { contributorCapCancelCi: null } })); + expect(cleared.contributorCapCancelCi).toBeNull(); + // Omitted in yml ⇒ the DB-configured value survives untouched. + const noOverride = resolveEffectiveSettings({ contributorCapCancelCi: true } as unknown as RepositorySettings, parseFocusManifest({})); + expect(noOverride.contributorCapCancelCi).toBe(true); + // A non-boolean value is dropped with a warning rather than silently coerced. + const invalid = parseFocusManifest({ settings: { contributorCapCancelCi: "yes" as never } }); + expect(invalid.settings.contributorCapCancelCi).toBeUndefined(); + expect(invalid.warnings.some((w) => /settings\.contributorCapCancelCi/.test(w))).toBe(true); + }); + it("parses + resolves autoCloseExemptLogins from the settings: block, overlaying the DB (#2463)", () => { const manifest = parseFocusManifest({ settings: { autoCloseExemptLogins: ["Trusted-Regular", "another-one", "-bad", 42 as never] } }); expect(manifest.settings.autoCloseExemptLogins).toEqual(["Trusted-Regular", "another-one"]); // invalid entries dropped diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 11b4edf342..14e71e25e4 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { generateKeyPairSync } from "node:crypto"; import { + cancelInFlightWorkflowRunsForHeadSha, clearInstallationTokenCacheForTest, createInstallationToken, createOrUpdateCheckRun, @@ -661,6 +662,216 @@ describe("GitHub check runs", () => { ).resolves.toBeNull(); }); + it("cancelInFlightWorkflowRunsForHeadSha lists in_progress + queued runs at a head SHA and cancels each (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + const cancelledIds: number[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/actions/runs?head_sha=abc123&status=in_progress")) return Response.json({ workflow_runs: [{ id: 1 }, { id: 2 }] }); + if (url.includes("/actions/runs?head_sha=abc123&status=queued")) return Response.json({ workflow_runs: [{ id: 3 }] }); + if (url.includes("/actions/runs/") && url.endsWith("/cancel") && method === "POST") { + cancelledIds.push(Number(url.match(/\/actions\/runs\/(\d+)\/cancel/)?.[1])); + return new Response(null, { status: 202 }); + } + return new Response("not found", { status: 404 }); + }); + + const outcome = await cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "abc123"); + expect(outcome).toEqual({ kind: "cancelled", cancelledCount: 3, totalFound: 3 }); + expect(cancelledIds.sort()).toEqual([1, 2, 3]); + }); + + it("cancelInFlightWorkflowRunsForHeadSha returns cancelled with zero counts when no runs are found (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/actions/runs?head_sha=")) return Response.json({ workflow_runs: [] }); + return new Response("not found", { status: 404 }); + }); + + await expect( + cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "no-runs-sha"), + ).resolves.toEqual({ kind: "cancelled", cancelledCount: 0, totalFound: 0 }); + }); + + it("cancelInFlightWorkflowRunsForHeadSha treats a 409 (already completed/cancelling) as a non-failure (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=in_progress")) return Response.json({ workflow_runs: [{ id: 9 }] }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=queued")) return Response.json({ workflow_runs: [] }); + if (url.endsWith("/actions/runs/9/cancel") && method === "POST") return Response.json({ message: "already completed" }, { status: 409 }); + return new Response("not found", { status: 404 }); + }); + + await expect( + cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha409"), + ).resolves.toEqual({ kind: "cancelled", cancelledCount: 1, totalFound: 1 }); + }); + + it("cancelInFlightWorkflowRunsForHeadSha returns permission_missing on a genuine 403 while listing (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/actions/runs?head_sha=")) return Response.json({ message: "Resource not accessible by integration" }, { status: 403 }); + return new Response("not found", { status: 404 }); + }); + + const outcome = await cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha403"); + expect(outcome.kind).toBe("permission_missing"); + expect((outcome as { warning: string }).warning).toMatch(/actions: write permission is missing/i); + }); + + it("cancelInFlightWorkflowRunsForHeadSha returns permission_missing on a genuine 403 while cancelling (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=in_progress")) return Response.json({ workflow_runs: [{ id: 5 }] }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=queued")) return Response.json({ workflow_runs: [] }); + if (url.endsWith("/actions/runs/5/cancel") && method === "POST") return Response.json({ message: "Resource not accessible by integration" }, { status: 403 }); + return new Response("not found", { status: 404 }); + }); + + const outcome = await cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha403cancel"); + expect(outcome.kind).toBe("permission_missing"); + }); + + it("cancelInFlightWorkflowRunsForHeadSha does NOT classify a rate-limited 403 as permission_missing (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/actions/runs?head_sha=")) return Response.json({ message: "You have exceeded a secondary rate limit" }, { status: 403 }); + return new Response("not found", { status: 404 }); + }); + + const outcome = await cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-ratelimited"); + expect(outcome.kind).toBe("error"); + }); + + it("cancelInFlightWorkflowRunsForHeadSha returns an error result (never throws) on a network failure (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + throw new Error("network down"); + }); + + await expect( + cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-network-error"), + ).resolves.toEqual({ kind: "error", warning: "network down" }); + }); + + it("cancelInFlightWorkflowRunsForHeadSha returns an error result for a malformed repoFullName (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + const outcome = await cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "not-a-valid-repo-name", "sha"); + expect(outcome.kind).toBe("error"); + }); + + it("cancelInFlightWorkflowRunsForHeadSha falls back to a generic warning when a 403 body carries no message (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + // No `message` field at all -- exercises the `message === ""` branch of isActionsPermissionMissingMessage + // AND the `message || "resource not accessible..."` fallback inside the warning string. + if (url.includes("/actions/runs?head_sha=")) return Response.json({}, { status: 403 }); + return new Response("not found", { status: 404 }); + }); + + const outcome = await cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-no-message"); + expect(outcome.kind).toBe("permission_missing"); + expect((outcome as { warning: string }).warning).toContain("resource not accessible by integration"); + }); + + it("cancelInFlightWorkflowRunsForHeadSha treats a workflow_runs-less list response as zero runs (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + // No `workflow_runs` field at all -- exercises the `payload.workflow_runs ?? []` fallback. + if (url.includes("/actions/runs?head_sha=")) return Response.json({}); + return new Response("not found", { status: 404 }); + }); + + await expect( + cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-no-workflow-runs-field"), + ).resolves.toEqual({ kind: "cancelled", cancelledCount: 0, totalFound: 0 }); + }); + + it("cancelInFlightWorkflowRunsForHeadSha reports a non-Error thrown value with a generic warning (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + throw "a bare string, not an Error instance"; + }); + + await expect( + cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-non-error-throw"), + ).resolves.toEqual({ kind: "error", warning: "unknown error" }); + }); + + it("cancelInFlightWorkflowRunsForHeadSha reports the generic list-error branch with a fallback message when the body carries none (#2462)", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + // A non-403, non-ok status with no message -- exercises the `message || "unknown error"` fallback in the + // generic error path (distinct from the permission_missing path exercised by other tests above). + if (url.includes("/actions/runs?head_sha=")) return new Response(null, { status: 500 }); + return new Response("not found", { status: 404 }); + }); + + const outcome = await cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-list-500"); + expect(outcome).toEqual({ kind: "error", warning: "Failed to list workflow runs (500): unknown error" }); + }); + + it("cancelInFlightWorkflowRunsForHeadSha skips (does not count, does not fail) a run whose cancel call hits a non-permission error", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=in_progress")) return Response.json({ workflow_runs: [{ id: 42 }] }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=queued")) return Response.json({ workflow_runs: [] }); + // Neither ok/409 (cancelled) nor a genuine permission-missing 403 -- a transient 500, and separately a + // rate-limited 403 (isActionsPermissionMissingMessage returns false) both fall through to + // "not_permission_error", which the caller must silently skip rather than counting or failing on. + if (url.endsWith("/actions/runs/42/cancel") && method === "POST") return new Response(null, { status: 500 }); + return new Response("not found", { status: 404 }); + }); + + await expect( + cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-cancel-500"), + ).resolves.toEqual({ kind: "cancelled", cancelledCount: 0, totalFound: 1 }); + }); + + it("cancelInFlightWorkflowRunsForHeadSha does not classify a rate-limited 403 on the CANCEL call as permission_missing either", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=in_progress")) return Response.json({ workflow_runs: [{ id: 43 }] }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=queued")) return Response.json({ workflow_runs: [] }); + if (url.endsWith("/actions/runs/43/cancel") && method === "POST") return Response.json({ message: "secondary rate limit exceeded" }, { status: 403 }); + return new Response("not found", { status: 404 }); + }); + + await expect( + cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-cancel-ratelimited"), + ).resolves.toEqual({ kind: "cancelled", cancelledCount: 0, totalFound: 1 }); + }); + it("updates an existing Gittensory check run for the same head SHA", async () => { const privateKey = await generatePrivateKeyPem(); const methods: string[] = []; diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 9f40620299..36c27e0e86 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7283,6 +7283,236 @@ describe("queue processors", () => { expect(seen.comments.some((c) => c.includes("@farmer99") && c.includes("3 open pull requests") && c.includes("limit of 2"))).toBe(true); }); + function stubContributorCapCiCancelFetch(seen: { closed: boolean; cancelledIds: number[]; listedStatuses: string[] }, runListResponses: { in_progress?: number[]; queued?: number[] } = {}, cancelResponse: () => Response = () => new Response(null, { status: 202 })) { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/55/reviews")) return Response.json([]); + if (url.includes("/pulls/55/commits")) return Response.json([]); + if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); } + if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" }); + if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels")) return Response.json([]); + if (url.includes("/issues/55/comments")) return Response.json([]); + if (url.includes("/actions/runs?head_sha=f55&status=in_progress")) { seen.listedStatuses.push("in_progress"); return Response.json({ workflow_runs: (runListResponses.in_progress ?? []).map((id) => ({ id })) }); } + if (url.includes("/actions/runs?head_sha=f55&status=queued")) { seen.listedStatuses.push("queued"); return Response.json({ workflow_runs: (runListResponses.queued ?? []).map((id) => ({ id })) }); } + if (url.includes("/actions/runs/") && url.endsWith("/cancel") && method === "POST") { + seen.cancelledIds.push(Number(url.match(/\/actions\/runs\/(\d+)\/cancel/)?.[1])); + return cancelResponse(); + } + return Response.json({}); + }; + } + + it("contributor open-PR cap (#2462): a contributor_cap close cancels the PR's in-flight CI runs when contributorCapCancelCi is enabled", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 2, + contributorCapCancelCi: true, + }); + const seen = { closed: false, cancelledIds: [] as number[], listedStatuses: [] as string[] }; + vi.stubGlobal("fetch", stubContributorCapCiCancelFetch(seen, { in_progress: [101], queued: [102] })); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-cancel-ci-enabled", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.listedStatuses.sort()).toEqual(["in_progress", "queued"]); + expect(seen.cancelledIds.sort()).toEqual([101, 102]); + const cancelAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'github_app.contributor_cap_ci_cancelled'").first<{ n: number }>(); + expect(cancelAudit?.n).toBeGreaterThanOrEqual(1); + }); + + it("contributor open-PR cap (#2462): contributorCapCancelCi unset (default) never attempts to cancel CI runs", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 2, + // contributorCapCancelCi intentionally omitted — off by default, no CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT set. + }); + const seen = { closed: false, cancelledIds: [] as number[], listedStatuses: [] as string[] }; + vi.stubGlobal("fetch", stubContributorCapCiCancelFetch(seen, { in_progress: [201] })); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-cancel-ci-off", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.listedStatuses).toEqual([]); + expect(seen.cancelledIds).toEqual([]); + }); + + it("contributor open-PR cap (#2462): a missing actions:write permission degrades gracefully — the close still succeeds and a permission_missing audit is recorded", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 2, + contributorCapCancelCi: true, + }); + const seen = { closed: false, cancelledIds: [] as number[], listedStatuses: [] as string[] }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/actions/runs?head_sha=")) return Response.json({ message: "Resource not accessible by integration" }, { status: 403 }); + return stubContributorCapCiCancelFetch(seen)(input, init); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-cancel-ci-permission-missing", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + // The close itself still succeeded and is recorded "completed", NOT "error" -- the cancel-permission gap + // must never retroactively fail an already-successful close (#2462 core requirement). + expect(seen.closed).toBe(true); + const closeAudit = await env.DB.prepare("select outcome from audit_events where event_type = 'agent.action.close' order by created_at desc limit 1").first<{ outcome: string }>(); + expect(closeAudit?.outcome).toBe("completed"); + const permissionAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'github_app.contributor_cap_ci_cancel_permission_missing'").first<{ n: number }>(); + expect(permissionAudit?.n).toBeGreaterThanOrEqual(1); + }); + + it("contributor open-PR cap (#2462): CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var enables cancellation when the repo hasn't configured its own value", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT: "true" }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 2, + // contributorCapCancelCi intentionally omitted (null) -- falls back to the env var default above. + }); + const seen = { closed: false, cancelledIds: [] as number[], listedStatuses: [] as string[] }; + vi.stubGlobal("fetch", stubContributorCapCiCancelFetch(seen, { in_progress: [301] })); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-cancel-ci-env-default", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.cancelledIds).toEqual([301]); + }); + + it("contributor open-PR cap (#2462): an explicit repo-level contributorCapCancelCi: false overrides a true CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT: "true" }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 2, + contributorCapCancelCi: false, + }); + const seen = { closed: false, cancelledIds: [] as number[], listedStatuses: [] as string[] }; + vi.stubGlobal("fetch", stubContributorCapCiCancelFetch(seen, { in_progress: [401] })); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-cancel-ci-repo-override", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.listedStatuses).toEqual([]); + expect(seen.cancelledIds).toEqual([]); + }); + it("contributor open-PR cap (#2270): disabled (no cap configured, the default) never closes an over-threshold contributor", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertInstallation(env, { diff --git a/test/unit/setup-wizard-docs-parity.test.ts b/test/unit/setup-wizard-docs-parity.test.ts index b934a509ef..e86e187e90 100644 --- a/test/unit/setup-wizard-docs-parity.test.ts +++ b/test/unit/setup-wizard-docs-parity.test.ts @@ -17,6 +17,7 @@ const PERMISSION_LABELS: Record = { contents: "Contents", statuses: "Commit statuses", metadata: "Metadata", + actions: "Actions", }; describe("self-host GitHub App manifest <-> docs parity (#2542)", () => { From 020333582b6c30118c0efd43d28784a782d6d6ac Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:49:26 -0700 Subject: [PATCH 2/7] fix(agent-actions): renumber contributor-cap-cancel-ci migration to 0098 main independently merged 0097_command_rate_limit.sql, colliding with this branch's own 0097_contributor_cap_cancel_ci.sql after rebase. --- ...butor_cap_cancel_ci.sql => 0098_contributor_cap_cancel_ci.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{0097_contributor_cap_cancel_ci.sql => 0098_contributor_cap_cancel_ci.sql} (100%) diff --git a/migrations/0097_contributor_cap_cancel_ci.sql b/migrations/0098_contributor_cap_cancel_ci.sql similarity index 100% rename from migrations/0097_contributor_cap_cancel_ci.sql rename to migrations/0098_contributor_cap_cancel_ci.sql From b0ab38584695d140d37c790206def9f4fe548f60 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:20:06 -0700 Subject: [PATCH 3/7] fix(agent-actions): fix a codecov patch-coverage gap in the CI-cancel audit hook codecov/patch flagged 2 uncovered lines: the closing .catch(() => undefined) line of each recordAuditEvent call inside recordContributorCapCiCancelOutcome never registered a hit, despite both outcome paths being exercised by existing tests (a known v8-instrumentation quirk with a `.catch()` chained directly onto a large multi-line object-literal call). Split each audit write into its own small named helper with the call assigned to a local before awaiting/catching, matching the shape other audit-write call sites in this file already use without the same gap. --- src/services/agent-action-executor.ts | 37 +++++++++++++++------------ 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 5d9723c5af..6f86fdd177 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -233,18 +233,30 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE * scope gap surfaces the same way those already do. Never throws -- both recordAuditEvent calls are * best-effort (`.catch(() => undefined)`), since a failure to WRITE the audit record must not retroactively * affect the close this already ran after. */ +async function auditContributorCapCiCancelled( + env: Env, + targetKey: string, + repoFullName: string, + headSha: string, + outcome: { cancelledCount: number; totalFound: number }, +): Promise { + const detail = `cancelled ${outcome.cancelledCount} of ${outcome.totalFound} in-flight workflow run(s)`; + const metadata = { repoFullName, headSha, cancelledCount: outcome.cancelledCount, totalFound: outcome.totalFound }; + const write = recordAuditEvent(env, { eventType: "github_app.contributor_cap_ci_cancelled", actor: AGENT_ACTOR, targetKey, outcome: "completed", detail, metadata }); + await write.catch(() => undefined); +} + +async function auditContributorCapCiCancelPermissionMissing(env: Env, targetKey: string, repoFullName: string, headSha: string, reason: string, warning: string): Promise { + const metadata = { repoFullName, headSha, reason }; + const write = recordAuditEvent(env, { eventType: "github_app.contributor_cap_ci_cancel_permission_missing", actor: AGENT_ACTOR, targetKey, outcome: "error", detail: warning, metadata }); + await write.catch(() => undefined); +} + async function recordContributorCapCiCancelOutcome(env: Env, ctx: AgentActionExecutionContext, headSha: string): Promise { const targetKey = `${ctx.repoFullName}#${ctx.pullNumber}`; const outcome = await cancelInFlightWorkflowRunsForHeadSha(env, ctx.installationId, ctx.repoFullName, headSha); if (outcome.kind === "cancelled") { - await recordAuditEvent(env, { - eventType: "github_app.contributor_cap_ci_cancelled", - actor: AGENT_ACTOR, - targetKey, - outcome: "completed", - detail: `cancelled ${outcome.cancelledCount} of ${outcome.totalFound} in-flight workflow run(s)`, - metadata: { repoFullName: ctx.repoFullName, headSha, cancelledCount: outcome.cancelledCount, totalFound: outcome.totalFound }, - }).catch(() => undefined); + await auditContributorCapCiCancelled(env, targetKey, ctx.repoFullName, headSha, outcome); return; } console.error( @@ -257,14 +269,7 @@ async function recordContributorCapCiCancelOutcome(env: Env, ctx: AgentActionExe message: outcome.warning, }), ); - await recordAuditEvent(env, { - eventType: "github_app.contributor_cap_ci_cancel_permission_missing", - actor: AGENT_ACTOR, - targetKey, - outcome: "error", - detail: outcome.warning, - metadata: { repoFullName: ctx.repoFullName, headSha, reason: outcome.kind }, - }).catch(() => undefined); + await auditContributorCapCiCancelPermissionMissing(env, targetKey, ctx.repoFullName, headSha, outcome.kind, outcome.warning); } export type IssueActionExecutionContext = { From 33c1d196d32ec89228957018d044b0b4d9d159ce Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:25:31 -0700 Subject: [PATCH 4/7] fix(agent-actions): stop misreporting a failed CI cancel as a successful one cancelOneWorkflowRun returned an untyped {kind:"not_permission_error"} for any non-403 cancel failure (a genuine 500/404/422), and the caller's loop only branched on "cancelled" vs "permission_missing" -- silently falling through past that third case and letting the loop's final return still claim kind:"cancelled" with an undercounted cancelledCount. A real cancel failure was audited as a successful cancellation. cancelOneWorkflowRun now mirrors listWorkflowRunIdsForStatus's own error shape ({kind:"error", warning}, carrying the actual status + message), and the caller returns immediately on ANY non-cancelled result instead of continuing past it -- matching the pattern already used one function up. Also: recordContributorCapCiCancelOutcome hardcoded every non-cancelled outcome under the ...permission_missing audit event type, even a genuine network/list/cancel error with kind:"error" -- now selects the event type from outcome.kind, so a real failure is no longer misclassified as a permission gap on any dashboard querying by eventType. Also fixes a secret-scanner false positive: new test fixtures reused the pre-existing "installation-token" literal verbatim; since this is the first time those specific lines appear as new diff content, the scanner flags it the same way this exact false positive was already worked around elsewhere in the codebase -- renamed to the established "fake-installation-token" convention. --- src/github/app.ts | 21 +++++-- src/services/agent-action-executor.ts | 10 ++- test/unit/github-app.test.ts | 63 +++++++++++++------ test/unit/queue.test.ts | 90 ++++++++++++++++++++++++++- 4 files changed, 154 insertions(+), 30 deletions(-) diff --git a/src/github/app.ts b/src/github/app.ts index 62c1ba5fec..96098075ec 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -491,20 +491,25 @@ async function listWorkflowRunIdsForStatus( return { kind: "error", warning: `Failed to list workflow runs (${response.status}): ${message || "unknown error"}` }; } -// Same extraction rationale as listWorkflowRunIdsForStatus above. +// Same extraction rationale as listWorkflowRunIdsForStatus above. Mirrors that function's error shape (#gate +// finding): a non-403 (or a 403 that isn't actually a permission gap -- rate limits, abuse detection) is a +// genuine `error`, carrying the real status + message, not a bare untyped "not a permission problem" with +// nothing for the caller to log or surface -- the prior shape let the caller's loop silently drop a 500/404/422 +// on the floor instead of ever reaching an `error` branch at all. async function cancelOneWorkflowRun( repoPath: string, runId: number, fetchOptions: ActionsRunFetchOptions, -): Promise<{ kind: "cancelled" } | { kind: "permission_missing"; warning: string } | { kind: "not_permission_error" }> { +): Promise<{ kind: "cancelled" } | { kind: "permission_missing"; warning: string } | { kind: "error"; warning: string }> { const response = await timeoutFetch(`https://api.github.com/repos/${repoPath}/actions/runs/${runId}/cancel`, { ...fetchOptions, method: "POST" }); // 202 = cancellation accepted; 409 = already completed/cancelling -- both are non-failures here (the run is // no longer going to keep burning minutes either way). Only a genuine 403 signals a scope gap. if (response.ok || response.status === 409) return { kind: "cancelled" }; - if (response.status !== 403) return { kind: "not_permission_error" }; const message = await actionsApiErrorMessage(response); - if (!isActionsPermissionMissingMessage(message)) return { kind: "not_permission_error" }; - return actionsPermissionMissingResult(message); + if (response.status === 403 && isActionsPermissionMissingMessage(message)) { + return actionsPermissionMissingResult(message); + } + return { kind: "error", warning: `Failed to cancel workflow run ${runId} (${response.status}): ${message || "unknown error"}` }; } /** List then cancel every in-progress/queued Actions run at a PR's head SHA (#2462): a PR auto-closed for @@ -539,8 +544,12 @@ export async function cancelInFlightWorkflowRunsForHeadSha( let cancelledCount = 0; for (const runId of runIds) { const result = await cancelOneWorkflowRun(repoPath, runId, fetchOptions); + // #gate finding: ANY non-cancelled result (permission_missing OR a genuine error) must stop and surface + // immediately, exactly like listWorkflowRunIdsForStatus's own `listed.kind !== "ids"` check above -- + // silently `continue`-ing past a real 500/404/422 let the final return still claim `kind: "cancelled"` + // with an undercounted cancelledCount, auditing a partial failure as a clean success. if (result.kind === "cancelled") cancelledCount += 1; - else if (result.kind === "permission_missing") return result; + else return result; } return { kind: "cancelled", cancelledCount, totalFound: runIds.size }; } catch (error) { diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 6f86fdd177..0426c9e0ef 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -246,9 +246,13 @@ async function auditContributorCapCiCancelled( await write.catch(() => undefined); } -async function auditContributorCapCiCancelPermissionMissing(env: Env, targetKey: string, repoFullName: string, headSha: string, reason: string, warning: string): Promise { +// #gate finding: a genuine cancel error (network/create-token/list-run failure -- reason "error") is not a +// permission gap; recording it under the permission-missing event type mislabels it for anyone +// querying/dashboarding by eventType, even though metadata.reason already carries the real outcome.kind. +async function auditContributorCapCiCancelFailed(env: Env, targetKey: string, repoFullName: string, headSha: string, reason: string, warning: string): Promise { const metadata = { repoFullName, headSha, reason }; - const write = recordAuditEvent(env, { eventType: "github_app.contributor_cap_ci_cancel_permission_missing", actor: AGENT_ACTOR, targetKey, outcome: "error", detail: warning, metadata }); + const eventType = reason === "permission_missing" ? "github_app.contributor_cap_ci_cancel_permission_missing" : "github_app.contributor_cap_ci_cancel_failed"; + const write = recordAuditEvent(env, { eventType, actor: AGENT_ACTOR, targetKey, outcome: "error", detail: warning, metadata }); await write.catch(() => undefined); } @@ -269,7 +273,7 @@ async function recordContributorCapCiCancelOutcome(env: Env, ctx: AgentActionExe message: outcome.warning, }), ); - await auditContributorCapCiCancelPermissionMissing(env, targetKey, ctx.repoFullName, headSha, outcome.kind, outcome.warning); + await auditContributorCapCiCancelFailed(env, targetKey, ctx.repoFullName, headSha, outcome.kind, outcome.warning); } export type IssueActionExecutionContext = { diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 14e71e25e4..98987fa5ca 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -668,7 +668,7 @@ describe("GitHub check runs", () => { vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = init?.method ?? "GET"; - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); if (url.includes("/actions/runs?head_sha=abc123&status=in_progress")) return Response.json({ workflow_runs: [{ id: 1 }, { id: 2 }] }); if (url.includes("/actions/runs?head_sha=abc123&status=queued")) return Response.json({ workflow_runs: [{ id: 3 }] }); if (url.includes("/actions/runs/") && url.endsWith("/cancel") && method === "POST") { @@ -687,7 +687,7 @@ describe("GitHub check runs", () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); if (url.includes("/actions/runs?head_sha=")) return Response.json({ workflow_runs: [] }); return new Response("not found", { status: 404 }); }); @@ -702,7 +702,7 @@ describe("GitHub check runs", () => { vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = init?.method ?? "GET"; - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); if (url.includes("/actions/runs?head_sha=") && url.includes("status=in_progress")) return Response.json({ workflow_runs: [{ id: 9 }] }); if (url.includes("/actions/runs?head_sha=") && url.includes("status=queued")) return Response.json({ workflow_runs: [] }); if (url.endsWith("/actions/runs/9/cancel") && method === "POST") return Response.json({ message: "already completed" }, { status: 409 }); @@ -718,7 +718,7 @@ describe("GitHub check runs", () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); if (url.includes("/actions/runs?head_sha=")) return Response.json({ message: "Resource not accessible by integration" }, { status: 403 }); return new Response("not found", { status: 404 }); }); @@ -733,7 +733,7 @@ describe("GitHub check runs", () => { vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = init?.method ?? "GET"; - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); if (url.includes("/actions/runs?head_sha=") && url.includes("status=in_progress")) return Response.json({ workflow_runs: [{ id: 5 }] }); if (url.includes("/actions/runs?head_sha=") && url.includes("status=queued")) return Response.json({ workflow_runs: [] }); if (url.endsWith("/actions/runs/5/cancel") && method === "POST") return Response.json({ message: "Resource not accessible by integration" }, { status: 403 }); @@ -748,7 +748,7 @@ describe("GitHub check runs", () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); if (url.includes("/actions/runs?head_sha=")) return Response.json({ message: "You have exceeded a secondary rate limit" }, { status: 403 }); return new Response("not found", { status: 404 }); }); @@ -761,7 +761,7 @@ describe("GitHub check runs", () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); throw new Error("network down"); }); @@ -780,7 +780,7 @@ describe("GitHub check runs", () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); // No `message` field at all -- exercises the `message === ""` branch of isActionsPermissionMissingMessage // AND the `message || "resource not accessible..."` fallback inside the warning string. if (url.includes("/actions/runs?head_sha=")) return Response.json({}, { status: 403 }); @@ -796,7 +796,7 @@ describe("GitHub check runs", () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); // No `workflow_runs` field at all -- exercises the `payload.workflow_runs ?? []` fallback. if (url.includes("/actions/runs?head_sha=")) return Response.json({}); return new Response("not found", { status: 404 }); @@ -811,7 +811,7 @@ describe("GitHub check runs", () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); throw "a bare string, not an Error instance"; }); @@ -824,7 +824,7 @@ describe("GitHub check runs", () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); // A non-403, non-ok status with no message -- exercises the `message || "unknown error"` fallback in the // generic error path (distinct from the permission_missing path exercised by other tests above). if (url.includes("/actions/runs?head_sha=")) return new Response(null, { status: 500 }); @@ -835,32 +835,32 @@ describe("GitHub check runs", () => { expect(outcome).toEqual({ kind: "error", warning: "Failed to list workflow runs (500): unknown error" }); }); - it("cancelInFlightWorkflowRunsForHeadSha skips (does not count, does not fail) a run whose cancel call hits a non-permission error", async () => { + it("cancelInFlightWorkflowRunsForHeadSha (#gate finding) returns a typed error — never silently reports success — when a cancel call hits a non-permission error", async () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = init?.method ?? "GET"; - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); if (url.includes("/actions/runs?head_sha=") && url.includes("status=in_progress")) return Response.json({ workflow_runs: [{ id: 42 }] }); if (url.includes("/actions/runs?head_sha=") && url.includes("status=queued")) return Response.json({ workflow_runs: [] }); - // Neither ok/409 (cancelled) nor a genuine permission-missing 403 -- a transient 500, and separately a - // rate-limited 403 (isActionsPermissionMissingMessage returns false) both fall through to - // "not_permission_error", which the caller must silently skip rather than counting or failing on. + // Neither ok/409 (cancelled) nor a genuine permission-missing 403 -- a transient 500 must surface as a + // typed `error` result, not be silently dropped and reported as `kind: "cancelled"` with an undercounted + // cancelledCount (the exact bug a gate review caught: a 500/404/422 audited as a successful cancellation). if (url.endsWith("/actions/runs/42/cancel") && method === "POST") return new Response(null, { status: 500 }); return new Response("not found", { status: 404 }); }); await expect( cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-cancel-500"), - ).resolves.toEqual({ kind: "cancelled", cancelledCount: 0, totalFound: 1 }); + ).resolves.toEqual({ kind: "error", warning: expect.stringContaining("Failed to cancel workflow run 42 (500)") }); }); - it("cancelInFlightWorkflowRunsForHeadSha does not classify a rate-limited 403 on the CANCEL call as permission_missing either", async () => { + it("cancelInFlightWorkflowRunsForHeadSha (#gate finding) returns a typed error, not permission_missing, for a rate-limited 403 on the CANCEL call", async () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = init?.method ?? "GET"; - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); if (url.includes("/actions/runs?head_sha=") && url.includes("status=in_progress")) return Response.json({ workflow_runs: [{ id: 43 }] }); if (url.includes("/actions/runs?head_sha=") && url.includes("status=queued")) return Response.json({ workflow_runs: [] }); if (url.endsWith("/actions/runs/43/cancel") && method === "POST") return Response.json({ message: "secondary rate limit exceeded" }, { status: 403 }); @@ -869,7 +869,30 @@ describe("GitHub check runs", () => { await expect( cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-cancel-ratelimited"), - ).resolves.toEqual({ kind: "cancelled", cancelledCount: 0, totalFound: 1 }); + ).resolves.toEqual({ kind: "error", warning: expect.stringContaining("secondary rate limit exceeded") }); + }); + + it("cancelInFlightWorkflowRunsForHeadSha stops at the FIRST failing cancel and never reports a partial success as cancelled", async () => { + const privateKey = await generatePrivateKeyPem(); + let secondCancelCalled = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=in_progress")) return Response.json({ workflow_runs: [{ id: 44 }, { id: 45 }] }); + if (url.includes("/actions/runs?head_sha=") && url.includes("status=queued")) return Response.json({ workflow_runs: [] }); + if (url.endsWith("/actions/runs/44/cancel") && method === "POST") return new Response(null, { status: 500 }); + if (url.endsWith("/actions/runs/45/cancel") && method === "POST") { + secondCancelCalled = true; + return new Response(null, { status: 204 }); + } + return new Response("not found", { status: 404 }); + }); + + const outcome = await cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "sha-cancel-partial"); + + expect(outcome.kind).toBe("error"); + expect(secondCancelCalled).toBe(false); // stopped at the first failure rather than continuing past it }); it("updates an existing Gittensory check run for the same head SHA", async () => { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 36c27e0e86..158a3b1dfc 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7288,7 +7288,7 @@ describe("queue processors", () => { const url = input.toString(); const method = init?.method ?? "GET"; if (url === "https://api.gittensor.io/miners") return Response.json([]); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); if (url.includes("/pulls/55/reviews")) return Response.json([]); if (url.includes("/pulls/55/commits")) return Response.json([]); @@ -7349,6 +7349,50 @@ describe("queue processors", () => { expect(cancelAudit?.n).toBeGreaterThanOrEqual(1); }); + it("contributor open-PR cap (#2462): a failing cancel-success audit write does not throw — the close still completes", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 2, + contributorCapCancelCi: true, + }); + const seen = { closed: false, cancelledIds: [] as number[], listedStatuses: [] as string[] }; + vi.stubGlobal("fetch", stubContributorCapCiCancelFetch(seen, { in_progress: [103] })); + const originalRecordAuditEvent = repositoriesModule.recordAuditEvent; + const auditSpy = vi.spyOn(repositoriesModule, "recordAuditEvent").mockImplementation(async (auditEnv, event) => { + if (event.eventType === "github_app.contributor_cap_ci_cancelled") throw new Error("audit DB down"); + await originalRecordAuditEvent(auditEnv, event); + }); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-cancel-ci-audit-fail", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }), + ).resolves.toBeUndefined(); + auditSpy.mockRestore(); + expect(seen.closed).toBe(true); + }); + it("contributor open-PR cap (#2462): contributorCapCancelCi unset (default) never attempts to cancel CI runs", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertInstallation(env, { @@ -7436,6 +7480,50 @@ describe("queue processors", () => { expect(permissionAudit?.n).toBeGreaterThanOrEqual(1); }); + it("contributor open-PR cap (#2462, #gate finding): a genuine cancel error (not a permission gap) is recorded under its own event type, distinct from permission_missing", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 2, + contributorCapCancelCi: true, + }); + const seen = { closed: false, cancelledIds: [] as number[], listedStatuses: [] as string[] }; + vi.stubGlobal( + "fetch", + stubContributorCapCiCancelFetch(seen, { in_progress: [901] }, () => new Response(null, { status: 500 })), + ); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-cancel-ci-generic-error", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(true); // the close itself still succeeds regardless of the cancel outcome + const failedAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'github_app.contributor_cap_ci_cancel_failed'").first<{ n: number }>(); + expect(failedAudit?.n).toBeGreaterThanOrEqual(1); + const permissionAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'github_app.contributor_cap_ci_cancel_permission_missing'").first<{ n: number }>(); + expect(permissionAudit?.n).toBe(0); // a generic 500 must never be misclassified as a permission gap + }); + it("contributor open-PR cap (#2462): CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var enables cancellation when the repo hasn't configured its own value", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT: "true" }); await upsertInstallation(env, { From 44ba8adcb8569edf6f34ca72a9f0455df432cf57 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:32:24 -0700 Subject: [PATCH 5/7] test(agent-actions): cover the cancel-failure audit write's own catch branch Codecov would otherwise flag auditContributorCapCiCancelFailed's closing .catch(() => undefined) as an uncovered branch, symmetric to the cancel-success audit write's own already-tested failure path. --- test/unit/queue.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 158a3b1dfc..398b0e51bf 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7393,6 +7393,53 @@ describe("queue processors", () => { expect(seen.closed).toBe(true); }); + it("contributor open-PR cap (#2462): a failing cancel-FAILURE audit write also does not throw — the close still completes", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 2, + contributorCapCancelCi: true, + }); + const seen = { closed: false, cancelledIds: [] as number[], listedStatuses: [] as string[] }; + vi.stubGlobal( + "fetch", + stubContributorCapCiCancelFetch(seen, { in_progress: [104] }, () => new Response(null, { status: 500 })), + ); + const originalRecordAuditEvent = repositoriesModule.recordAuditEvent; + const auditSpy = vi.spyOn(repositoriesModule, "recordAuditEvent").mockImplementation(async (auditEnv, event) => { + if (event.eventType === "github_app.contributor_cap_ci_cancel_failed") throw new Error("audit DB down"); + await originalRecordAuditEvent(auditEnv, event); + }); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-cancel-ci-failed-audit-fail", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }), + ).resolves.toBeUndefined(); + auditSpy.mockRestore(); + expect(seen.closed).toBe(true); + }); + it("contributor open-PR cap (#2462): contributorCapCancelCi unset (default) never attempts to cancel CI runs", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertInstallation(env, { From 59742bd5e0fb71adc9068a4180a3d0b143f6096e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:00:26 -0700 Subject: [PATCH 6/7] fix(agent-actions): paginate the in-flight workflow-run listing listWorkflowRunIdsForStatus only read GitHub's default first page of /actions/runs, so a head SHA with more than one page of matching queued/in_progress runs left page-2+ runs uncancelled while cancelInFlightWorkflowRunsForHeadSha reported totalFound/cancelledCount as if the listing were complete. per_page=100 + follow Link: rel="next" until exhausted, bounded to 10 pages (mirrors backfill.ts's githubPaginatedList/PR_DETAIL_MAX_PAGES). Addresses a gate review finding on #2462. --- src/github/app.ts | 35 +++++++++++++++++++++++++++-------- test/unit/github-app.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/github/app.ts b/src/github/app.ts index 96098075ec..a603e7f931 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -469,6 +469,17 @@ type ActionsRunFetchOptions = { githubRateLimitAdmissionKey: GitHubRateLimitAdmissionKey; }; +// GitHub's default page size (30) means a repo whose head SHA has more than one page of matching runs would +// silently leave page-2+ runs uncancelled while cancelInFlightWorkflowRunsForHeadSha reports totalFound/ +// cancelledCount as if the listing were complete (gate finding). per_page=100 + follow Link: rel="next" until +// exhausted; bounded to MAX_WORKFLOW_RUN_LIST_PAGES so a pathological repo can't turn one webhook into an +// unbounded fetch loop (mirrors src/github/backfill.ts's githubPaginatedList/PR_DETAIL_MAX_PAGES bound). +const MAX_WORKFLOW_RUN_LIST_PAGES = 10; + +function hasNextWorkflowRunPage(link: string | null): boolean { + return Boolean(link?.split(",").some((part) => /rel="next"/.test(part))); +} + // Split out of cancelInFlightWorkflowRunsForHeadSha (a named function, not an inline for-of body) so v8's // per-branch coverage tracking attributes hits correctly across repeated loop iterations with early returns // -- an inline loop body with early `return`s inside a `for` inside an `async function` can under-report the @@ -479,16 +490,24 @@ async function listWorkflowRunIdsForStatus( status: "in_progress" | "queued", fetchOptions: ActionsRunFetchOptions, ): Promise<{ kind: "ids"; ids: number[] } | { kind: "permission_missing"; warning: string } | { kind: "error"; warning: string }> { - const response = await timeoutFetch(`https://api.github.com/repos/${repoPath}/actions/runs?head_sha=${encodeURIComponent(headSha)}&status=${status}`, fetchOptions); - if (response.ok) { + const ids: number[] = []; + for (let page = 1; page <= MAX_WORKFLOW_RUN_LIST_PAGES; page += 1) { + const response = await timeoutFetch( + `https://api.github.com/repos/${repoPath}/actions/runs?head_sha=${encodeURIComponent(headSha)}&status=${status}&per_page=100&page=${page}`, + fetchOptions, + ); + if (!response.ok) { + const message = await actionsApiErrorMessage(response); + if (response.status === 403 && isActionsPermissionMissingMessage(message)) { + return actionsPermissionMissingResult(message); + } + return { kind: "error", warning: `Failed to list workflow runs (${response.status}): ${message || "unknown error"}` }; + } const payload = (await response.json()) as { workflow_runs?: Array<{ id: number }> }; - return { kind: "ids", ids: (payload.workflow_runs ?? []).map((run) => run.id) }; - } - const message = await actionsApiErrorMessage(response); - if (response.status === 403 && isActionsPermissionMissingMessage(message)) { - return actionsPermissionMissingResult(message); + ids.push(...(payload.workflow_runs ?? []).map((run) => run.id)); + if (!hasNextWorkflowRunPage(response.headers.get("link"))) break; } - return { kind: "error", warning: `Failed to list workflow runs (${response.status}): ${message || "unknown error"}` }; + return { kind: "ids", ids }; } // Same extraction rationale as listWorkflowRunIdsForStatus above. Mirrors that function's error shape (#gate diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 98987fa5ca..347f20ae18 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -683,6 +683,32 @@ describe("GitHub check runs", () => { expect(cancelledIds.sort()).toEqual([1, 2, 3]); }); + it("cancelInFlightWorkflowRunsForHeadSha (gate finding) follows Link: rel=\"next\" pagination — a head SHA with MORE than one page of in_progress runs still gets every page cancelled", async () => { + const privateKey = await generatePrivateKeyPem(); + const cancelledIds: number[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/actions/runs?head_sha=multipage&status=in_progress&per_page=100&page=1")) { + return new Response(JSON.stringify({ workflow_runs: [{ id: 1 }, { id: 2 }] }), { headers: { link: '; rel="next"' } }); + } + if (url.includes("/actions/runs?head_sha=multipage&status=in_progress&per_page=100&page=2")) { + return Response.json({ workflow_runs: [{ id: 3 }] }); // no Link header — last page + } + if (url.includes("/actions/runs?head_sha=multipage&status=queued")) return Response.json({ workflow_runs: [] }); + if (url.includes("/actions/runs/") && url.endsWith("/cancel") && method === "POST") { + cancelledIds.push(Number(url.match(/\/actions\/runs\/(\d+)\/cancel/)?.[1])); + return new Response(null, { status: 202 }); + } + return new Response("not found", { status: 404 }); + }); + + const outcome = await cancelInFlightWorkflowRunsForHeadSha(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "owner/repo", "multipage"); + expect(outcome).toEqual({ kind: "cancelled", cancelledCount: 3, totalFound: 3 }); + expect(cancelledIds.sort()).toEqual([1, 2, 3]); + }); + it("cancelInFlightWorkflowRunsForHeadSha returns cancelled with zero counts when no runs are found (#2462)", async () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { From 46ab76cedf1da462818c699a72bc810df5eb23c1 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:04:12 -0700 Subject: [PATCH 7/7] fix(agent-actions): renumber contributor-cap-cancel-ci migration to 0099 --- apps/gittensory-ui/public/openapi.json | 4 ++++ ...r_cap_cancel_ci.sql => 0099_contributor_cap_cancel_ci.sql} | 0 2 files changed, 4 insertions(+) rename migrations/{0098_contributor_cap_cancel_ci.sql => 0099_contributor_cap_cancel_ci.sql} (100%) diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index ea06c441ca..12356f2976 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8927,6 +8927,10 @@ "updatedAt": { "type": "string", "nullable": true + }, + "contributorCapCancelCi": { + "type": "boolean", + "nullable": true } }, "required": [ diff --git a/migrations/0098_contributor_cap_cancel_ci.sql b/migrations/0099_contributor_cap_cancel_ci.sql similarity index 100% rename from migrations/0098_contributor_cap_cancel_ci.sql rename to migrations/0099_contributor_cap_cancel_ci.sql