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
5 changes: 5 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean> {
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<void> {
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<boolean> {
const comment = payload.comment;
if (payload.action !== "edited" || !comment || !isCheckedPrPanelRetrigger(comment.body)) return false;
Expand Down
123 changes: 123 additions & 0 deletions src/review/planner.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
const ai = env.AI as unknown as { run?: (model: string, options: Record<string, unknown>, extra?: unknown) => Promise<unknown> } | 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<string | null> {
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"),
);
}
104 changes: 104 additions & 0 deletions test/unit/planner.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): 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`");
});
});
Loading
Loading