diff --git a/src/env.d.ts b/src/env.d.ts index 7ca3d22563..86e75119d8 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -163,6 +163,11 @@ declare global { * recording are wired, reading a promoted override into the live gate is a noted follow-up that must not * risk loosening the gate. See src/review/selftune-wire.ts. */ GITTENSORY_REVIEW_SELFTUNE?: string; + /** Convergence (#issue-coding-plan): the `@gittensory plan` command. Default OFF — `@gittensory plan` falls + * through to the existing mention path, so the worker is byte-identical to today. When truthy, a MAINTAINER + * comment of `@gittensory plan` on an issue generates an implementation plan from the issue text via Workers + * AI and posts it as an issue comment. See src/review/planner.ts. */ + GITTENSORY_REVIEW_PLANNER?: string; /** Proof of Power (#1059): when truthy, the unauthenticated `GET /v1/public/stats` endpoint serves the public * homepage counter — computed LIVE from gittensory's OWN review ledger (review_targets + review_audit) behind * a 60s cache, so it stays current as new reviews land. Default OFF — unset/false 404s the endpoint, so the diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 834a476125..bf447ac253 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -180,6 +180,7 @@ import type { LocalBranchAnalysisInput } from "../signals/local-branch"; import { runGittensoryAiReview } from "../services/ai-review"; import { evaluatePreMergeChecks } from "../review/pre-merge-checks"; import { secretLeakFinding } from "../review/safety"; +import { buildIssuePlanComment, classifyPlanCommandRequest, generateIssuePlan, isPlanCommand, isPlannerEnabled } from "../review/planner"; import { aiCiRefutationActive, buildReviewGroundingText, checkSummaryText as checkFailureSummaryText, isGroundingEnabled } from "../review/grounding-wire"; import { buildReviewRagContext, isRagEnabled } from "../review/rag-wire"; import { evaluateWithSurfaceLane } from "../review/content-lane-wire"; @@ -1608,6 +1609,19 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str return; } + if (eventName === "issue_comment" && (await maybeProcessPlanCommand(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, @@ -3146,6 +3160,61 @@ async function recordGateOverrideSkip( }); } +/** + * `@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 + * issue comment so a contributor has a concrete starting point. Flag-OFF (default) returns false immediately + * (BEFORE any parse), so `@gittensory plan` falls through to the existing mention path → byte-identical. Returns + * true once it owns the event (so the caller records it processed and stops). Fail-safe: a model/post error is + * recorded as a skip and never throws into the webhook loop. + */ +async function maybeProcessPlanCommand(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise { + if (!isPlannerEnabled(env)) return false; // flag-OFF → not handled here; the worker is byte-identical to today + if (!isPlanCommand(payload.comment?.body)) return false; + // All eligibility guards live in the PURE classifier (exhaustively unit-tested); here we carry one ok branch. + const req = classifyPlanCommandRequest(payload, getInstallationId(payload)); + if (!req.ok) { + await recordPlanSkip(env, deliveryId, req.repoFullName, req.targetKey, req.actor, req.reason); + return true; + } + const targetKey = `${req.repoFullName}#${req.issue.number}`; + // Issue-level authorization: planning spends Workers AI + posts publicly, so restrict it to maintainers + // (the REAL repo permission, not the comment's spoofable author_association). + const association = await resolveRealRepoPermissionAssociation(env, req.installationId, req.repoFullName, req.actor); + if (!isMaintainerAssociation(association)) { + await recordPlanSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "actor_not_maintainer"); + return true; + } + const plan = await generateIssuePlan(env, { title: req.issue.title, body: req.issue.body }); + if (!plan) { + await recordPlanSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "no_plan_generated"); + return true; + } + await createIssueComment(env, req.installationId, req.repoFullName, req.issue.number, buildIssuePlanComment(plan, { actor: req.actor, repoFullName: req.repoFullName, issueNumber: req.issue.number })); + await recordAuditEvent(env, { + eventType: "github_app.issue_plan_generated", + actor: req.actor, + targetKey, + outcome: "completed", + detail: `Implementation plan posted for ${targetKey}.`, + metadata: { deliveryId, repoFullName: req.repoFullName }, + }); + await recordGithubProductUsage(env, "issue_plan_generated", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "completed", metadata: {} }); + return true; +} + +async function recordPlanSkip(env: Env, deliveryId: string, repoFullName: string | null, targetKey: string | null, actor: string | null, reason: string): Promise { + await recordAuditEvent(env, { + eventType: "github_app.issue_plan_skipped", + actor, + targetKey, + outcome: "completed", + detail: reason, + metadata: { deliveryId, repoFullName, reason }, + }); + await recordGithubProductUsage(env, "issue_plan_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; diff --git a/src/review/planner.ts b/src/review/planner.ts new file mode 100644 index 0000000000..bdfa0426f8 --- /dev/null +++ b/src/review/planner.ts @@ -0,0 +1,123 @@ +// Convergence (#issue-coding-plan) — the `@gittensory plan` command: on a maintainer's request, generate a +// concise, actionable implementation plan from an ISSUE's text and post it as an issue comment so a contributor +// (or their agent) has a concrete starting point. +// +// SAFETY CONTRACT: +// • flag-OFF (default) → isPlannerEnabled is false, the handler short-circuits BEFORE parsing, and the worker +// is byte-identical to today (`@gittensory plan` falls through to the existing mention path → help card). +// • flag-ON → only a MAINTAINER can trigger it; the model sees only the (already-public) issue title + body; +// the output is public-safe-sanitized before posting; any model/error degrades to a no-plan no-op. + +import { BEST_REVIEW_MODELS, coerceAiText, RELIABLE_FALLBACK_MODELS } from "../services/ai-review"; +import { sanitizePublicComment } from "../github/commands"; +import { AGENT_COMMAND_COMMENT_MARKER } from "../github/comments"; +import { gittensoryFooter } from "../github/footer"; +import type { GitHubWebhookPayload } from "../types"; + +/** True when the issue-planning command is enabled. Flag-OFF (default) → every export below is unreachable from + * the webhook path. Truthy follows the codebase convention (`/^(1|true|yes|on)$/i`, same as isSelfTuneEnabled). */ +export function isPlannerEnabled(env: { GITTENSORY_REVIEW_PLANNER?: string | undefined }): boolean { + return /^(1|true|yes|on)$/i.test(env.GITTENSORY_REVIEW_PLANNER ?? ""); +} + +/** Recognize a bare `@gittensory plan` mention (the rest of the line is ignored). Returns false for any other + * body so the handler never intercepts an unrelated comment. PURE. */ +export function isPlanCommand(body: string | null | undefined): boolean { + if (!body) return false; + return /(?:^|\s)@gittensory\s+plan\b/i.test(body); +} + +/** The validated request for a `@gittensory plan` command, or a skip reason. PURE so every guard (wrong action, + * bot author, missing repo/issue/installation, a PR rather than an issue) is exhaustively unit-tested without the + * webhook harness; the processor then carries a single `ok` branch. (#issue-coding-plan) */ +export type PlanCommandRequest = + | { ok: true; repoFullName: string; installationId: number; actor: string; issue: { number: number; title?: string | null | undefined; body?: string | null | undefined } } + | { ok: false; reason: string; repoFullName: string | null; actor: string | null; targetKey: string | null }; + +export function classifyPlanCommandRequest(payload: GitHubWebhookPayload, installationId: number | null): PlanCommandRequest { + const comment = payload.comment; + 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 || issue.pull_request || !installationId || !actor) { + return { ok: false, reason: "missing_repo_issue_installation_or_actor", repoFullName, actor, targetKey }; + } + return { ok: true, repoFullName, installationId, actor, issue: { number: issue.number, title: issue.title, body: issue.body } }; +} + +const PLANNER_SYSTEM_PROMPT = [ + "You are a senior open-source maintainer assistant. Given a single GitHub issue, produce a CONCISE, actionable", + "implementation plan a contributor can follow. Output GitHub-flavored markdown with these sections, in order:", + "a one-line **Summary**; **Proposed approach** (2-4 bullets); **Steps** (an ordered checklist of concrete edits);", + "**Files likely involved** (best-effort from the description, may be empty); **Tests to add**; and", + "**Risks / open questions**. Be specific and practical; prefer the smallest correct change. Never invent file", + "paths you are not reasonably confident about. Do NOT include secrets, credentials, tokens, or any private data.", + "If the issue is too vague to plan, say so plainly and list the clarifying questions a maintainer should answer.", +].join(" "); + +// Bound the issue text fed to the model so a giant issue body can't blow the prompt, and bound the plan we post. +const MAX_ISSUE_CHARS = 6_000; +const MAX_PLAN_CHARS = 8_000; +const PLANNER_MAX_TOKENS = 1_200; + +/** One Workers-AI text completion for the planner: primary model, one reliable fallback, a single retry each. + * Fail-safe — any error or empty output returns null. Mirrors runWorkersOpinion's routing (AI Gateway when set). */ +async function runPlannerModel(env: Env, system: string, user: string): Promise { + const ai = env.AI as unknown as { run?: (model: string, options: Record, extra?: unknown) => Promise } | undefined; + if (!ai || typeof ai.run !== "function") return null; + const gatewayId = env.AI_GATEWAY_ID?.trim(); + const extra = gatewayId ? { gateway: { id: gatewayId } } : undefined; + for (const model of [BEST_REVIEW_MODELS[0], RELIABLE_FALLBACK_MODELS[0]]) { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const result = await ai.run(model, { max_tokens: PLANNER_MAX_TOKENS, temperature: 0.2, messages: [{ role: "system", content: system }, { role: "user", content: user }] }, extra); + const text = coerceAiText(result).trim(); + if (text) return text; + } catch { + /* retry, then fall through to the fallback model */ + } + } + } + return null; +} + +/** Generate an implementation plan (markdown) from an issue's title + body via Workers AI. Returns null when AI + * is unavailable or returns nothing (the caller then posts no plan). The returned text is bounded; the caller + * still sanitizes it before posting. */ +export async function generateIssuePlan(env: Env, issue: { title?: string | null | undefined; body?: string | null | undefined }): Promise { + const title = (issue.title ?? "").trim(); + const body = (issue.body ?? "").trim().slice(0, MAX_ISSUE_CHARS); + if (!title && !body) return null; // nothing to plan from + const user = `Issue title: ${title || "(none)"}\n\nIssue description:\n${body || "(no description provided)"}`; + const plan = await runPlannerModel(env, PLANNER_SYSTEM_PROMPT, user); + if (!plan) return null; + return plan.slice(0, MAX_PLAN_CHARS); +} + +/** Render the generated plan into a public-safe issue comment. Sanitized at the boundary so the posted body can + * never carry private terms even if the model emitted them. */ +export function buildIssuePlanComment(plan: string, args: { actor: string; repoFullName: string; issueNumber: number }): string { + return sanitizePublicComment( + [ + AGENT_COMMAND_COMMENT_MARKER, + "", + "> [!NOTE]", + `> **Gittensory implementation plan** — requested by @${args.actor}`, + "> AI-generated from the issue text. Treat it as a starting point and verify against the codebase before implementing.", + "", + "| Signal | State |", + "| --- | --- |", + "| Command | `@gittensory plan` |", + `| Scope | ${args.repoFullName}#${args.issueNumber} |`, + "", + plan, + "", + "---", + gittensoryFooter(), + ].join("\n"), + ); +} diff --git a/test/unit/planner.test.ts b/test/unit/planner.test.ts new file mode 100644 index 0000000000..60191ec04b --- /dev/null +++ b/test/unit/planner.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; + +import { buildIssuePlanComment, classifyPlanCommandRequest, generateIssuePlan, isPlanCommand, isPlannerEnabled } from "../../src/review/planner"; +import type { GitHubWebhookPayload } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +describe("isPlannerEnabled (#issue-coding-plan)", () => { + it("is OFF for unset/falsey flags and ON for truthy ones", () => { + for (const off of [undefined, "", "false", "no", "0", "off"]) expect(isPlannerEnabled({ GITTENSORY_REVIEW_PLANNER: off })).toBe(false); + for (const on of ["1", "true", "yes", "on", "TRUE", "On"]) expect(isPlannerEnabled({ GITTENSORY_REVIEW_PLANNER: on })).toBe(true); + }); +}); + +describe("isPlanCommand (#issue-coding-plan)", () => { + it("matches a bare @gittensory plan mention (case-insensitive, anywhere)", () => { + expect(isPlanCommand("@gittensory plan")).toBe(true); + expect(isPlanCommand("Hey @gittensory plan this please")).toBe(true); + expect(isPlanCommand("@GitTensory plan")).toBe(true); + }); + it("does not match other commands or non-mentions", () => { + expect(isPlanCommand("@gittensory help")).toBe(false); + expect(isPlanCommand("@gittensoryplan")).toBe(false); // no handle boundary + expect(isPlanCommand("plan the work")).toBe(false); + expect(isPlanCommand(null)).toBe(false); + expect(isPlanCommand(undefined)).toBe(false); + }); +}); + +describe("generateIssuePlan (#issue-coding-plan)", () => { + it("returns the model's plan text when Workers AI responds", async () => { + const run = vi.fn(async () => ({ response: "## Summary\nDo the thing.\n\n## Steps\n1. Edit foo.ts" })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + const plan = await generateIssuePlan(env, { title: "Add a flag", body: "We need a config flag." }); + expect(plan).toContain("## Summary"); + expect(run).toHaveBeenCalledTimes(1); + // The issue text is passed to the model as the user message. + const opts = (run.mock.calls[0] as unknown as [string, { messages?: Array<{ role: string; content: string }> }])[1]; + const userMessage = opts?.messages?.find((m) => m.role === "user")?.content ?? ""; + expect(userMessage).toContain("Add a flag"); + expect(userMessage).toContain("We need a config flag."); + }); + + it("returns null when there is no issue text to plan from (no AI call)", async () => { + const run = vi.fn(async () => ({ response: "x" })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + expect(await generateIssuePlan(env, { title: "", body: "" })).toBeNull(); + expect(await generateIssuePlan(env, { title: null, body: null })).toBeNull(); + expect(run).not.toHaveBeenCalled(); + }); + + it("returns null when Workers AI is unavailable or returns nothing (fail-safe)", async () => { + expect(await generateIssuePlan(createTestEnv({ AI: undefined as unknown as Ai }), { title: "T", body: "B" })).toBeNull(); + const emptyRun = vi.fn(async () => ({ response: " " })); + expect(await generateIssuePlan(createTestEnv({ AI: { run: emptyRun } as unknown as Ai }), { title: "T", body: "B" })).toBeNull(); + // throwing on every attempt also degrades to null + const throwRun = vi.fn(async () => { + throw new Error("ai down"); + }); + expect(await generateIssuePlan(createTestEnv({ AI: { run: throwRun } as unknown as Ai }), { title: "T", body: "B" })).toBeNull(); + }); +}); + +describe("classifyPlanCommandRequest (#issue-coding-plan)", () => { + const base = (over: Record = {}): GitHubWebhookPayload => + ({ + action: "created", + repository: { full_name: "acme/widgets" }, + issue: { number: 9, title: "T", state: "open", body: "B" }, + comment: { id: 1, body: "@gittensory plan", user: { login: "maint", type: "User" } }, + sender: { login: "maint", type: "User" }, + ...over, + }) as unknown as GitHubWebhookPayload; + + it("returns ok with the validated fields for a maintainer comment on a real issue", () => { + const req = classifyPlanCommandRequest(base(), 123); + expect(req).toEqual({ ok: true, repoFullName: "acme/widgets", installationId: 123, actor: "maint", issue: { number: 9, title: "T", body: "B" } }); + }); + + it("skips a non-created action or a bot author", () => { + expect(classifyPlanCommandRequest(base({ action: "edited" }), 123)).toMatchObject({ ok: false, reason: "unsupported_comment_action_or_bot", targetKey: "acme/widgets#9" }); + expect(classifyPlanCommandRequest(base({ comment: { id: 1, body: "@gittensory plan", user: { login: "bot", type: "Bot" } } }), 123)).toMatchObject({ ok: false, reason: "unsupported_comment_action_or_bot" }); + expect(classifyPlanCommandRequest(base({ sender: { login: "x", type: "Bot" } }), 123)).toMatchObject({ ok: false, reason: "unsupported_comment_action_or_bot" }); + expect(classifyPlanCommandRequest(base({ sender: { login: "renovate[bot]", type: "User" } }), 123)).toMatchObject({ ok: false, reason: "unsupported_comment_action_or_bot" }); + }); + + it("skips when the repo, issue, installation, or actor is missing, or the comment is on a PR", () => { + expect(classifyPlanCommandRequest(base({ repository: undefined }), 123)).toMatchObject({ ok: false, reason: "missing_repo_issue_installation_or_actor", repoFullName: null, targetKey: null }); + expect(classifyPlanCommandRequest(base({ issue: undefined }), 123)).toMatchObject({ ok: false, reason: "missing_repo_issue_installation_or_actor", targetKey: "acme/widgets" }); + expect(classifyPlanCommandRequest(base({ issue: { number: 9, title: "T", state: "open", pull_request: {} } }), 123)).toMatchObject({ ok: false, reason: "missing_repo_issue_installation_or_actor" }); + expect(classifyPlanCommandRequest(base(), null)).toMatchObject({ ok: false, reason: "missing_repo_issue_installation_or_actor" }); + expect(classifyPlanCommandRequest(base({ sender: undefined, comment: { id: 1, body: "@gittensory plan", user: undefined } }), 123)).toMatchObject({ ok: false, reason: "missing_repo_issue_installation_or_actor", actor: null }); + }); +}); + +describe("buildIssuePlanComment (#issue-coding-plan)", () => { + it("renders the plan with the marker, actor, scope, and footer", () => { + const body = buildIssuePlanComment("## Summary\nShip it.", { actor: "maintainer1", repoFullName: "acme/widgets", issueNumber: 42 }); + expect(body).toContain("Gittensory implementation plan"); + expect(body).toContain("@maintainer1"); + expect(body).toContain("acme/widgets#42"); + expect(body).toContain("Ship it."); + expect(body).toContain("`@gittensory plan`"); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 8cc1db2ed4..dd02d567db 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -1447,6 +1447,131 @@ describe("queue processors", () => { expect(gateText).toContain("Pre-merge check not satisfied: Approved label required"); }); + async function setupPlannerRepo(env: Env): Promise { + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + } + + function plannerWebhook(commentBody: string, sender: string, issueOverride?: Record): Parameters[1] { + return { + type: "github-webhook", + deliveryId: `plan-${sender}-${commentBody.length}-${issueOverride ? "pr" : "issue"}`, + 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: issueOverride ?? { number: 77, title: "Add a retry to the fetch helper", state: "open", user: { login: "reporter" }, body: "The fetch helper should retry on 5xx." }, + comment: { body: commentBody, user: { login: sender, type: "User" } }, + sender: { login: sender, type: "User" }, + }, + } as unknown as Parameters[1]; + } + + it("planner (#issue-coding-plan): a maintainer @gittensory plan on an issue posts an AI plan (flag ON)", async () => { + const run = vi.fn(async () => ({ response: "## Summary\nAdd retry-on-5xx to the fetch helper.\n\n## Steps\n1. Wrap the fetch in a retry loop." })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_PLANNER: "true", AI: { run } as unknown as Ai }); + 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 plan", "maintainer1")); + expect(run).toHaveBeenCalledTimes(1); + expect(postedBody).toContain("Gittensory implementation plan"); + expect(postedBody).toContain("Add retry-on-5xx"); + const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.issue_plan_generated").first<{ n: number }>(); + expect(audit?.n).toBe(1); + }); + + it("planner: flag OFF is byte-identical — @gittensory plan posts no plan and the AI is never called", async () => { + const run = vi.fn(async () => ({ response: "should not run" })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_PLANNER: "false", AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + let postedPlan = false; + 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" }); + if (url.includes("/issues/77/comments")) { + if (init?.body && JSON.parse(init.body.toString()).body?.includes("implementation plan")) postedPlan = true; + return Response.json({ id: 5 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory plan", "maintainer1")); + expect(run).not.toHaveBeenCalled(); + expect(postedPlan).toBe(false); + }); + + it("planner: a NON-maintainer is denied — no plan is generated or posted (flag ON)", async () => { + const run = vi.fn(async () => ({ response: "should not run" })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_PLANNER: "true", AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + 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 + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory plan", "outsider")); + expect(run).not.toHaveBeenCalled(); + const denied = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.issue_plan_skipped").first<{ detail: string }>(); + expect(denied?.detail).toBe("actor_not_maintainer"); + }); + + it("planner: a flag-ON non-plan comment is not intercepted (the handler declines)", async () => { + const run = vi.fn(async () => ({ response: "nope" })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_PLANNER: "true", AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await processJob(env, plannerWebhook("just a normal comment with no command", "maintainer1")); + expect(run).not.toHaveBeenCalled(); // not a plan command → maybeProcessPlanCommand returns false, no AI spend + }); + + it("planner: @gittensory plan on a PR (not an issue) is skipped via the classifier", async () => { + const run = vi.fn(async () => ({ response: "nope" })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_PLANNER: "true", AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await processJob(env, plannerWebhook("@gittensory plan", "maintainer1", { number: 77, title: "PR not issue", state: "open", user: { login: "x" }, body: "b", pull_request: { url: "https://api.github.com/x" } })); + expect(run).not.toHaveBeenCalled(); + const skip = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.issue_plan_skipped").first<{ detail: string }>(); + expect(skip?.detail).toBe("missing_repo_issue_installation_or_actor"); + }); + + it("planner: a maintainer request that yields no plan is recorded as a skip (fail-safe)", async () => { + const run = vi.fn(async () => ({ response: " " })); // model returns nothing usable + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_PLANNER: "true", AI: { run } as unknown as Ai }); + 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: "write" }); // 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 plan", "maintainer1")); + expect(posted).toBe(false); // no plan → nothing posted + const skip = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.issue_plan_skipped").first<{ detail: string }>(); + expect(skip?.detail).toBe("no_plan_generated"); + }); + 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, { diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 412a1741cf..4b8d98abf2 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 3ebf2c74f0fe5d19fd93a60b6f1b7380) +// Generated by Wrangler by running `wrangler types` (hash: 7a9bc145afd262bca0a9a05a8bb43168) // Runtime types generated with workerd@1.20260617.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { REVIEW_CONFIG: KVNamespace; @@ -39,6 +39,7 @@ interface __BaseEnv_Env { GITTENSORY_REVIEW_RAG: "true"; GITTENSORY_REVIEW_CONTENT_LANE: "false"; GITTENSORY_REVIEW_SELFTUNE: "false"; + GITTENSORY_REVIEW_PLANNER: "false"; GITTENSORY_REVIEW_DRAFT: "false"; GITTENSORY_REVIEW_PARITY_AUDIT: "false"; GITTENSORY_REVIEW_REPOS: "JSONbored/gittensory,JSONbored/awesome-claude,JSONbored/metagraphed"; @@ -58,7 +59,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index f419feb3d5..cc65b7953b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -99,6 +99,10 @@ // identical to today. Config-application (reading a promoted override into the live gate) is a deferred // follow-up — see src/review/selftune-wire.ts. "GITTENSORY_REVIEW_SELFTUNE": "false", + // Convergence (#issue-coding-plan): the `@gittensory plan` command. Default OFF — `@gittensory plan` falls + // through to the existing mention path (byte-identical). ON → a MAINTAINER comment of `@gittensory plan` on + // an issue generates an implementation plan from the issue text via Workers AI and posts it as a comment. + "GITTENSORY_REVIEW_PLANNER": "false", // Convergence (port): public OAuth draft-submission flow ported from reviewbot. Default OFF — every // /v1/drafts endpoint 404s and no draft behavior runs. Turning it on also needs the // DRAFT_TOKEN_ENCRYPTION_SECRET worker secret + GITHUB_OAUTH_CLIENT_SECRET to be set.