Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/github/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheckRunOutcome | null> {
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,
Expand Down
34 changes: 25 additions & 9 deletions src/github/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GittensoryMentionCommandName, "help" | "miner-context" | MaintainerQueueDigestCommandName>;

// 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 = {
Expand All @@ -72,6 +80,7 @@ export type AgentCommandFeedbackContext = {
};

const COMMANDS = new Set<GittensoryMentionCommandName>(GITTENSORY_MENTION_COMMAND_CATALOG.map((command) => command.id));
const ACTION_COMMANDS = new Set<GittensoryActionCommandName>(GITTENSORY_ACTION_COMMANDS);
const MAINTAINER_QUEUE_DIGEST_COMMANDS = new Set<MaintainerQueueDigestCommandName>(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";
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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}\``,
"",
"<details>",
"<summary>Command result</summary>",
Expand Down
149 changes: 145 additions & 4 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -76,6 +76,7 @@ import {
isMaintainerQueueDigestCommand,
parseAgentCommandFeedbackContext,
parseGittensoryMentionCommand,
sanitizePublicComment,
} from "../github/commands";
import { ensurePullRequestLabel } from "../github/labels";
import { fetchPublicContributorProfile } from "../github/public";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1429,6 +1443,130 @@ async function recordGithubProductUsage(
}).catch(() => undefined);
}

/**
* Handle `@gittensory gate-override <reason>` 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<boolean> {
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<void> {
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<boolean> {
const comment = payload.comment;
if (payload.action !== "edited" || !comment || !isCheckedPrPanelRetrigger(comment.body)) return false;
Expand All @@ -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,
Expand Down Expand Up @@ -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<string | null> {
async function resolveRealRepoPermissionAssociation(env: Env, installationId: number, repoFullName: string, actor: string | null): Promise<string | null> {
if (!actor) return null;
const permission = await getRepositoryCollaboratorPermission(env, installationId, repoFullName, actor).catch(() => null);
if (permission === "admin" || permission === "maintain") return "MEMBER";
Expand Down Expand Up @@ -1566,6 +1704,9 @@ async function recordPrPanelRetriggerSkip(
async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise<boolean> {
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);
Expand Down
1 change: 1 addition & 0 deletions src/settings/command-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
};

Expand Down
2 changes: 2 additions & 0 deletions src/signals/settings-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export type RepoSettingsPreview = {
createMissingLabel: boolean;
includeMaintainerAuthors: boolean;
requireLinkedIssue: boolean;
badgeEnabled: boolean;
commandAuthorization: {
defaultAllowed: CommandAuthorizationRole[];
commandOverrides: Array<{ command: string; allowedRoles: CommandAuthorizationRole[] }>;
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions test/unit/command-authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions test/unit/github-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading