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:
+
+ -
+ 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
+
+ ).
+
+ -
+ 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.
+
+ -
+ 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.
+
+
+
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)", () => {