From 59126e73003bf265495805f1b2db3dcfee6c5d8c Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 14:40:13 +0800 Subject: [PATCH] feat(engine): add IdeaTarget existing|provision union for idea intake Closes #7635 Co-authored-by: Cursor --- packages/loopover-engine/src/idea-intake.ts | 50 ++++++++++++++++--- packages/loopover-engine/src/index.ts | 2 + packages/loopover-mcp/bin/loopover-mcp.js | 9 ++-- packages/loopover-mcp/bin/loopover-mcp.ts | 8 +-- src/api/routes.ts | 8 +-- src/mcp/server.ts | 13 +++-- test/unit/idea-intake-bridge.test.ts | 40 +++++++++++---- test/unit/mcp-cli-intake-idea-tool.test.ts | 2 +- .../mcp-cli-plan-idea-claims-tool.test.ts | 8 +-- test/unit/mcp-intake-idea.test.ts | 6 +-- test/unit/routes-intake-idea.test.ts | 3 +- test/unit/routes-plan-idea-claims.test.ts | 9 ++-- 12 files changed, 117 insertions(+), 41 deletions(-) diff --git a/packages/loopover-engine/src/idea-intake.ts b/packages/loopover-engine/src/idea-intake.ts index a5f8d14eb6..2b0ff15ec5 100644 --- a/packages/loopover-engine/src/idea-intake.ts +++ b/packages/loopover-engine/src/idea-intake.ts @@ -19,12 +19,17 @@ export const IDEA_CONSTRAINT_MAX_CHARS = 200; export type IdeaPriority = "normal" | "high"; +/** Where an idea should land (#7635 / #7589): an existing BYOR repo, or an APR repo not yet provisioned. */ +export type IdeaTarget = + | { kind: "existing"; repo: string } + | { kind: "provision" }; + /** The raw input a renter provides (spec §1). */ export type IdeaSubmission = { id: string; title: string; body: string; - targetRepo: string; + targetRepo: IdeaTarget; constraints?: string[] | undefined; acceptanceHints?: string[] | undefined; priority?: IdeaPriority | undefined; @@ -80,6 +85,40 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +const TARGET_REPO_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +/** Parse `IdeaTarget` from raw intake input. `existing` still requires `owner/name`; `provision` needs no repo. */ +function parseIdeaTarget(raw: unknown, errors: string[]): IdeaTarget | undefined { + if (raw === undefined || raw === null || raw === "") { + errors.push("target_repo_required"); + return undefined; + } + if (typeof raw !== "object" || Array.isArray(raw)) { + errors.push("target_repo_malformed"); + return undefined; + } + const input = raw as Record; + if (input.kind === "provision") return { kind: "provision" }; + if (input.kind === "existing") { + if (!isNonEmptyString(input.repo)) { + errors.push("target_repo_required"); + return undefined; + } + if (!TARGET_REPO_SLUG.test(input.repo)) { + errors.push("target_repo_malformed"); + return undefined; + } + return { kind: "existing", repo: input.repo }; + } + errors.push("target_repo_malformed"); + return undefined; +} + +/** Concrete `owner/name` when the target already exists; `null` for APR `provision` (no repo yet). */ +export function existingTargetRepo(target: IdeaTarget): string | null { + return target.kind === "existing" ? target.repo : null; +} + /** Validate + normalize a raw renter submission (spec §1). Returns every failure at once (never folds with * `??`/`||`) so a caller can surface all problems in one pass rather than one-at-a-time. */ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { @@ -91,10 +130,7 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { else if (input.title.length > IDEA_TITLE_MAX_CHARS) errors.push("title_too_long"); if (!isNonEmptyString(input.body)) errors.push("body_required"); else if (input.body.length > IDEA_BODY_MAX_CHARS) errors.push("body_too_long"); - // `owner/name`, each segment a GitHub-legal slug — an uninstallable/malformed repo is rejected at intake, - // never scored, since it can never produce a `go`. - if (!isNonEmptyString(input.targetRepo)) errors.push("target_repo_required"); - else if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.targetRepo)) errors.push("target_repo_malformed"); + const targetRepo = parseIdeaTarget(input.targetRepo, errors); const constraints = input.constraints; if (constraints !== undefined) { @@ -109,14 +145,14 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { const priority = input.priority; if (priority !== undefined && priority !== "normal" && priority !== "high") errors.push("priority_invalid"); - if (errors.length > 0) return { ok: false, errors }; + if (errors.length > 0 || targetRepo === undefined) return { ok: false, errors }; return { ok: true, idea: { id: input.id as string, title: input.title as string, body: input.body as string, - targetRepo: input.targetRepo as string, + targetRepo, constraints: constraints as string[] | undefined, acceptanceHints: acceptanceHints as string[] | undefined, priority: priority as IdeaPriority | undefined, diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index e011842832..bb3f5834c3 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -608,6 +608,7 @@ export { export { buildClaimPlan, buildTaskGraph, + existingTargetRepo, scoreTaskGraph, validateIdeaSubmission, IDEA_TITLE_MAX_CHARS, @@ -621,6 +622,7 @@ export { type ConstituentIssueDraft, type IdeaPriority, type IdeaSubmission, + type IdeaTarget, type IdeaValidationResult, type TaskGraph, type TaskGraphIssueScore, diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 3d73fbb5e0..fad44f000a 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -27,7 +27,7 @@ import { buildResultsPayload } from "@loopover/engine"; // #6753: the same pure composer the remote MCP tool + /v1/loop/progress-snapshot both call. import { buildProgressSnapshot } from "@loopover/engine"; // #6755: the same pure bridge the remote MCP tool + /v1/loop/intake-idea both call. -import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "@loopover/engine"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan, existingTargetRepo } from "@loopover/engine"; import { z } from "zod"; import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js"; import { formatTable } from "../lib/format-table.js"; @@ -550,7 +550,7 @@ const intakeIdeaShape = { id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + targetRepo: z.unknown().optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), @@ -1550,7 +1550,10 @@ registerStdioTool("loopover_plan_idea_claims", { if (!validated.ok) return toolResult(`Invalid idea submission: ${validated.errors.join(", ")}.`, { ok: false, errors: validated.errors }); const graph = buildTaskGraph(validated.idea, input.decomposition); - const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) + return toolResult("Invalid idea submission: target_repo_required.", { ok: false, errors: ["target_repo_required"] }); + const claimPlan = buildClaimPlan(graph, repo); return toolResult(`Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, { ok: true, verdict: claimPlan.graphVerdict, claimPlan }); }); registerStdioTool("loopover_check_issue_slop", { diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index d164dddd5e..c30a4e05b2 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -37,7 +37,7 @@ import { buildResultsPayload } from "@loopover/engine"; // #6753: the same pure composer the remote MCP tool + /v1/loop/progress-snapshot both call. import { buildProgressSnapshot } from "@loopover/engine"; // #6755: the same pure bridge the remote MCP tool + /v1/loop/intake-idea both call. -import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "@loopover/engine"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan, existingTargetRepo } from "@loopover/engine"; import { z } from "zod"; import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js"; import { formatTable } from "../lib/format-table.js"; @@ -576,7 +576,7 @@ const intakeIdeaShape = { id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + targetRepo: z.unknown().optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), @@ -1750,7 +1750,9 @@ registerStdioTool( const validated = validateIdeaSubmission(input); if (!validated.ok) return toolResult(`Invalid idea submission: ${validated.errors.join(", ")}.`, { ok: false, errors: validated.errors }); const graph = buildTaskGraph(validated.idea, input.decomposition); - const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) return toolResult("Invalid idea submission: target_repo_required.", { ok: false, errors: ["target_repo_required"] }); + const claimPlan = buildClaimPlan(graph, repo); return toolResult( `Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, { ok: true, verdict: claimPlan.graphVerdict, claimPlan }, diff --git a/src/api/routes.ts b/src/api/routes.ts index 25e973876a..c99ff9a5ac 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -217,7 +217,7 @@ import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { evaluateEscalation } from "../loop-escalation"; import { buildResultsPayload } from "../results-payload"; import { buildProgressSnapshot } from "../loop-progress"; -import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan, existingTargetRepo } from "../idea-intake"; import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings"; import { buildMcpCompatibilityMetadata, @@ -552,7 +552,7 @@ const intakeIdeaSchema = z.object({ id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + targetRepo: z.unknown().optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), @@ -3714,7 +3714,9 @@ export function createApp() { const validated = validateIdeaSubmission(parsed.data); if (!validated.ok) return c.json({ ok: false, errors: validated.errors }, 400); const graph = buildTaskGraph(validated.idea, parsed.data.decomposition); - const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) return c.json({ ok: false, errors: ["target_repo_required"] }, 400); + const claimPlan = buildClaimPlan(graph, repo); return c.json({ ok: true, verdict: claimPlan.graphVerdict, claimPlan }); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 85d59a46ee..36be1b34ac 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -172,7 +172,7 @@ import { buildPredictedGateVerdict, buildGateDispositions, type PredictedGateVer export { buildGateDispositions, type GateDisposition } from "../rules/predicted-gate"; import { buildIssueSlopAssessment } from "../signals/issue-slop"; import { buildSlopAssessment } from "../signals/slop"; -import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan, existingTargetRepo } from "../idea-intake"; import { buildResultsPayload } from "../results-payload"; import { buildProgressSnapshot } from "../loop-progress"; import { evaluateEscalation } from "../loop-escalation"; @@ -1132,7 +1132,7 @@ const intakeIdeaShape = { id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + targetRepo: z.unknown().optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), @@ -3700,7 +3700,14 @@ export class LoopoverMcp { }; } const graph = buildTaskGraph(validated.idea, input.decomposition); - const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) { + return { + summary: "Invalid idea submission: target_repo_required.", + data: { ok: false, errors: ["target_repo_required"] } as unknown as Record, + }; + } + const claimPlan = buildClaimPlan(graph, repo); return { summary: `Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, data: { ok: true, verdict: claimPlan.graphVerdict, claimPlan } as unknown as Record, diff --git a/test/unit/idea-intake-bridge.test.ts b/test/unit/idea-intake-bridge.test.ts index c057f8453f..4783f308fd 100644 --- a/test/unit/idea-intake-bridge.test.ts +++ b/test/unit/idea-intake-bridge.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildClaimPlan, buildTaskGraph, + existingTargetRepo, scoreTaskGraph, validateIdeaSubmission, IDEA_TITLE_MAX_CHARS, @@ -13,13 +14,21 @@ import { } from "../../packages/loopover-engine/src/idea-intake"; function validIdea(overrides: Partial = {}): IdeaSubmission { - return { id: "idea-1", title: "One-line intent", body: "A freeform description of the outcome.", targetRepo: "acme/widgets", ...overrides }; + return { + id: "idea-1", + title: "One-line intent", + body: "A freeform description of the outcome.", + targetRepo: { kind: "existing", repo: "acme/widgets" }, + ...overrides, + }; } +const existingRepo = (repo: string) => ({ kind: "existing" as const, repo }); + describe("validateIdeaSubmission", () => { it("accepts a full, well-formed submission", () => { const r = validateIdeaSubmission({ - id: "idea-1", title: "t", body: "b", targetRepo: "owner/name", + id: "idea-1", title: "t", body: "b", targetRepo: existingRepo("owner/name"), constraints: ["no new dependencies"], acceptanceHints: ["existing callers keep working"], priority: "high", }); expect(r.ok).toBe(true); @@ -27,7 +36,7 @@ describe("validateIdeaSubmission", () => { }); it("accepts a minimal submission (only required fields)", () => { - const r = validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: "o/n" }); + const r = validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: existingRepo("o/n") }); expect(r.ok).toBe(true); if (r.ok) expect(r.idea.constraints).toBeUndefined(); }); @@ -52,10 +61,18 @@ describe("validateIdeaSubmission", () => { if (!r.ok) expect(r.errors).toEqual(expect.arrayContaining(["title_too_long", "body_too_long"])); }); - it("flags a malformed targetRepo (must be owner/name)", () => { - expect(validateIdeaSubmission(validIdea({ targetRepo: "no-slash" })).ok).toBe(false); - expect(validateIdeaSubmission(validIdea({ targetRepo: "a/b/c" })).ok).toBe(false); - expect(validateIdeaSubmission(validIdea({ targetRepo: "owner/name" })).ok).toBe(true); + it("flags a malformed targetRepo (must be existing owner/name or provision)", () => { + expect(validateIdeaSubmission(validIdea({ targetRepo: existingRepo("no-slash") })).ok).toBe(false); + expect(validateIdeaSubmission(validIdea({ targetRepo: existingRepo("a/b/c") })).ok).toBe(false); + expect(validateIdeaSubmission(validIdea({ targetRepo: existingRepo("owner/name") })).ok).toBe(true); + expect(validateIdeaSubmission(validIdea({ targetRepo: { kind: "provision" } })).ok).toBe(true); + expect(validateIdeaSubmission(validIdea({ targetRepo: "owner/name" as unknown as IdeaSubmission["targetRepo"] })).ok).toBe(false); + expect(validateIdeaSubmission(validIdea({ targetRepo: { kind: "other" } as unknown as IdeaSubmission["targetRepo"] })).ok).toBe(false); + }); + + it("existingTargetRepo returns owner/name for existing and null for provision (#7635)", () => { + expect(existingTargetRepo({ kind: "existing", repo: "acme/widgets" })).toBe("acme/widgets"); + expect(existingTargetRepo({ kind: "provision" })).toBeNull(); }); it("flags invalid constraints (non-array, non-string element, over-length entry)", () => { @@ -96,7 +113,7 @@ describe("validateIdeaSubmission", () => { const r = validateIdeaSubmission({ title: "t", body: "b", - targetRepo: "o/n", + targetRepo: existingRepo("o/n"), acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)], }); expect(r.ok).toBe(false); @@ -230,10 +247,11 @@ describe("scoreTaskGraph — graph verdict is the least-favorable across issues" }); describe("buildClaimPlan — routes a scored task-graph into a loop claim plan (#4799)", () => { - const idea = validIdea({ id: "idea-C", targetRepo: "acme/widgets" }); + const idea = validIdea({ id: "idea-C", targetRepo: existingRepo("acme/widgets") }); + const repo = existingTargetRepo(idea.targetRepo)!; it("puts a lone go issue in claimable, carrying the target repo", () => { - const plan = buildClaimPlan(buildTaskGraph(idea, [{ key: "issue-1", title: "Add widget", body: "new" }]), idea.targetRepo); + const plan = buildClaimPlan(buildTaskGraph(idea, [{ key: "issue-1", title: "Add widget", body: "new" }]), repo); expect(plan.ideaId).toBe("idea-C"); expect(plan.targetRepo).toBe("acme/widgets"); expect(plan.graphVerdict).toBe("go"); @@ -249,7 +267,7 @@ describe("buildClaimPlan — routes a scored task-graph into a loop claim plan ( { key: "issue-2", title: "Held", body: "b", dependsOn: ["issue-1"] }, // raise (dep not landed) -> deferred { key: "issue-3", title: "Unshippable", body: "b", feasibility: { issueStatus: "invalid" } }, // avoid -> skipped ]); - const plan = buildClaimPlan(graph, idea.targetRepo); + const plan = buildClaimPlan(graph, repo); expect(plan.claimable.map((s) => s.key)).toEqual(["issue-1"]); expect(plan.deferred.map((s) => s.key)).toEqual(["issue-2"]); expect(plan.deferred[0]?.reasons).toContain("dependency_not_landed"); diff --git a/test/unit/mcp-cli-intake-idea-tool.test.ts b/test/unit/mcp-cli-intake-idea-tool.test.ts index 64f9892c0a..31e1dd712a 100644 --- a/test/unit/mcp-cli-intake-idea-tool.test.ts +++ b/test/unit/mcp-cli-intake-idea-tool.test.ts @@ -16,7 +16,7 @@ let client: Client; let transport: StdioClientTransport; let configDir: string; -const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", targetRepo: "acme/widgets" }; +const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", targetRepo: { kind: "existing", repo: "acme/widgets" } }; beforeEach(async () => { configDir = mkdtempSync(join(tmpdir(), "loopover-intake-idea-")); diff --git a/test/unit/mcp-cli-plan-idea-claims-tool.test.ts b/test/unit/mcp-cli-plan-idea-claims-tool.test.ts index 456655bda1..9a3face583 100644 --- a/test/unit/mcp-cli-plan-idea-claims-tool.test.ts +++ b/test/unit/mcp-cli-plan-idea-claims-tool.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { buildClaimPlan, buildTaskGraph, validateIdeaSubmission } from "../../src/idea-intake"; +import { buildClaimPlan, buildTaskGraph, existingTargetRepo, validateIdeaSubmission } from "../../src/idea-intake"; // #6756: the local mirror of loopover_plan_idea_claims. Like its same-tier sibling loopover_check_slop_risk, // it computes IN-PROCESS from @loopover/engine — no API round-trip — so claim planning works fully offline. @@ -20,14 +20,16 @@ const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", - targetRepo: "acme/widgets", + targetRepo: { kind: "existing", repo: "acme/widgets" }, }; function expectedPayload(body: unknown) { const validated = validateIdeaSubmission(body); if (!validated.ok) return { ok: false as const, errors: validated.errors }; + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) return { ok: false as const, errors: ["target_repo_required"] }; const graph = buildTaskGraph(validated.idea, (body as { decomposition?: never }).decomposition); - const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + const claimPlan = buildClaimPlan(graph, repo); return { ok: true as const, verdict: claimPlan.graphVerdict, claimPlan }; } diff --git a/test/unit/mcp-intake-idea.test.ts b/test/unit/mcp-intake-idea.test.ts index 34c033ea20..032d826221 100644 --- a/test/unit/mcp-intake-idea.test.ts +++ b/test/unit/mcp-intake-idea.test.ts @@ -21,7 +21,7 @@ describe("MCP loopover_intake_idea", () => { arguments: { id: "idea-A", title: "Retry flaky uploads", body: "Our upload client gives up on the first 5xx; it should retry a few times before failing.", - targetRepo: "acme/widgets", constraints: ["no new dependencies"], + targetRepo: { kind: "existing", repo: "acme/widgets" }, constraints: ["no new dependencies"], }, }); expect(result.isError).toBeFalsy(); @@ -38,7 +38,7 @@ describe("MCP loopover_intake_idea", () => { arguments: { id: "idea-B", title: "Add API key auth to the public endpoints", body: "Let callers authenticate the read API with an API key instead of leaving it open.", - targetRepo: "acme/widgets", + targetRepo: { kind: "existing", repo: "acme/widgets" }, decomposition: [ { key: "issue-1", title: "Introduce API-key store + validation helper", body: "A valid key validates." }, { key: "issue-2", title: "Gate the read endpoints behind key validation", body: "Require a valid key.", dependsOn: ["issue-1"] }, @@ -69,7 +69,7 @@ describe("MCP loopover_plan_idea_claims", () => { const result = await client.callTool({ name: "loopover_plan_idea_claims", arguments: { - id: "idea-P", title: "Add API key auth", body: "Authenticate the read API with a key.", targetRepo: "acme/widgets", + id: "idea-P", title: "Add API key auth", body: "Authenticate the read API with a key.", targetRepo: { kind: "existing", repo: "acme/widgets" }, decomposition: [ { key: "issue-1", title: "Introduce API-key store", body: "validate keys" }, { key: "issue-2", title: "Gate the read endpoints", body: "require a key", dependsOn: ["issue-1"] }, diff --git a/test/unit/routes-intake-idea.test.ts b/test/unit/routes-intake-idea.test.ts index e89ce7f1b2..ba1877d489 100644 --- a/test/unit/routes-intake-idea.test.ts +++ b/test/unit/routes-intake-idea.test.ts @@ -15,7 +15,7 @@ const PATH = "/v1/loop/intake-idea"; const post = (env: Env, body: unknown) => createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, env); -const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", targetRepo: "acme/widgets" }; +const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", targetRepo: { kind: "existing", repo: "acme/widgets" } }; describe("POST /v1/loop/intake-idea (#6755)", () => { it("turns a valid submission into a scored task-graph", async () => { @@ -52,6 +52,7 @@ describe("POST /v1/loop/intake-idea (#6755)", () => { { ...VALID, priority: "high" }, { ...VALID, priority: "normal" }, { ...VALID, constraints: ["no new deps"], acceptanceHints: ["covered by a unit test"] }, + { ...VALID, targetRepo: { kind: "provision" } }, { ...VALID, decomposition: [{ key: "a", title: "Only issue", body: "Body." }] }, { ...VALID, decomposition: [{ key: "a", title: "First", body: "Body." }, { key: "b", title: "Second", body: "Body.", dependsOn: ["a"] }] }, ]; diff --git a/test/unit/routes-plan-idea-claims.test.ts b/test/unit/routes-plan-idea-claims.test.ts index 16c20b9440..36a42e6afb 100644 --- a/test/unit/routes-plan-idea-claims.test.ts +++ b/test/unit/routes-plan-idea-claims.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createApp } from "../../src/api/routes"; -import { buildClaimPlan, buildTaskGraph, validateIdeaSubmission } from "../../src/idea-intake"; +import { buildClaimPlan, buildTaskGraph, existingTargetRepo, validateIdeaSubmission } from "../../src/idea-intake"; import { createTestEnv } from "../helpers/d1"; // #6756: POST /v1/loop/plan-idea-claims — the REST mirror bringing loopover_plan_idea_claims to the same @@ -21,14 +21,16 @@ const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", - targetRepo: "acme/widgets", + targetRepo: { kind: "existing", repo: "acme/widgets" }, }; function expectedPayload(body: unknown) { const validated = validateIdeaSubmission(body); if (!validated.ok) return { ok: false as const, errors: validated.errors }; + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) return { ok: false as const, errors: ["target_repo_required"] }; const graph = buildTaskGraph(validated.idea, (body as { decomposition?: never }).decomposition); - const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + const claimPlan = buildClaimPlan(graph, repo); return { ok: true as const, verdict: claimPlan.graphVerdict, claimPlan }; } @@ -81,6 +83,7 @@ describe("POST /v1/loop/plan-idea-claims (#6756)", () => { { ...VALID, title: "" }, { ...VALID, body: "" }, { ...VALID, targetRepo: "" }, + { ...VALID, targetRepo: { kind: "provision" } }, ]; for (const body of cases) { const response = await post(env, body);