Skip to content
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8927,6 +8927,10 @@
"updatedAt": {
"type": "string",
"nullable": true
},
"contributorCapCancelCi": {
"type": "boolean",
"nullable": true
}
},
"required": [
Expand Down
41 changes: 41 additions & 0 deletions apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,52 @@ SELFHOST_SETUP_TOKEN=change-this-long-random-value # unlocks /setup for a fresh
</li>
<li>Commit statuses: read.</li>
<li>Metadata: read.</li>
<li>
Actions: write — lets a repo opt into cancelling a closed PR's in-flight CI runs (the{" "}
<code>contributorCapCancelCi</code> 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.
</li>
</ul>
<p>
Events: pull request, pull request review, push, issues, check suite, check run, and status.
</p>

<h2>Re-approving a permission bump on an existing App</h2>
<p>
A future release can widen this permission list (most recently, Actions: write for the
opt-in CI-cancellation feature). GitHub does <strong>not</strong> 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.
</p>
<p>
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.
</p>
<p>To re-approve:</p>
<ol>
<li>
Open your App's settings page —{" "}
<code>https://github.com/settings/apps/&lt;your-app-slug&gt;/permissions</code>{" "}
(organization Apps:{" "}
<code>
https://github.com/organizations/&lt;org&gt;/settings/apps/&lt;your-app-slug&gt;/permissions
</code>
).
</li>
<li>
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.
</li>
<li>
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.
</li>
</ol>

<h2>Direct App env</h2>
<CodeBlock
filename=".env"
Expand Down
5 changes: 5 additions & 0 deletions migrations/0099_contributor_cap_cancel_ci.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- CI-run cancellation on a contributor_cap close (#2462): nullable, not NOT NULL DEFAULT 0 -- null means
-- "unset", distinct from an explicit false, so the CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var can act as the
-- fallback only when a repo hasn't configured this. Existing repos see no behavior change until either the
-- repo opts in or the operator sets the global env default, AND the App installation has granted actions:write.
ALTER TABLE repository_settings ADD COLUMN contributor_cap_cancel_ci INTEGER;
5 changes: 5 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
contributorOpenPrCap: null,
contributorOpenIssueCap: null,
contributorCapLabel: "over-contributor-limit",
contributorCapCancelCi: null,
reviewNagPolicy: "off",
reviewNagMaxPings: 3,
reviewNagCooldownDays: 5,
Expand Down Expand Up @@ -565,6 +566,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
contributorOpenPrCap: normalizeOpenItemCap(row.contributorOpenPrCap),
contributorOpenIssueCap: normalizeOpenItemCap(row.contributorOpenIssueCap),
contributorCapLabel: row.contributorCapLabel,
contributorCapCancelCi: row.contributorCapCancelCi,
reviewNagPolicy: normalizeReviewNagPolicy(row.reviewNagPolicy),
reviewNagMaxPings: normalizePositiveIntWithDefault(row.reviewNagMaxPings, 3),
reviewNagCooldownDays: normalizeReviewNagCooldownDays(row.reviewNagCooldownDays, 5),
Expand Down Expand Up @@ -656,6 +658,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
contributorOpenPrCap: normalizeOpenItemCap(settings.contributorOpenPrCap),
contributorOpenIssueCap: normalizeOpenItemCap(settings.contributorOpenIssueCap),
contributorCapLabel: settings.contributorCapLabel ?? "over-contributor-limit",
contributorCapCancelCi: typeof settings.contributorCapCancelCi === "boolean" ? settings.contributorCapCancelCi : null,
reviewNagPolicy: normalizeReviewNagPolicy(settings.reviewNagPolicy),
reviewNagMaxPings: normalizePositiveIntWithDefault(settings.reviewNagMaxPings, 3),
reviewNagCooldownDays: normalizeReviewNagCooldownDays(settings.reviewNagCooldownDays, 5),
Expand Down Expand Up @@ -717,6 +720,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
contributorOpenPrCap: resolved.contributorOpenPrCap,
contributorOpenIssueCap: resolved.contributorOpenIssueCap,
contributorCapLabel: resolved.contributorCapLabel,
contributorCapCancelCi: resolved.contributorCapCancelCi,
reviewNagPolicy: resolved.reviewNagPolicy,
reviewNagMaxPings: resolved.reviewNagMaxPings,
reviewNagCooldownDays: resolved.reviewNagCooldownDays,
Expand Down Expand Up @@ -779,6 +783,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
contributorOpenPrCap: resolved.contributorOpenPrCap,
contributorOpenIssueCap: resolved.contributorOpenIssueCap,
contributorCapLabel: resolved.contributorCapLabel,
contributorCapCancelCi: resolved.contributorCapCancelCi,
reviewNagPolicy: resolved.reviewNagPolicy,
reviewNagMaxPings: resolved.reviewNagMaxPings,
reviewNagCooldownDays: resolved.reviewNagCooldownDays,
Expand Down
5 changes: 5 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export const repositorySettings = sqliteTable("repository_settings", {
contributorOpenPrCap: integer("contributor_open_pr_cap"),
contributorOpenIssueCap: integer("contributor_open_issue_cap"),
contributorCapLabel: text("contributor_cap_label").notNull().default("over-contributor-limit"),
// Cancel in-flight CI runs on a contributor_cap close (#2462): null = unset, falls back to the
// CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var (nullable, unlike a plain boolean toggle, so an explicit `false`
// is distinguishable from "not configured" for that fallback). Only meaningful when the App installation has
// granted actions:write -- degrades gracefully (logs, never blocks the close) otherwise.
contributorCapCancelCi: integer("contributor_cap_cancel_ci", { mode: "boolean" }),
// Review-request nagging cooldown (#2463, anti-abuse): default 'off' (disabled).
reviewNagPolicy: text("review_nag_policy").notNull().default("off"),
reviewNagMaxPings: integer("review_nag_max_pings").notNull().default(3),
Expand Down
5 changes: 5 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ declare global {
* same contributor_cap short-circuit (src/settings/agent-actions.ts). A positive integer string (e.g. "20");
* see src/settings/global-contributor-cap.ts for parsing. */
GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP?: string;
/** Install-wide default for the per-repo contributorCapCancelCi setting (#2462): "true"/"1"/"yes"/"on"
* (case-insensitive) enables cancelling in-flight CI runs on a contributor_cap close for every repo that
* hasn't explicitly configured its own value. Unset/blank/anything else = off (the existing behavior). A
* repo's own `contributorCapCancelCi` (DB or `.gittensory.yml`) always takes precedence over this. */
CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT?: string;
GITHUB_WEBHOOK_SECRET: string;
GITHUB_WEBHOOK_MAX_BODY_BYTES?: string;
/** Webhook secret for the central Gittensory Orb GitHub App (#1255) — distinct from the review app's
Expand Down
143 changes: 143 additions & 0 deletions src/github/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
githubRateLimitAdmissionKeyForInstallation,
makeInstallationOctokit,
timeoutFetch,
type GitHubRateLimitAdmissionKey,
} from "./client";
import { maintainerControlPanelUrl } from "./footer";
import type { AgentActionMode } from "../settings/agent-execution";
Expand Down Expand Up @@ -433,6 +434,148 @@ export async function getGithubUserCreatedAt(
}
}

/** Sentinel result for cancelInFlightWorkflowRunsForHeadSha (#2462) -- mirrors CheckRunOutcome's shape (a
* typed "degraded, not thrown" result) so a missing `actions: write` grant never has to be distinguished
* from a genuine network/API failure by the caller via exception type-narrowing. */
export type CancelWorkflowRunsOutcome =
| { kind: "cancelled"; cancelledCount: number; totalFound: number }
| { kind: "permission_missing"; warning: string }
| { kind: "error"; warning: string };

// A rate-limit / secondary-limit 403 is NOT a permission gap -- mirrors isCheckRunPermissionError's own
// exclusion (src/github/app.ts, isRateLimitedError check) so a burst-load 403 is never misrecorded as a
// permanent actions:write scope gap. Operates on the RAW response body's `message` field (not a thrown
// Octokit error) since these wrappers use plain timeoutFetch, not an Octokit client.
function isActionsPermissionMissingMessage(message: string): boolean {
if (/secondary rate limit|\babuse\b|api rate limit exceeded|rate limit/i.test(message)) return false;
return /resource not accessible by integration|not have permission/i.test(message) || message === "";
}

async function actionsApiErrorMessage(response: Response): Promise<string> {
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<CancelWorkflowRunsOutcome> {
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<number>();
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
Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading