From 558857ba0e3a478bccceb8f2e9f98f9643a2c914 Mon Sep 17 00:00:00 2001 From: phamngocquy Date: Fri, 31 Jul 2026 14:16:09 +0800 Subject: [PATCH] intake(idea): accept the IdeaTarget object shapes validateIdeaSubmission already supports Fixes #10064 --- apps/loopover-ui/public/openapi.json | 26 +++++++++++++++++-- .../loopover-contract/src/api-requests.ts | 2 +- packages/loopover-contract/src/tools/agent.ts | 2 +- .../loopover-engine/test/idea-intake.test.ts | 9 ++++--- src/openapi/schemas.ts | 4 +-- test/unit/contract-api-requests.test.ts | 13 ++++++++++ 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 3a40a3a5b4..dc8c345010 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14906,7 +14906,18 @@ "type": "string" }, "targetRepo": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {}, + "additionalProperties": { + "nullable": true + } + } + ] }, "constraints": { "type": "array", @@ -14998,7 +15009,18 @@ "type": "string" }, "targetRepo": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {}, + "additionalProperties": { + "nullable": true + } + } + ] }, "constraints": { "type": "array", diff --git a/packages/loopover-contract/src/api-requests.ts b/packages/loopover-contract/src/api-requests.ts index 6b49b49c50..d17c016e27 100644 --- a/packages/loopover-contract/src/api-requests.ts +++ b/packages/loopover-contract/src/api-requests.ts @@ -165,7 +165,7 @@ export const intakeIdeaSchema = z.object({ id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + targetRepo: z.union([z.string(), z.looseObject({})]).optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), diff --git a/packages/loopover-contract/src/tools/agent.ts b/packages/loopover-contract/src/tools/agent.ts index 3ed4b43f84..5d419fd210 100644 --- a/packages/loopover-contract/src/tools/agent.ts +++ b/packages/loopover-contract/src/tools/agent.ts @@ -30,7 +30,7 @@ export const IntakeIdeaInput = z.object({ id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + targetRepo: z.union([z.string(), z.looseObject({})]).optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), diff --git a/packages/loopover-engine/test/idea-intake.test.ts b/packages/loopover-engine/test/idea-intake.test.ts index 480def28bf..c84d663e62 100644 --- a/packages/loopover-engine/test/idea-intake.test.ts +++ b/packages/loopover-engine/test/idea-intake.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { validateIdeaSubmission } from "../dist/index.js"; +import { buildClaimPlan, buildTaskGraph, validateIdeaSubmission } from "../dist/index.js"; // Engine-suite (node:test) coverage for validateIdeaSubmission's targetRepo resolution (#9609) so the // `engine` Codecov flag credits the changed lines, mirroring test/unit/idea-intake-bridge.test.ts. @@ -21,10 +21,13 @@ test("accepts the canonical { kind: 'existing', repo } object it returns (round- if (r.ok) assert.deepEqual(r.idea.targetRepo, { kind: "existing", repo: "acme/widgets" }); }); -test("accepts a provision object", () => { +test("accepts a provision object and buildClaimPlan carries an empty targetRepo", () => { const r = validateIdeaSubmission(rawIdea({ kind: "provision" })); assert.equal(r.ok, true); - if (r.ok) assert.deepEqual(r.idea.targetRepo, { kind: "provision" }); + if (!r.ok) return; + assert.deepEqual(r.idea.targetRepo, { kind: "provision" }); + const plan = buildClaimPlan(buildTaskGraph(r.idea), r.idea.targetRepo); + assert.equal(plan.targetRepo, ""); }); test("rejects a malformed slug in both the string and the existing-object form", () => { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 0b49ebd854..2f2fce0307 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -2287,7 +2287,7 @@ export const IntakeIdeaRequestSchema = z id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + targetRepo: z.union([z.string(), z.looseObject({})]).optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), @@ -2322,7 +2322,7 @@ export const PlanIdeaClaimsRequestSchema = z id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + targetRepo: z.union([z.string(), z.looseObject({})]).optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), diff --git a/test/unit/contract-api-requests.test.ts b/test/unit/contract-api-requests.test.ts index 4359206826..942a3c79c3 100644 --- a/test/unit/contract-api-requests.test.ts +++ b/test/unit/contract-api-requests.test.ts @@ -27,6 +27,7 @@ import { SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS, } from "@loopover/contract"; +import { validateIdeaSubmission } from "@loopover/engine"; import { MAX_NOTIFICATION_DELIVERY_ID_LENGTH as SRC_DELIVERY_ID, MAX_NOTIFICATION_MARK_READ_IDS as SRC_MARK_READ } from "../../src/db/repositories"; import { MAX_FOCUS_MANIFEST_BYTES as SRC_MANIFEST_BYTES } from "../../src/signals/focus-manifest"; import { MAX_LOCAL_SCORER_WARNING_CHARS as SRC_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT as SRC_WARNING_COUNT } from "../../src/signals/local-scorer-diagnostics"; @@ -108,6 +109,18 @@ describe("the moved schemas accept and reject what they always did (#9750)", () // empty submission must REACH the handler rather than be rejected by the schema. expect(intakeIdeaSchema.safeParse({}).success).toBe(true); expect(intakeIdeaSchema.safeParse({ constraints: Array.from({ length: 51 }, () => "c") }).success).toBe(false); + expect(intakeIdeaSchema.safeParse({ targetRepo: { kind: "provision" } }).success).toBe(true); + expect(intakeIdeaSchema.safeParse({ targetRepo: { kind: "existing", repo: "acme/widgets" } }).success).toBe(true); + expect(intakeIdeaSchema.safeParse({ targetRepo: "acme/widgets" }).success).toBe(true); + expect(intakeIdeaSchema.safeParse({ targetRepo: 42 }).success).toBe(false); + }); + + it("#10064: malformed object targetRepo passes the schema but is rejected by validateIdeaSubmission", () => { + const body = { targetRepo: { kind: "existing" } }; + expect(intakeIdeaSchema.safeParse(body).success).toBe(true); + const validated = validateIdeaSubmission(body); + expect(validated.ok).toBe(false); + if (!validated.ok) expect(validated.errors).toContain("target_repo_required"); }); it("leaves check-before-start entirely optional — the repository is the path param, not the body", () => {