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
27 changes: 25 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ const OFFICIAL_MINER_DETECTION_TTL_MS = 5 * 60 * 1000;
const OFFICIAL_MINER_DETECTION_UNAVAILABLE_TTL_MS = 60 * 1000;
const PR_PUBLIC_SURFACE_ACTIONS = new Set(["opened", "reopened", "synchronize", "ready_for_review", "edited"]);
const PR_GATE_CLOSED_ACTIONS = new Set(["closed"]);
const ISSUE_PLAN_COOLDOWN_MS = 10 * 60 * 1000;

/**
* Run (or dry-run) the data-retention prune across the configured log/snapshot tables and audit the
Expand Down Expand Up @@ -3189,7 +3190,8 @@ async function recordGateOverrideSkip(
* 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.
* recorded as a skip and never throws into the webhook loop. A per-actor/per-repo cooldown prevents repeated
* maintainer comments from spending shared AI quota in a burst.
*/
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
Expand All @@ -3208,7 +3210,11 @@ async function maybeProcessPlanCommand(env: Env, deliveryId: string, payload: Gi
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 (await isPlanCommandCoolingDown(env, req.repoFullName, req.actor, ISSUE_PLAN_COOLDOWN_MS)) {
await recordPlanSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "cooldown_active");
return true;
}
const plan = await generateIssuePlan(env, { title: req.issue.title, body: req.issue.body }, { actor: req.actor, repoFullName: req.repoFullName, issueNumber: req.issue.number });
if (!plan) {
await recordPlanSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "no_plan_generated");
return true;
Expand All @@ -3226,6 +3232,23 @@ async function maybeProcessPlanCommand(env: Env, deliveryId: string, payload: Gi
return true;
}

async function isPlanCommandCoolingDown(env: Env, repoFullName: string, actor: string, cooldownMs: number): Promise<boolean> {
const since = new Date(Date.now() - cooldownMs).toISOString();
const row = await env.DB.prepare(
`select 1 as active
from audit_events
where event_type in ('github_app.issue_plan_generated', 'github_app.issue_plan_skipped')
and actor = ?
and json_extract(metadata_json, '$.repoFullName') = ?
and created_at >= ?
and (event_type = 'github_app.issue_plan_generated' or coalesce(detail, '') in ('no_plan_generated', 'cooldown_active'))
limit 1`,
)
.bind(actor, repoFullName, since)
.first<{ active: number }>();
return Boolean(row);
}

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",
Expand Down
38 changes: 35 additions & 3 deletions src/review/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
// • 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.
// shared AI budget accounting runs before Workers AI; 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 { BEST_REVIEW_MODELS, clampNumber, coerceAiText, estimateNeurons, RELIABLE_FALLBACK_MODELS, utcDayStartIso } from "../services/ai-review";
import { recordAiUsageEvent, sumAiEstimatedNeuronsSince } from "../db/repositories";
import { sanitizePublicComment } from "../github/commands";
import { AGENT_COMMAND_COMMENT_MARKER } from "../github/comments";
import { gittensoryFooter } from "../github/footer";
Expand Down Expand Up @@ -63,6 +65,25 @@ const PLANNER_SYSTEM_PROMPT = [
const MAX_ISSUE_CHARS = 6_000;
const MAX_PLAN_CHARS = 8_000;
const PLANNER_MAX_TOKENS = 1_200;
const PLANNER_MODEL_COUNT = 4;

function plannerDailyBudget(env: Env): number {
const raw = Number(env.AI_DAILY_NEURON_BUDGET);
return clampNumber(env.AI_DAILY_NEURON_BUDGET && Number.isFinite(raw) ? raw : 10_000_000, 0, 10_000_000);
}

async function recordPlannerUsage(env: Env, args: { actor?: string | null | undefined; repoFullName?: string | null | undefined; issueNumber?: number | null | undefined; status: string; estimatedNeurons: number; detail: string }): Promise<void> {
await recordAiUsageEvent(env, {
feature: "issue_plan",
actor: args.actor ?? null,
route: "github_app.issue_plan",
model: [BEST_REVIEW_MODELS[0], RELIABLE_FALLBACK_MODELS[0]].join("+"),
status: args.status,
estimatedNeurons: args.estimatedNeurons,
detail: args.detail,
metadata: { repoFullName: args.repoFullName ?? null, issueNumber: args.issueNumber ?? null },
});
}

/** 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). */
Expand All @@ -88,12 +109,23 @@ async function runPlannerModel(env: Env, system: string, user: string): Promise<
/** 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> {
export async function generateIssuePlan(
env: Env,
issue: { title?: string | null | undefined; body?: string | null | undefined },
accounting: { actor?: string | null | undefined; repoFullName?: string | null | undefined; issueNumber?: number | 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 estimatedNeurons = estimateNeurons(PLANNER_SYSTEM_PROMPT.length + user.length, PLANNER_MAX_TOKENS, PLANNER_MODEL_COUNT);
const remainingBudget = Math.max(0, plannerDailyBudget(env) - (await sumAiEstimatedNeuronsSince(env, utcDayStartIso())));
if (estimatedNeurons > remainingBudget) {
await recordPlannerUsage(env, { ...accounting, status: "quota_exceeded", estimatedNeurons: 0, detail: `estimated ${estimatedNeurons} neurons exceeds remaining ${remainingBudget}` });
return null;
}
const plan = await runPlannerModel(env, PLANNER_SYSTEM_PROMPT, user);
await recordPlannerUsage(env, { ...accounting, status: plan ? "ok" : "no_output", estimatedNeurons: plan ? estimatedNeurons : 0, detail: plan ? "issue plan generated" : "no usable output" });
if (!plan) return null;
return plan.slice(0, MAX_PLAN_CHARS);
}
Expand Down
10 changes: 10 additions & 0 deletions test/unit/planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ describe("generateIssuePlan (#issue-coding-plan)", () => {
expect((run.mock.calls[0] as unknown as unknown[])[2]).toEqual({ gateway: { id: "gw-1" } });
});

it("does not call Workers AI when the shared daily neuron budget is exhausted", async () => {
const run = vi.fn(async () => ({ response: "should not run" }));
const env = createTestEnv({ AI: { run } as unknown as Ai, AI_DAILY_NEURON_BUDGET: "0" });
await expect(generateIssuePlan(env, { title: "Add a flag", body: "Use AI budget." }, { actor: "maint", repoFullName: "acme/widgets", issueNumber: 7 })).resolves.toBeNull();
expect(run).not.toHaveBeenCalled();
const usage = await env.DB.prepare("select feature, actor, status, estimated_neurons, metadata_json from ai_usage_events where feature = ?").bind("issue_plan").first<{ feature: string; actor: string; status: string; estimated_neurons: number; metadata_json: string }>();
expect(usage).toMatchObject({ feature: "issue_plan", actor: "maint", status: "quota_exceeded", estimated_neurons: 0 });
expect(JSON.parse(usage?.metadata_json ?? "{}")).toMatchObject({ repoFullName: "acme/widgets", issueNumber: 7 });
});

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 });
Expand Down
41 changes: 41 additions & 0 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,47 @@ describe("queue processors", () => {
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);
const usage = await env.DB.prepare("select feature, actor, status, estimated_neurons, metadata_json from ai_usage_events where feature = ?").bind("issue_plan").first<{ feature: string; actor: string; status: string; estimated_neurons: number; metadata_json: string }>();
expect(usage?.status).toBe("ok");
expect(usage?.actor).toBe("maintainer1");
expect(usage?.estimated_neurons).toBeGreaterThan(0);
expect(JSON.parse(usage?.metadata_json ?? "{}")).toMatchObject({ repoFullName: "JSONbored/gittensory", issueNumber: 77 });
});

it("planner: enforces the shared AI budget before calling Workers AI", async () => {
const run = vi.fn(async () => ({ response: "should not run" }));
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_PLANNER: "true", AI_DAILY_NEURON_BUDGET: "0", 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: "admin" });
return new Response("not found", { status: 404 });
});
await processJob(env, plannerWebhook("@gittensory plan", "maintainer1"));
expect(run).not.toHaveBeenCalled();
const usage = await env.DB.prepare("select status from ai_usage_events where feature = ?").bind("issue_plan").first<{ status: string }>();
expect(usage?.status).toBe("quota_exceeded");
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("planner: enforces a per-actor per-repo cooldown before spending AI", async () => {
const run = vi.fn(async () => ({ response: "## Summary\nPlan." }));
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, 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")) return Response.json({ id: init?.body ? 5 : 6 }, { status: 201 });
return new Response("not found", { status: 404 });
});
await processJob(env, plannerWebhook("@gittensory plan", "maintainer1"));
await processJob(env, plannerWebhook("@gittensory plan again", "maintainer1"));
expect(run).toHaveBeenCalledTimes(1);
const cooldown = await env.DB.prepare("select detail from audit_events where event_type = ? and detail = ?").bind("github_app.issue_plan_skipped", "cooldown_active").first<{ detail: string }>();
expect(cooldown?.detail).toBe("cooldown_active");
});

it("planner: flag OFF is byte-identical — @gittensory plan posts no plan and the AI is never called", async () => {
Expand Down
Loading