From cfc86140752493bace402efc3d006365d2529f1c Mon Sep 17 00:00:00 2001 From: real-venus Date: Mon, 6 Jul 2026 23:41:20 -0700 Subject: [PATCH] feat(commands): add @gittensory configuration effective-config command (#2168) --- src/github/configuration-command.ts | 30 ++++++++ src/queue/processors.ts | 88 ++++++++++++++++++++++ src/settings/effective-config-summary.ts | 42 +++++++++++ test/unit/configuration-command.test.ts | 65 ++++++++++++++++ test/unit/effective-config-summary.test.ts | 60 +++++++++++++++ test/unit/queue.test.ts | 81 ++++++++++++++++++++ 6 files changed, 366 insertions(+) create mode 100644 src/github/configuration-command.ts create mode 100644 src/settings/effective-config-summary.ts create mode 100644 test/unit/configuration-command.test.ts create mode 100644 test/unit/effective-config-summary.test.ts diff --git a/src/github/configuration-command.ts b/src/github/configuration-command.ts new file mode 100644 index 0000000000..582d80a482 --- /dev/null +++ b/src/github/configuration-command.ts @@ -0,0 +1,30 @@ +import { parseGittensoryMentionCommand } from "./commands"; +import type { GitHubWebhookPayload } from "../types"; + +/** The validated request for a `@gittensory configuration` command, `null` when the comment is not that command, + * or a skip reason. PURE so every guard (wrong action, bot author, missing repo/issue/installation/actor) is + * exhaustively unit-tested without the webhook harness; the processor then carries a single `ok` branch. Unlike + * the issue-only planner, configuration is repo-level and answers on either a PR or an issue thread. (#2168) */ +export type ConfigurationCommandRequest = + | { ok: true; repoFullName: string; installationId: number; actor: string; issueNumber: number } + | { ok: false; reason: string; repoFullName: string | null; actor: string | null; targetKey: string | null }; + +export function classifyConfigurationCommandRequest( + payload: GitHubWebhookPayload, + installationId: number | null, +): ConfigurationCommandRequest | null { + const comment = payload.comment; + const command = parseGittensoryMentionCommand(comment?.body); + if (!command || command.name !== "configuration") return null; // not our command — fall through to other handlers + const repoFullName = payload.repository?.full_name ?? null; + const issue = payload.issue ?? null; + const actor = payload.sender?.login ?? comment?.user?.login ?? null; + const targetKey = repoFullName && issue ? `${repoFullName}#${issue.number}` : repoFullName; + if (payload.action !== "created" || comment?.user?.type === "Bot" || payload.sender?.type === "Bot" || /\[bot\]$/i.test(actor ?? "")) { + return { ok: false, reason: "unsupported_comment_action_or_bot", repoFullName, actor, targetKey }; + } + if (!repoFullName || !issue || !installationId || !actor) { + return { ok: false, reason: "missing_repo_issue_installation_or_actor", repoFullName, actor, targetKey }; + } + return { ok: true, repoFullName, installationId, actor, issueNumber: issue.number }; +} diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 305e1f5625..b421f04f8a 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -417,6 +417,8 @@ import { isPlanCommand, isPlannerEnabled, } from "../review/planner"; +import { classifyConfigurationCommandRequest } from "../github/configuration-command"; +import { summarizeEffectiveConfig } from "../settings/effective-config-summary"; import { buildReviewGroundingText, checkSummaryText as checkFailureSummaryText, @@ -5508,6 +5510,22 @@ async function processGitHubWebhook( } if (eventName === "issue_comment" && (await maybeProcessResolveCommand(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 maybeProcessConfigurationCommand(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 maybeProcessPlanCommand(env, deliveryId, payload)) @@ -10411,6 +10429,76 @@ async function maybeProcessResolveCommand(env: Env, deliveryId: string, payload: await createOrUpdateAgentCommandComment(env, req.installationId, req.repoFullName, req.pr.number, confirmation, mode); await recordAuditEvent(env, { eventType: "github_app.finding_resolved", actor: req.actor, targetKey, outcome: "completed", detail: `Marked ${resolvedLabel} as resolved.`, metadata: { deliveryId, repoFullName: req.repoFullName, scope: findingRef.scope, resolvedWarningCount: selection.findings.length, recordedSuppressionCount, ...(findingRef.scope === "single" ? { findingCode: findingRef.findingCode } : {}) } }); await recordGithubProductUsage(env, "finding_resolved", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "completed", metadata: { scope: findingRef.scope, resolvedWarningCount: selection.findings.length, recordedSuppressionCount, ...(findingRef.scope === "single" ? { findingCode: findingRef.findingCode } : {}) } }); return true; } +/** + * `@gittensory configuration` (#2168): post the EFFECTIVE resolved review config (yml>DB>defaults) as a + * public-safe comment so a maintainer can see what's actually in force without the dashboard. Read-only — it never + * mutates the PR, so unlike gate-override it always answers a maintainer's direct query (the displayed execution + * mode still reflects a pause). Honors the repo's per-repo `commandAuthorization` for `configuration` over the REAL + * repo permission (never the spoofable comment author_association). Returns true once it owns the event; a + * non-configuration comment returns false and falls through to the other command handlers. + */ +async function maybeProcessConfigurationCommand( + env: Env, + deliveryId: string, + payload: GitHubWebhookPayload, +): Promise { + const req = classifyConfigurationCommandRequest(payload, getInstallationId(payload)); + if (!req) return false; + if (!req.ok) { + await recordConfigurationSkip(env, deliveryId, req.repoFullName, req.targetKey, req.actor, req.reason); + return true; + } + const targetKey = `${req.repoFullName}#${req.issueNumber}`; + const settings = await resolveRepositorySettings(env, req.repoFullName); + const association = await resolveRealRepoPermissionAssociation(env, req.installationId, req.repoFullName, req.actor); + const authorization = evaluateCommandAuthorization({ + policy: settings.commandAuthorization, + commandName: "configuration", + commenterLogin: req.actor, + commenterAssociation: association, + }); + if (!authorization.authorized) { + await recordConfigurationSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, authorization.reason); + return true; + } + const mode = resolveAgentActionMode({ + globalPaused: isGlobalAgentPause(env), + agentPaused: settings.agentPaused, + agentDryRun: settings.agentDryRun, + }); + const body = sanitizePublicComment( + [AGENT_COMMAND_COMMENT_MARKER, "", summarizeEffectiveConfig(settings, mode), "", "---", gittensoryFooter()].join("\n"), + ); + await createIssueComment(env, req.installationId, req.repoFullName, req.issueNumber, body); + await recordAuditEvent(env, { + eventType: "github_app.configuration_posted", + actor: req.actor, + targetKey, + outcome: "completed", + detail: `Effective configuration posted for ${targetKey}.`, + metadata: { deliveryId, repoFullName: req.repoFullName, mode }, + }); + return true; +} + +async function recordConfigurationSkip( + env: Env, + deliveryId: string, + repoFullName: string | null, + targetKey: string | null, + actor: string | null, + reason: string, +): Promise { + await recordAuditEvent(env, { + eventType: "github_app.configuration_skipped", + actor, + targetKey, + outcome: "completed", + detail: reason, + metadata: { deliveryId, repoFullName, reason }, + }); +} + /** * `@gittensory plan` (#issue-coding-plan, flag-gated by GITTENSORY_REVIEW_PLANNER). On a MAINTAINER's comment on * an ISSUE (not a PR), generate a concise implementation plan from the issue text via Workers AI and post it as an diff --git a/src/settings/effective-config-summary.ts b/src/settings/effective-config-summary.ts new file mode 100644 index 0000000000..8a264edd68 --- /dev/null +++ b/src/settings/effective-config-summary.ts @@ -0,0 +1,42 @@ +import type { AgentActionMode } from "./agent-execution"; +import { AGENT_ACTION_CLASSES, resolveAutonomy } from "./autonomy"; +import { summarizeCommandAuthorizationPolicy } from "./command-authorization"; +import type { RepositorySettings } from "../types"; + +/** + * PURE, public-safe summary of a repo's EFFECTIVE review config (#2168) — the yml>DB>defaults result a maintainer + * would otherwise only see in the dashboard, surfaced on demand via `@gittensory configuration`. Renders ONLY + * non-sensitive operational config: the agent execution mode, per-action-class autonomy, the slop-gate threshold, + * the blacklist label, and the command-authorization overview (reusing {@link summarizeCommandAuthorizationPolicy}). + * + * Deliberately omits every secret / wallet / hotkey / coldkey / raw-trust-score / reward field (house rule) — none + * of the rendered fields derives from a private score, so the output is safe to post publicly. The handler still + * wraps it in `sanitizePublicComment` + `gittensoryFooter` as a second belt. `executionMode` is passed in resolved + * (the caller applies the global kill-switch via {@link resolveAgentActionMode}) so this stays pure. */ +export function summarizeEffectiveConfig(settings: RepositorySettings, executionMode: AgentActionMode): string { + const autonomyLines = AGENT_ACTION_CLASSES.map( + (actionClass) => ` - \`${actionClass}\`: ${resolveAutonomy(settings.autonomy, actionClass)}`, + ); + // The command-authorization policy always normalizes to a populated default + per-command overrides (the + // maintainer-only command defaults), so both lists are non-empty — no empty-case branch needed. + const authorization = summarizeCommandAuthorizationPolicy(settings.commandAuthorization); + const overrideLines = authorization.commandOverrides.map( + (override) => ` - \`${override.command}\`: ${override.allowedRoles.join(", ")}`, + ); + const slopGate = typeof settings.slopGateMinScore === "number" ? String(settings.slopGateMinScore) : "not set"; + // Config-as-code allows an explicit `null` to DISABLE the label; an absent value falls back to the "slop" default. + const blacklistLabel = settings.blacklistLabel === null ? "(disabled)" : (settings.blacklistLabel ?? "slop"); + return [ + "**Effective review configuration**", + "", + `- Agent execution mode: **${executionMode}**`, + "- Autonomy by action class:", + ...autonomyLines, + `- Slop-gate minimum score: ${slopGate}`, + `- Blacklist label: \`${blacklistLabel}\``, + "- Command authorization:", + ` - default roles: ${authorization.defaultAllowed.join(", ")}`, + " - overrides:", + ...overrideLines, + ].join("\n"); +} diff --git a/test/unit/configuration-command.test.ts b/test/unit/configuration-command.test.ts new file mode 100644 index 0000000000..52b9c1378a --- /dev/null +++ b/test/unit/configuration-command.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { classifyConfigurationCommandRequest } from "../../src/github/configuration-command"; +import type { GitHubWebhookPayload } from "../../src/types"; + +type PayloadParts = { + action?: string; + body?: string | null; + commentUser?: { login?: string; type?: string } | null; + sender?: { login?: string; type?: string } | null; + repository?: { full_name?: string } | null; + issue?: { number: number; pull_request?: unknown } | null; +}; + +function payload(parts: PayloadParts = {}): GitHubWebhookPayload { + return { + action: parts.action ?? "created", + repository: parts.repository === null ? undefined : (parts.repository ?? { full_name: "acme/widgets" }), + issue: parts.issue === null ? undefined : (parts.issue ?? { number: 7 }), + comment: { body: parts.body === undefined ? "@gittensory configuration" : parts.body, user: parts.commentUser === null ? undefined : (parts.commentUser ?? { login: "maintainer", type: "User" }) }, + sender: parts.sender === null ? undefined : (parts.sender ?? { login: "maintainer", type: "User" }), + } as unknown as GitHubWebhookPayload; +} + +describe("classifyConfigurationCommandRequest", () => { + it("returns null for a non-configuration comment (no mention, or a different verb)", () => { + expect(classifyConfigurationCommandRequest(payload({ body: "just a comment" }), 123)).toBeNull(); + expect(classifyConfigurationCommandRequest(payload({ body: "@gittensory plan" }), 123)).toBeNull(); + expect(classifyConfigurationCommandRequest(payload({ body: null }), 123)).toBeNull(); + }); + + it("returns ok:true with the resolved target for a valid maintainer configuration command", () => { + const req = classifyConfigurationCommandRequest(payload(), 123); + expect(req).toEqual({ ok: true, repoFullName: "acme/widgets", installationId: 123, actor: "maintainer", issueNumber: 7 }); + }); + + it("works on a PR thread too (repo-level command, not issue-only)", () => { + const req = classifyConfigurationCommandRequest(payload({ issue: { number: 9, pull_request: {} } }), 123); + expect(req).toEqual({ ok: true, repoFullName: "acme/widgets", installationId: 123, actor: "maintainer", issueNumber: 9 }); + }); + + it("skips a non-created action", () => { + expect(classifyConfigurationCommandRequest(payload({ action: "edited" }), 123)).toMatchObject({ ok: false, reason: "unsupported_comment_action_or_bot" }); + }); + + it("skips bot actors (comment user, sender, or a [bot] login suffix)", () => { + expect(classifyConfigurationCommandRequest(payload({ commentUser: { login: "x", type: "Bot" } }), 123)).toMatchObject({ ok: false, reason: "unsupported_comment_action_or_bot" }); + expect(classifyConfigurationCommandRequest(payload({ sender: { login: "x", type: "Bot" } }), 123)).toMatchObject({ ok: false, reason: "unsupported_comment_action_or_bot" }); + expect(classifyConfigurationCommandRequest(payload({ sender: { login: "renovate[bot]", type: "User" }, commentUser: { login: "renovate[bot]", type: "User" } }), 123)).toMatchObject({ ok: false, reason: "unsupported_comment_action_or_bot" }); + }); + + it("skips when repo, issue, installation, or actor is missing", () => { + expect(classifyConfigurationCommandRequest(payload({ repository: null }), 123)).toMatchObject({ ok: false, reason: "missing_repo_issue_installation_or_actor", repoFullName: null }); + expect(classifyConfigurationCommandRequest(payload({ issue: null }), 123)).toMatchObject({ ok: false, reason: "missing_repo_issue_installation_or_actor" }); + expect(classifyConfigurationCommandRequest(payload(), null)).toMatchObject({ ok: false, reason: "missing_repo_issue_installation_or_actor" }); + // actor resolves to null when neither sender.login nor comment.user.login is present (both still non-bot) + expect( + classifyConfigurationCommandRequest(payload({ sender: { type: "User" }, commentUser: { type: "User" } }), 123), + ).toMatchObject({ ok: false, reason: "missing_repo_issue_installation_or_actor", actor: null }); + }); + + it("falls back to the comment author when the sender login is absent", () => { + const req = classifyConfigurationCommandRequest(payload({ sender: { type: "User" }, commentUser: { login: "author", type: "User" } }), 123); + expect(req).toMatchObject({ ok: true, actor: "author" }); + }); +}); diff --git a/test/unit/effective-config-summary.test.ts b/test/unit/effective-config-summary.test.ts new file mode 100644 index 0000000000..172854288a --- /dev/null +++ b/test/unit/effective-config-summary.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { summarizeEffectiveConfig } from "../../src/settings/effective-config-summary"; +import type { RepositorySettings } from "../../src/types"; + +const base = (overrides: Partial = {}): RepositorySettings => + ({ + autonomy: { review: "auto", merge: "observe" }, + slopGateMinScore: 70, + blacklistLabel: "slop", + commandAuthorization: { default: ["maintainer", "collaborator"], commands: { configuration: ["maintainer"] } }, + ...overrides, + }) as RepositorySettings; + +describe("summarizeEffectiveConfig", () => { + it("renders execution mode, every action class's resolved autonomy, slop gate, blacklist label, and command auth", () => { + const out = summarizeEffectiveConfig(base(), "live"); + expect(out).toContain("Agent execution mode: **live**"); + // set class shows its level; unset classes resolve to the deny-by-default floor `observe` + expect(out).toContain("`review`: auto"); + expect(out).toContain("`merge`: observe"); + expect(out).toContain("`approve`: observe"); // unset → observe + expect(out).toContain("Slop-gate minimum score: 70"); + expect(out).toContain("Blacklist label: `slop`"); + expect(out).toContain("default roles: maintainer, collaborator"); + expect(out).toContain("`configuration`: maintainer"); + }); + + it("reflects the resolved execution mode verbatim", () => { + expect(summarizeEffectiveConfig(base(), "paused")).toContain("execution mode: **paused**"); + expect(summarizeEffectiveConfig(base(), "dry_run")).toContain("execution mode: **dry_run**"); + }); + + it("shows 'not set' when slopGateMinScore is absent or null", () => { + expect(summarizeEffectiveConfig(base({ slopGateMinScore: undefined }), "live")).toContain("Slop-gate minimum score: not set"); + expect(summarizeEffectiveConfig(base({ slopGateMinScore: null }), "live")).toContain("Slop-gate minimum score: not set"); + }); + + it("renders the blacklist label across configured / default / disabled cases", () => { + expect(summarizeEffectiveConfig(base({ blacklistLabel: "spam" }), "live")).toContain("Blacklist label: `spam`"); + expect(summarizeEffectiveConfig(base({ blacklistLabel: undefined }), "live")).toContain("Blacklist label: `slop`"); // default + expect(summarizeEffectiveConfig(base({ blacklistLabel: null }), "live")).toContain("Blacklist label: `(disabled)`"); + }); + + it("renders the normalized default command overrides even when the repo configures none", () => { + // An empty per-command config normalizes to the maintainer-only command defaults, so overrides are always listed. + const out = summarizeEffectiveConfig(base({ commandAuthorization: { default: ["maintainer"], commands: {} } }), "live"); + expect(out).toContain(" - overrides:\n"); + expect(out).toMatch(/ {2}- `[a-z-]+`: /); // at least one override line + }); + + it("never leaks a secret/reward/trust/wallet field (public-safe, #2168 house rule)", () => { + const out = summarizeEffectiveConfig( + base({ autonomy: { review: "auto", request_changes: "propose", approve: "auto_with_approval", merge: "auto", close: "suggest" } }), + "live", + ).toLowerCase(); + for (const banned of ["reward", "payout", "emission", "wallet", "hotkey", "coldkey", "privatekey", "trustscore", "rawtrust", "coldkeys", "secret"]) { + expect(out).not.toContain(banned); + } + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 4d29df6257..c10de566ee 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10337,6 +10337,87 @@ describe("queue processors", () => { expect(skip?.detail).toBe("no_plan_generated"); }); + it("configuration (#2168): a maintainer @gittensory configuration posts the effective resolved config", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupPlannerRepo(env); + let postedBody: string | undefined; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "admin" }); // maintainer + if (url.includes("/issues/77/comments")) { + postedBody = init?.body ? JSON.parse(init.body.toString()).body : undefined; + return Response.json({ id: 5 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory configuration", "maintainer1")); + expect(postedBody).toContain("Effective review configuration"); + expect(postedBody).toContain("Agent execution mode: **live**"); + expect(postedBody).toContain("Autonomy by action class:"); + // public-safe: never leaks a reward/trust/wallet field + expect(postedBody?.toLowerCase()).not.toMatch(/reward|wallet|hotkey|coldkey|trustscore/); + const audit = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.configuration_posted").first<{ outcome: string }>(); + expect(audit?.outcome).toBe("completed"); + }); + + it("configuration: a non-maintainer is denied — nothing is posted and a skip is recorded", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupPlannerRepo(env); + let posted = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "read" }); // not a maintainer + if (url.includes("/issues/77/comments")) { + posted = true; + return Response.json({ id: 5 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory configuration", "outsider")); + expect(posted).toBe(false); + const skip = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.configuration_skipped").first<{ detail: string }>(); + expect(skip?.detail).toBe("not_maintainer_or_pr_author"); + }); + + it("configuration: a non-configuration comment is not intercepted (the handler declines, no config audit)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupPlannerRepo(env); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await processJob(env, plannerWebhook("just a normal comment, no mention", "maintainer1")); + const posted = await env.DB.prepare("select 1 from audit_events where event_type = ?").bind("github_app.configuration_posted").first(); + const skipped = await env.DB.prepare("select 1 from audit_events where event_type = ?").bind("github_app.configuration_skipped").first(); + expect(posted).toBeFalsy(); + expect(skipped).toBeFalsy(); + }); + + it("configuration: a bot-authored command is recorded as a classifier skip, never posted", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupPlannerRepo(env); + let posted = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/comments")) posted = true; + return new Response("not found", { status: 404 }); + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "config-bot", + 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: 77, title: "t", state: "open", user: { login: "reporter" }, body: "b" }, + comment: { body: "@gittensory configuration", user: { login: "some-bot[bot]", type: "Bot" } }, + sender: { login: "some-bot[bot]", type: "Bot" }, + }, + } as unknown as Parameters[1]); + expect(posted).toBe(false); + const skip = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.configuration_skipped").first<{ detail: string }>(); + expect(skip?.detail).toBe("unsupported_comment_action_or_bot"); + }); + it("REGRESSION (#audit-draft-maintenance): a clean DRAFT PR is never auto-merged/approved/closed (drafts are WIP)", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertInstallation(env, {