Skip to content
Closed
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
15 changes: 13 additions & 2 deletions packages/loopover-engine/src/idea-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}

// The "owner/name" slug check shared by both existing-repo input shapes (bare string and
// `{ kind: "existing", repo }`): each segment must be a GitHub-legal slug.
const TARGET_REPO_SLUG_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;

/** 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 {
Expand All @@ -98,10 +102,17 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult {
else if (input.body.length > IDEA_BODY_MAX_CHARS) errors.push("body_too_long");
// 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.
// `go`). A canonical `{ kind: "existing", repo }` object — the exact shape this validator itself returns,
// and what a TypeScript caller building against the exported `IdeaTarget` union constructs — resolves the
// same way, with the same slug check, so `validateIdeaSubmission` round-trips its own output (#9609). 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 };
if (TARGET_REPO_SLUG_PATTERN.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 === "existing") {
const repo = (input.targetRepo as Record<string, unknown>).repo;
if (typeof repo === "string" && TARGET_REPO_SLUG_PATTERN.test(repo)) resolvedTarget = { kind: "existing", repo };
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" };
Expand Down
37 changes: 34 additions & 3 deletions test/unit/idea-intake-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,42 @@ describe("validateIdeaSubmission", () => {
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" }]) {
it("rejects an object targetRepo whose kind is neither existing nor provision", () => {
for (const targetRepo of [{}, { kind: "bogus" }]) {
const r = validateIdeaSubmission(rawIdea({ targetRepo }));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors).toContain("target_repo_required");
if (!r.ok) expect(r.errors).toEqual(["target_repo_required"]);
}
});

it("accepts the canonical { kind: 'existing', repo } object the validator itself returns (#9609)", () => {
const r = validateIdeaSubmission(rawIdea({ targetRepo: { kind: "existing", repo: "acme/widgets" } }));
expect(r.ok).toBe(true);
if (r.ok) expect(r.idea.targetRepo).toEqual({ kind: "existing", repo: "acme/widgets" });
});

it("round-trips its own output: re-validating a validated idea succeeds with targetRepo unchanged (#9609)", () => {
for (const targetRepo of ["acme/widgets", { kind: "provision" }]) {
const first = validateIdeaSubmission(rawIdea({ targetRepo }));
expect(first.ok).toBe(true);
if (!first.ok) continue;
const second = validateIdeaSubmission(first.idea);
expect(second.ok).toBe(true);
if (second.ok) expect(second.idea.targetRepo).toEqual(first.idea.targetRepo);
}
});

it("flags a recognised-but-malformed { kind: 'existing' } repo as target_repo_malformed (#9609)", () => {
const r = validateIdeaSubmission(rawIdea({ targetRepo: { kind: "existing", repo: "not-a-slug" } }));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors).toEqual(["target_repo_malformed"]);
});

it("flags an { kind: 'existing' } target whose repo is missing, empty, or not a string as target_repo_malformed (#9609)", () => {
for (const targetRepo of [{ kind: "existing" }, { kind: "existing", repo: "" }, { kind: "existing", repo: " " }, { kind: "existing", repo: 42 }]) {
const r = validateIdeaSubmission(rawIdea({ targetRepo }));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors).toEqual(["target_repo_malformed"]);
}
});

Expand Down