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
29 changes: 22 additions & 7 deletions packages/loopover-engine/src/idea-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@ export const IDEA_CONSTRAINT_MAX_CHARS = 200;

export type IdeaPriority = "normal" | "high";

/** Where an idea's work lands: an existing repo (BYOR) or a not-yet-created one to auto-provision (#7589). */
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;
Expand Down Expand Up @@ -91,10 +96,16 @@ 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");
// Back-compat wire form: a bare "owner/name" string means an existing repo (each segment a GitHub-legal
// slug -- an uninstallable/malformed repo is rejected at intake, never scored, since it can never produce a
// `go`). A `{ kind: "provision" }` object requests a not-yet-created repo (#7589). Anything else is missing.
let resolvedTarget: IdeaTarget | undefined;
if (isNonEmptyString(input.targetRepo)) {
if (/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.targetRepo)) resolvedTarget = { kind: "existing", repo: input.targetRepo };
else errors.push("target_repo_malformed");
} else if (typeof input.targetRepo === "object" && input.targetRepo !== null && (input.targetRepo as Record<string, unknown>).kind === "provision") {
resolvedTarget = { kind: "provision" };
} else errors.push("target_repo_required");

const constraints = input.constraints;
if (constraints !== undefined) {
Expand All @@ -116,7 +127,7 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult {
id: input.id as string,
title: input.title as string,
body: input.body as string,
targetRepo: input.targetRepo as string,
targetRepo: resolvedTarget as IdeaTarget,
constraints: constraints as string[] | undefined,
acceptanceHints: acceptanceHints as string[] | undefined,
priority: priority as IdeaPriority | undefined,
Expand Down Expand Up @@ -254,7 +265,11 @@ export type ClaimPlan = {
* (#4798) to the claim/code/submit loop. Each issue is dispositioned by its already-computed feasibility
* verdict — `go` → claimable, `raise` → deferred, `avoid` → skipped — preserving the graph's own
* dependency-respecting order so a prerequisite is always claimed before its dependents. No IO, no claiming. */
export function buildClaimPlan(graph: TaskGraph, targetRepo: string): ClaimPlan {
export function buildClaimPlan(graph: TaskGraph, target: IdeaTarget | string): ClaimPlan {
// Accepts either a bare repo string (its historical shape) or an IdeaTarget, so callers threading a
// submission's `targetRepo` (#7635) need no change. A not-yet-provisioned target has no repo yet, so the
// plan carries "" -- ClaimStep/ClaimPlan.targetRepo stays a plain string, unchanged for every consumer.
const targetRepo = typeof target === "string" ? target : target.kind === "existing" ? target.repo : "";
const claimable: ClaimStep[] = [];
const deferred: ClaimStep[] = [];
const skipped: ClaimStep[] = [];
Expand Down
1 change: 1 addition & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ export {
type ConstituentIssueDraft,
type IdeaPriority,
type IdeaSubmission,
type IdeaTarget,
type IdeaValidationResult,
type TaskGraph,
type TaskGraphIssueScore,
Expand Down
Empty file modified packages/loopover-mcp/bin/loopover-mcp.js
100644 → 100755
Empty file.
64 changes: 48 additions & 16 deletions test/unit/idea-intake-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import {
} from "../../packages/loopover-engine/src/idea-intake";

function validIdea(overrides: Partial<IdeaSubmission> = {}): IdeaSubmission {
return { id: "idea-1", title: "One-line intent", body: "A freeform description of the outcome.", targetRepo: { kind: "existing", repo: "acme/widgets" }, ...overrides };
}

// Loose raw input for validateIdeaSubmission(unknown): lets a test pass a bare-string or malformed targetRepo
// (the back-compat wire form) that a strict IdeaSubmission would reject.
function rawIdea(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { id: "idea-1", title: "One-line intent", body: "A freeform description of the outcome.", targetRepo: "acme/widgets", ...overrides };
}

Expand Down Expand Up @@ -47,44 +53,62 @@ describe("validateIdeaSubmission", () => {
});

it("flags over-length title and body", () => {
const r = validateIdeaSubmission(validIdea({ title: "x".repeat(IDEA_TITLE_MAX_CHARS + 1), body: "y".repeat(IDEA_BODY_MAX_CHARS + 1) }));
const r = validateIdeaSubmission(rawIdea({ title: "x".repeat(IDEA_TITLE_MAX_CHARS + 1), body: "y".repeat(IDEA_BODY_MAX_CHARS + 1) }));
expect(r.ok).toBe(false);
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);
expect(validateIdeaSubmission(rawIdea({ targetRepo: "no-slash" })).ok).toBe(false);
expect(validateIdeaSubmission(rawIdea({ targetRepo: "a/b/c" })).ok).toBe(false);
expect(validateIdeaSubmission(rawIdea({ targetRepo: "owner/name" })).ok).toBe(true);
});

it("resolves a back-compat string targetRepo to an existing target, and accepts a provision object", () => {
const existing = validateIdeaSubmission(rawIdea({ targetRepo: "owner/name" }));
expect(existing.ok).toBe(true);
if (existing.ok) expect(existing.idea.targetRepo).toEqual({ kind: "existing", repo: "owner/name" });

const provision = validateIdeaSubmission(rawIdea({ targetRepo: { kind: "provision" } }));
expect(provision.ok).toBe(true);
if (provision.ok) expect(provision.idea.targetRepo).toEqual({ kind: "provision" });
});

it("rejects an object targetRepo that is not a provision request", () => {
for (const targetRepo of [{}, { kind: "existing" }]) {
const r = validateIdeaSubmission(rawIdea({ targetRepo }));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors).toContain("target_repo_required");
}
});

it("flags invalid constraints (non-array, non-string element, over-length entry)", () => {
expect((validateIdeaSubmission(validIdea({ constraints: "x" as unknown as string[] }))).ok).toBe(false);
expect((validateIdeaSubmission(validIdea({ constraints: [1] as unknown as string[] }))).ok).toBe(false);
const long = validateIdeaSubmission(validIdea({ constraints: ["c".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)] }));
expect((validateIdeaSubmission(rawIdea({ constraints: "x" as unknown as string[] }))).ok).toBe(false);
expect((validateIdeaSubmission(rawIdea({ constraints: [1] as unknown as string[] }))).ok).toBe(false);
const long = validateIdeaSubmission(rawIdea({ constraints: ["c".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)] }));
expect(long.ok).toBe(false);
if (!long.ok) expect(long.errors).toContain("constraint_too_long");
expect(validateIdeaSubmission(validIdea({ constraints: ["ok"] })).ok).toBe(true);
expect(validateIdeaSubmission(rawIdea({ constraints: ["ok"] })).ok).toBe(true);
});

it("flags invalid acceptanceHints and an invalid priority", () => {
expect(validateIdeaSubmission(validIdea({ acceptanceHints: "x" as unknown as string[] })).ok).toBe(false);
expect(validateIdeaSubmission(validIdea({ acceptanceHints: [2] as unknown as string[] })).ok).toBe(false);
expect(validateIdeaSubmission(validIdea({ priority: "urgent" as unknown as IdeaSubmission["priority"] })).ok).toBe(false);
expect(validateIdeaSubmission(validIdea({ priority: "normal" })).ok).toBe(true);
expect(validateIdeaSubmission(rawIdea({ acceptanceHints: "x" as unknown as string[] })).ok).toBe(false);
expect(validateIdeaSubmission(rawIdea({ acceptanceHints: [2] as unknown as string[] })).ok).toBe(false);
expect(validateIdeaSubmission(rawIdea({ priority: "urgent" as unknown as IdeaSubmission["priority"] })).ok).toBe(false);
expect(validateIdeaSubmission(rawIdea({ priority: "normal" })).ok).toBe(true);
});

it("caps acceptanceHints entry length at IDEA_CONSTRAINT_MAX_CHARS, same bound as constraints (#7243)", () => {
const atCap = validateIdeaSubmission(validIdea({ acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS)] }));
const atCap = validateIdeaSubmission(rawIdea({ acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS)] }));
expect(atCap.ok).toBe(true);

const overCap = validateIdeaSubmission(validIdea({ acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)] }));
const overCap = validateIdeaSubmission(rawIdea({ acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)] }));
expect(overCap.ok).toBe(false);
if (!overCap.ok) expect(overCap.errors).toContain("acceptance_hint_too_long");
});

it("does not raise acceptance_hint_too_long for a malformed (non-array) acceptanceHints — shape error only", () => {
const r = validateIdeaSubmission(validIdea({ acceptanceHints: "x".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1) as unknown as string[] }));
const r = validateIdeaSubmission(rawIdea({ acceptanceHints: "x".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1) as unknown as string[] }));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.errors).toContain("acceptance_hints_invalid");
Expand Down Expand Up @@ -230,7 +254,15 @@ 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" });

it("accepts a bare repo string and an IdeaTarget, and carries \"\" for a not-yet-provisioned target", () => {
const graph = buildTaskGraph(idea, [{ key: "issue-1", title: "Add widget", body: "new" }]);
// bare string (historical shape), existing IdeaTarget, and a provision target all resolve correctly.
expect(buildClaimPlan(graph, "acme/widgets").targetRepo).toBe("acme/widgets");
expect(buildClaimPlan(graph, { kind: "existing", repo: "acme/widgets" }).targetRepo).toBe("acme/widgets");
expect(buildClaimPlan(graph, { kind: "provision" }).targetRepo).toBe("");
});

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);
Expand Down