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/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/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; +}; + +// 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 +// "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 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 }> }; + ids.push(...(payload.workflow_runs ?? []).map((run) => run.id)); + if (!hasNextWorkflowRunPage(response.headers.get("link"))) break; + } + return { kind: "ids", ids }; +} + +// 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: "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" }; + const message = await actionsApiErrorMessage(response); + 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 + * 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); + // #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 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,55 @@ 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 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); +} + +// #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 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); +} + +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 auditContributorCapCiCancelled(env, targetKey, ctx.repoFullName, headSha, outcome); + 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 auditContributorCapCiCancelFailed(env, targetKey, ctx.repoFullName, headSha, outcome.kind, outcome.warning); +} + 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..347f20ae18 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,265 @@ 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: "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") { + 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 (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) => { + const url = input.toString(); + 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 }); + }); + + 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: "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 }); + 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: "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 }); + }); + + 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: "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 }); + 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: "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 }); + }); + + 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: "fake-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: "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 }); + 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: "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 }); + }); + + 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: "fake-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: "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 }); + 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 (#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: "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 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: "error", warning: expect.stringContaining("Failed to cancel workflow run 42 (500)") }); + }); + + 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: "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 }); + 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: "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 () => { const privateKey = await generatePrivateKeyPem(); const methods: string[] = []; diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 9f40620299..398b0e51bf 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7283,6 +7283,371 @@ 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: "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([]); + 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): 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): 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, { + 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, #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, { + 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)", () => {