diff --git a/src/github/app.ts b/src/github/app.ts index 8b5b972716..b1c8a64af3 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -201,6 +201,32 @@ export async function createOrUpdateErroredGateCheckRun( }); } +/** + * Finalize the current Gate check to a NEUTRAL (non-blocking) terminal state because a maintainer ran + * `@gittensory gate-override`. This applies to THIS commit only: the override is not persisted anywhere, + * so the next push re-evaluates the Gate from scratch (no permanent bypass). Called WITHOUT a checkRunId + * so createOrUpdateNamedCheckRun resolves the current Gate run by advisory.headSha. + */ +export async function createOrUpdateOverriddenGateCheckRun( + env: Env, + installationId: number, + repoFullName: string, + advisory: Advisory, + options: { actor: string; reason: string; checkRunId?: number | undefined }, +): Promise { + return createOrUpdateNamedCheckRun(env, installationId, repoFullName, advisory, { + name: GITTENSORY_GATE_CHECK_NAME, + status: "completed", + conclusion: "neutral", + output: { + title: `Gittensory Gate — overridden by @${options.actor}`, + summary: "A maintainer set the Gate to neutral for THIS commit only. This does NOT permanently bypass the Gate; a new push re-evaluates it.", + text: `Overridden by @${options.actor}: ${options.reason}`, + }, + checkRunId: options.checkRunId, + }); +} + async function createOrUpdateNamedCheckRun( env: Env, installationId: number, diff --git a/src/github/commands.ts b/src/github/commands.ts index 8f5fe0061c..85fc6c2c25 100644 --- a/src/github/commands.ts +++ b/src/github/commands.ts @@ -50,10 +50,18 @@ export type GittensoryMentionCommandName = (typeof GITTENSORY_MENTION_COMMAND_CA export type MaintainerQueueDigestCommandName = (typeof MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG)[number]["id"]; type SnapshotCommandName = Exclude; +// Action commands are NOT Q&A: they perform a side effect (handled before the mention-command path) rather +// than producing a public answer card. They are intentionally kept OUT of the Q&A catalog/unions so the +// exhaustive Q&A switches stay total, but parseGittensoryMentionCommand still recognizes them (so a bare +// @gittensory gate-override is not silently downgraded to "help"). +export const GITTENSORY_ACTION_COMMANDS = ["gate-override"] as const; +export type GittensoryActionCommandName = (typeof GITTENSORY_ACTION_COMMANDS)[number]; + export type GittensoryMentionCommand = { - name: GittensoryMentionCommandName; + name: GittensoryMentionCommandName | GittensoryActionCommandName; raw: string; question?: string | undefined; + reason?: string | undefined; }; type PublicAnswerCard = { @@ -72,6 +80,7 @@ export type AgentCommandFeedbackContext = { }; const COMMANDS = new Set(GITTENSORY_MENTION_COMMAND_CATALOG.map((command) => command.id)); +const ACTION_COMMANDS = new Set(GITTENSORY_ACTION_COMMANDS); const MAINTAINER_QUEUE_DIGEST_COMMANDS = new Set(MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG.map((command) => command.id)); const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const AGENT_COMMAND_FEEDBACK_MARKER = "gittensory-agent-command-answer"; @@ -155,8 +164,12 @@ export function parseGittensoryMentionCommand(body: string | null | undefined): if (!body) return null; const match = body.match(/(?:^|\s)@gittensory(?:\s+([a-z-]+))?([^\n\r]*)/i); if (!match) return null; - const requested = (match[1]?.toLowerCase() || "help") as GittensoryMentionCommandName; - const name = COMMANDS.has(requested) ? requested : "help"; + const requested = (match[1]?.toLowerCase() || "help") as GittensoryMentionCommandName | GittensoryActionCommandName; + if (ACTION_COMMANDS.has(requested as GittensoryActionCommandName)) { + const reason = (match[2] ?? "").trim(); + return { name: requested as GittensoryActionCommandName, raw: match[0].trim(), reason: reason.length > 0 ? reason : undefined }; + } + const name = COMMANDS.has(requested as GittensoryMentionCommandName) ? (requested as GittensoryMentionCommandName) : "help"; const question = name === "ask" ? (match[2] ?? "").trim() : undefined; return { name, raw: match[0].trim(), question: question && question.length > 0 ? question : undefined }; } @@ -218,29 +231,32 @@ export function buildPublicAgentCommandComment(args: { maintainerDigest?: MaintainerQueueDigest | null | undefined; }): string { const repoFullName = args.repo?.fullName ?? args.pullRequest?.repoFullName ?? "this repository"; - const sections = commandSections(args.command.name, args.bundle, args.officialMiner, args.maintainerDigest, args.command.question); + // Action commands (e.g. gate-override) never reach this Q&A renderer — they are handled and short-circuited + // earlier — so narrow the widened parse name back to a Q&A command name for the answer-card helpers. + const commandName = args.command.name as GittensoryMentionCommandName; + const sections = commandSections(commandName, args.bundle, args.officialMiner, args.maintainerDigest, args.command.question); const card = buildPublicAnswerCard({ - command: args.command.name, + command: commandName, sections, bundle: args.bundle, officialMiner: args.officialMiner, actorKind: args.actorKind, - question: args.command.name === "ask" ? args.command.question : undefined, + question: commandName === "ask" ? args.command.question : undefined, }); const body = [ AGENT_COMMAND_COMMENT_MARKER, "", "> [!NOTE]", - `> **${COMMAND_TITLES[args.command.name]}**`, + `> **${COMMAND_TITLES[commandName]}**`, "> Gittensory updated this command response in place from cached public-safe context.", "", "| Signal | State |", "| --- | --- |", - `| Command | \`@gittensory ${args.command.name}\` |`, + `| Command | \`@gittensory ${commandName}\` |`, `| Scope | ${repoFullName}#${args.issue.number} |`, `| Actor | ${args.actorKind} |`, "", - `Command: \`@gittensory ${args.command.name}\``, + `Command: \`@gittensory ${commandName}\``, "", "
", "Command result", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 986048e36c..95999b7134 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -64,8 +64,8 @@ import { refreshInstallationHealth, } from "../github/backfill"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot, fetchOfficialGittensorMiner, type GittensorContributorSnapshot, type OfficialGittensorMinerDetection } from "../gittensor/api"; -import { createOrUpdateCheckRun, createOrUpdateErroredGateCheckRun, createOrUpdateGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getInstallationId, getRepositoryCollaboratorPermission } from "../github/app"; -import { createOrUpdateAgentCommandComment, createOrUpdatePrIntelligenceComment, PR_PANEL_COMMENT_MARKER } from "../github/comments"; +import { createOrUpdateCheckRun, createOrUpdateErroredGateCheckRun, createOrUpdateGateCheckRun, createOrUpdateOverriddenGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getInstallationId, getRepositoryCollaboratorPermission } from "../github/app"; +import { AGENT_COMMAND_COMMENT_MARKER, createOrUpdateAgentCommandComment, createOrUpdatePrIntelligenceComment, PR_PANEL_COMMENT_MARKER } from "../github/comments"; import { gittensoryFooter, gittensorRepoEarnUrl } from "../github/footer"; import { buildMaintainerQueueDigest, @@ -76,6 +76,7 @@ import { isMaintainerQueueDigestCommand, parseAgentCommandFeedbackContext, parseGittensoryMentionCommand, + sanitizePublicComment, } from "../github/commands"; import { ensurePullRequestLabel } from "../github/labels"; import { fetchPublicContributorProfile } from "../github/public"; @@ -729,6 +730,19 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str return; } + if (eventName === "issue_comment" && (await maybeProcessGateOverrideCommand(env, deliveryId, payload))) { + await recordWebhookEvent(env, { + deliveryId, + eventName, + action: payload.action, + installationId: payload.installation?.id, + repositoryFullName: payload.repository?.full_name, + payloadHash: "processed", + status: "processed", + }); + return; + } + if (eventName === "issue_comment" && (await maybeProcessGittensoryMentionCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, @@ -1429,6 +1443,130 @@ async function recordGithubProductUsage( }).catch(() => undefined); } +/** + * Handle `@gittensory gate-override ` on a PR thread. SECURITY-SENSITIVE: this finalizes the Gate + * check to neutral for the current commit, so authorization MUST come from real repo permission + * (resolveRealRepoPermissionAssociation → getRepositoryCollaboratorPermission), never the spoofable + * payload.comment.author_association. The override is intentionally NOT persisted: a follow-up push + * re-evaluates the Gate from scratch (no permanent bypass). + */ +async function maybeProcessGateOverrideCommand(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise { + const comment = payload.comment; + const command = parseGittensoryMentionCommand(comment?.body); + if (!command || command.name !== "gate-override") return false; + + const repoFullName = payload.repository?.full_name; + const issue = payload.issue; + const installationId = getInstallationId(payload); + const actor = comment?.user?.login ?? payload.sender?.login ?? null; + const targetKey = repoFullName && issue ? `${repoFullName}#${issue.number}` : repoFullName; + if (comment?.user?.type === "Bot" || payload.sender?.type === "Bot" || /\[bot\]$/i.test(actor ?? "")) { + await recordGateOverrideSkip(env, deliveryId, repoFullName, targetKey, actor, "bot_author"); + return true; + } + if (!repoFullName || !issue?.pull_request || !installationId || !actor) { + await recordGateOverrideSkip(env, deliveryId, repoFullName, targetKey, actor, "missing_repo_pr_installation_or_actor"); + return true; + } + const [pr, settings] = await Promise.all([getPullRequest(env, repoFullName, issue.number), resolveRepositorySettings(env, repoFullName)]); + if (!pr) { + await recordGateOverrideSkip(env, deliveryId, repoFullName, targetKey, actor, "cached_pr_missing"); + return true; + } + + const actorAssociation = await resolveRealRepoPermissionAssociation(env, installationId, repoFullName, actor); + const pullRequestAuthor = pr.authorLogin ?? issue.user?.login ?? null; + const authorization = isAuthorizedCommandActor({ + commandName: "gate-override" as GittensoryMentionCommandName, + commenterLogin: actor, + commenterAssociation: actorAssociation, + pullRequestAuthorLogin: pullRequestAuthor, + commandAuthorizationPolicy: settings.commandAuthorization, + }); + if (!authorization.authorized) { + await recordAuditEvent(env, { + eventType: "github_app.gate_override_denied", + actor, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "denied", + detail: authorization.reason, + metadata: { deliveryId, repoFullName, allowedRoles: commandAuthorizationAllowedRoles(settings.commandAuthorization, "gate-override") }, + }); + await recordGithubProductUsage(env, "gate_override_denied", { + actor, + repoFullName, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "denied", + metadata: { reason: authorization.reason, actorKind: authorization.actorKind, allowedRoles: commandAuthorizationAllowedRoles(settings.commandAuthorization, "gate-override") }, + }); + return true; + } + + const [repo, otherOpenPullRequests] = await Promise.all([getRepository(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number)]); + const advisory = buildPullRequestAdvisory(repo, pr, { + otherOpenPullRequests, + requireLinkedIssue: settings.requireLinkedIssue || settings.linkedIssueGateMode !== "off", + }); + const safeReason = sanitizePublicComment((command.reason ?? "").trim() || "No reason provided."); + await createOrUpdateOverriddenGateCheckRun(env, installationId, repoFullName, advisory, { actor, reason: safeReason }); + await recordAuditEvent(env, { + eventType: "github_app.gate_overridden", + actor, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + detail: safeReason, + metadata: { deliveryId, repoFullName, headSha: advisory.headSha ?? null }, + }); + const confirmation = sanitizePublicComment( + [ + AGENT_COMMAND_COMMENT_MARKER, + "", + "> [!NOTE]", + `> **Gittensory Gate overridden by @${actor}**`, + "> The Gate check was set to neutral for the current commit only. This does NOT permanently bypass the Gate; a new push re-evaluates it.", + "", + `- Reason: ${safeReason}`, + "", + "---", + gittensoryFooter(), + ].join("\n"), + ); + await createOrUpdateAgentCommandComment(env, installationId, repoFullName, issue.number, confirmation); + await recordGithubProductUsage(env, "gate_overridden", { + actor, + repoFullName, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + metadata: { actorKind: authorization.actorKind, headSha: advisory.headSha ?? null }, + }); + return true; +} + +async function recordGateOverrideSkip( + env: Env, + deliveryId: string, + repoFullName: string | null | undefined, + targetKey: string | null | undefined, + actor: string | null, + reason: string, +): Promise { + await recordAuditEvent(env, { + eventType: "github_app.gate_override_skipped", + actor, + targetKey, + outcome: "completed", + detail: reason, + metadata: { deliveryId, repoFullName: repoFullName ?? null, reason }, + }); + await recordGithubProductUsage(env, "gate_override_skipped", { + actor, + repoFullName, + targetKey, + outcome: "skipped", + metadata: { reason }, + }); +} + async function maybeProcessPrPanelRetrigger(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise { const comment = payload.comment; if (payload.action !== "edited" || !comment || !isCheckedPrPanelRetrigger(comment.body)) return false; @@ -1453,7 +1591,7 @@ async function maybeProcessPrPanelRetrigger(env: Env, deliveryId: string, payloa return true; } - const actorAssociation = await resolvePrPanelRetriggerActorAssociation(env, installationId, repoFullName, actor); + const actorAssociation = await resolveRealRepoPermissionAssociation(env, installationId, repoFullName, actor); const pullRequestAuthor = pr.authorLogin ?? issue.user?.login ?? null; const needsMinerDetection = commandAuthorizationNeedsMinerDetection({ policy: settings.commandAuthorization, @@ -1513,7 +1651,7 @@ async function maybeProcessPrPanelRetrigger(env: Env, deliveryId: string, payloa return true; } -async function resolvePrPanelRetriggerActorAssociation(env: Env, installationId: number, repoFullName: string, actor: string | null): Promise { +async function resolveRealRepoPermissionAssociation(env: Env, installationId: number, repoFullName: string, actor: string | null): Promise { if (!actor) return null; const permission = await getRepositoryCollaboratorPermission(env, installationId, repoFullName, actor).catch(() => null); if (permission === "admin" || permission === "maintain") return "MEMBER"; @@ -1566,6 +1704,9 @@ async function recordPrPanelRetriggerSkip( async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise { const command = parseGittensoryMentionCommand(payload.comment?.body); if (!command) return false; + // Action commands (e.g. gate-override) are handled by their own dispatch earlier in processGitHubWebhook; + // they never produce a Q&A answer card here. Bail so the rest of this handler narrows to Q&A commands. + if (command.name === "gate-override") return false; const repoFullName = payload.repository?.full_name; const issue = payload.issue; const installationId = getInstallationId(payload); diff --git a/src/settings/command-authorization.ts b/src/settings/command-authorization.ts index 75f7bffe79..65a3244485 100644 --- a/src/settings/command-authorization.ts +++ b/src/settings/command-authorization.ts @@ -12,6 +12,7 @@ export const DEFAULT_COMMAND_AUTHORIZATION_POLICY: RepositoryCommandAuthorizatio "intake-health": ["maintainer", "collaborator"], "outcome-patterns": ["maintainer", "collaborator"], "noise-report": ["maintainer", "collaborator"], + "gate-override": ["maintainer", "collaborator"], }, }; diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index a9cd14ee3d..5dbd745ffa 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -195,6 +195,7 @@ export type RepoSettingsPreview = { createMissingLabel: boolean; includeMaintainerAuthors: boolean; requireLinkedIssue: boolean; + badgeEnabled: boolean; commandAuthorization: { defaultAllowed: CommandAuthorizationRole[]; commandOverrides: Array<{ command: string; allowedRoles: CommandAuthorizationRole[] }>; @@ -310,6 +311,7 @@ export function buildRepoSettingsPreview(args: { createMissingLabel: settings.createMissingLabel, includeMaintainerAuthors: settings.includeMaintainerAuthors, requireLinkedIssue: settings.requireLinkedIssue, + badgeEnabled: settings.badgeEnabled ?? false, commandAuthorization: summarizeCommandAuthorizationPolicy(settings.commandAuthorization), }, commandAuthorizationPreview, diff --git a/test/unit/command-authorization.test.ts b/test/unit/command-authorization.test.ts index 03024def88..7fcafee635 100644 --- a/test/unit/command-authorization.test.ts +++ b/test/unit/command-authorization.test.ts @@ -33,6 +33,18 @@ describe("repo command authorization policy", () => { }); }); + it("gate-override is maintainer/collaborator only and ignores spoofable author_association", () => { + // The gateOverridePolicy ships maintainer+collaborator only (no pr_author / confirmed_miner). + expect(commandAuthorizationAllowedRoles(undefined, "gate-override")).toEqual(["maintainer", "collaborator"]); + // Real admin/maintain → MEMBER and real write → COLLABORATOR are the only associations that pass. + expect(evaluateCommandAuthorization({ commandName: "gate-override", commenterAssociation: "MEMBER" })).toMatchObject({ authorized: true, reason: "maintainer_invocation", actorKind: "maintainer" }); + expect(evaluateCommandAuthorization({ commandName: "gate-override", commenterAssociation: "COLLABORATOR" })).toMatchObject({ authorized: true, reason: "collaborator_invocation", actorKind: "maintainer" }); + // An org member WITHOUT real repo write resolves (in the handler) to a null association → denied here, + // even if the PR author tries it themselves. + expect(evaluateCommandAuthorization({ commandName: "gate-override", commenterAssociation: null })).toMatchObject({ authorized: false }); + expect(evaluateCommandAuthorization({ commandName: "gate-override", commenterLogin: "author", pullRequestAuthorLogin: "author", commenterAssociation: null })).toMatchObject({ authorized: false }); + }); + it("honors command overrides and avoids miner lookup when plain PR author is allowed", () => { const policy = normalizeCommandAuthorizationPolicy({ default: ["maintainer"], commands: { "next-action": ["pr_author"] } }).policy; expect( diff --git a/test/unit/github-commands.test.ts b/test/unit/github-commands.test.ts index 3675151103..e07cb56155 100644 --- a/test/unit/github-commands.test.ts +++ b/test/unit/github-commands.test.ts @@ -31,6 +31,13 @@ describe("GitHub mention commands", () => { expect(parseGittensoryMentionCommand("@gittensory needs-author")?.name).toBe("needs-author"); expect(parseGittensoryMentionCommand("@gittensory duplicate-clusters")?.name).toBe("duplicate-clusters"); expect(parseGittensoryMentionCommand("@gittensory unknown")?.name).toBe("help"); + // gate-override is an action command: it must be recognized (NOT downgraded to "help") and carry the + // trailing free text as its reason. + expect(parseGittensoryMentionCommand("@gittensory gate-override")).toMatchObject({ name: "gate-override", reason: undefined }); + expect(parseGittensoryMentionCommand("@gittensory gate-override known false positive, shipping")).toMatchObject({ + name: "gate-override", + reason: "known false positive, shipping", + }); expect(parseGittensoryMentionCommand("gittensory preflight")).toBeNull(); expect(isMaintainerOnlyCommand("queue-summary")).toBe(true); expect(isMaintainerOnlyCommand("preflight")).toBe(false); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 3682f89e66..5f69070ad3 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -3919,6 +3919,174 @@ describe("queue processors", () => { expect(slopOn?.findings_json).toContain("empty_issue_body"); // opted in → triage finding present expect(slopOff?.findings_json ?? "").not.toContain("empty_issue_body"); // default off → no slop finding }); + + it("overrides the Gate to neutral for THIS commit only when a real write/admin maintainer runs gate-override", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 90, + title: "Override me", + state: "open", + user: { login: "contributor" }, + author_association: "CONTRIBUTOR", + head: { sha: "override-sha" }, + labels: [], + body: "Validation: npm test", + }); + const calls = { token: 0, permission: 0, checkGets: 0, checkPatches: 0, commentGets: 0, commentPatches: 0 }; + const patchBodies: Array<{ status?: string; conclusion?: string; output?: { title?: string; text?: string } }> = []; + let confirmationBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) { + calls.token += 1; + return Response.json({ token: "installation-token" }); + } + // Authorization MUST come from the real collaborator-permission API, never the comment author_association. + if (url.includes("/collaborators/maintainer/permission")) { + calls.permission += 1; + return Response.json({ permission: "admin" }); + } + if (url.includes("/commits/override-sha/check-runs") && method === "GET") { + calls.checkGets += 1; + return Response.json({ total_count: 1, check_runs: [{ id: 555, name: "Gittensory Gate" }] }); + } + if (url.includes("/check-runs/555") && method === "PATCH") { + calls.checkPatches += 1; + patchBodies.push(JSON.parse(String(init?.body ?? "{}")) as { status?: string; conclusion?: string; output?: { title?: string; text?: string } }); + return Response.json({ id: 555 }); + } + if (url.includes("/issues/90/comments") && method === "GET") { + calls.commentGets += 1; + return Response.json([]); + } + if (url.includes("/issues/90/comments") && method === "POST") { + calls.commentPatches += 1; + confirmationBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); + return Response.json({ id: 9100 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-override-allow", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 90, title: "Override me", state: "open", user: { login: "contributor" }, pull_request: {} }, + // author_association lies (says OWNER); the handler must IGNORE it and use real permission instead. + comment: { id: 800, body: "@gittensory gate-override known flaky duplicate check, shipping", author_association: "NONE", user: { login: "maintainer", type: "User" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + + // The existing Gate run (id 555) was PATCHed to a neutral, non-blocking terminal state — not a new check. + expect(calls.checkPatches).toBe(1); + const finalize = patchBodies[0]; + expect(finalize?.status).toBe("completed"); + expect(finalize?.conclusion).toBe("neutral"); + expect(finalize?.output?.title).toBe("Gittensory Gate — overridden by @maintainer"); + expect(finalize?.output?.text).toContain("Overridden by @maintainer: known flaky duplicate check, shipping"); + expect(confirmationBody).toContain("Gittensory Gate overridden by @maintainer"); + const audit = await env.DB.prepare("select event_type, actor, target_key, outcome, detail from audit_events where event_type = ?") + .bind("github_app.gate_overridden") + .first<{ event_type: string; actor: string; target_key: string; outcome: string; detail: string }>(); + expect(audit).toMatchObject({ event_type: "github_app.gate_overridden", actor: "maintainer", target_key: "JSONbored/gittensory#90", outcome: "completed" }); + const usageEvents = await listProductUsageEvents(env, { limit: 10 }); + expect(usageEvents).toEqual(expect.arrayContaining([expect.objectContaining({ surface: "github_app", eventName: "gate_overridden", outcome: "completed" })])); + // No override state is persisted: the gate stays "enabled" and the override does NOT persist an advisory, + // so a follow-up synchronize re-evaluates the Gate from scratch (no permanent bypass). + const settingsAfter = await env.DB.prepare("select gate_check_mode from repository_settings where repo_full_name = ?").bind("JSONbored/gittensory").first<{ gate_check_mode: string }>(); + expect(settingsAfter?.gate_check_mode).toBe("enabled"); + const overrideAdvisory = await env.DB.prepare("select id from advisories where target_key = ?").bind("JSONbored/gittensory#90").first<{ id: string }>(); + expect(overrideAdvisory ?? null).toBeNull(); + }); + + it("denies gate-override from an org member without real repository write/admin (ignores author_association)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 91, + title: "Cannot override", + state: "open", + user: { login: "contributor" }, + author_association: "CONTRIBUTOR", + head: { sha: "override-denied" }, + labels: [], + body: "Validation: npm test", + }); + const calls = { token: 0, permission: 0, checkGets: 0, checkPatches: 0, comments: 0 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) { + calls.token += 1; + return Response.json({ token: "installation-token" }); + } + // Real permission is only "read" — even though the comment claims MEMBER, the Gate must NOT be touched. + if (url.includes("/collaborators/org-member/permission")) { + calls.permission += 1; + return Response.json({ permission: "read" }); + } + if (url.includes("/check-runs")) { + calls.checkGets += 1; + return new Response("not found", { status: 404 }); + } + if (url.includes("/comments")) { + calls.comments += 1; + return Response.json([]); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-override-deny", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 91, title: "Cannot override", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 801, body: "@gittensory gate-override trust me", author_association: "MEMBER", user: { login: "org-member", type: "User" } }, + sender: { login: "org-member", type: "User" }, + }, + }); + + // Authorization denied via real permission: no Gate check call and no comment were made. + expect(calls.permission).toBe(1); + expect(calls.checkGets).toBe(0); + expect(calls.checkPatches).toBe(0); + expect(calls.comments).toBe(0); + const denied = await env.DB.prepare("select event_type, actor, target_key, outcome, detail from audit_events where event_type = ?") + .bind("github_app.gate_override_denied") + .first<{ event_type: string; actor: string; target_key: string; outcome: string; detail: string }>(); + expect(denied).toMatchObject({ event_type: "github_app.gate_override_denied", actor: "org-member", target_key: "JSONbored/gittensory#91", outcome: "denied", detail: "not_maintainer_or_pr_author" }); + const overridden = await env.DB.prepare("select id from audit_events where event_type = ?").bind("github_app.gate_overridden").first<{ id: string }>(); + expect(overridden ?? null).toBeNull(); + }); }); function completeSegment(repoFullName: string, segment: "labels" | "open_issues" | "open_pull_requests") {