Skip to content

Commit c086363

Browse files
RealDiligentRealDiligent
andauthored
fix(engine): accept the { kind: 'existing', repo } IdeaTarget shape in validateIdeaSubmission (#9634)
validateIdeaSubmission returns an IdeaTarget (a { kind: 'existing', repo } | { kind: 'provision' } union), but its own targetRepo validation only accepted a bare 'owner/name' string or a { kind: 'provision' } object -- so the canonical { kind: 'existing', repo } shape it produces (and that any TS caller writing against the exported IdeaSubmission type constructs) fell through to target_repo_required. The value it returns did not round-trip through it. Accept the existing-target object shape, sharing the same owner/name split-and-guard as the bare-string form via a resolveExistingTarget helper so a '..'-traversal slug is rejected identically in both forms. Co-authored-by: RealDiligent <nft.gold.eth@gmail.com>
1 parent d997bac commit c086363

3 files changed

Lines changed: 85 additions & 10 deletions

File tree

packages/loopover-engine/src/idea-intake.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,19 @@ function isNonEmptyString(value: unknown): value is string {
8686
return typeof value === "string" && value.trim().length > 0;
8787
}
8888

89+
/** Resolve an existing-repo target from its "owner/name" string, applying the same split-and-guard as
90+
* repo-clone.ts's normalizeRepoFullName (exactly two valid segments, so a "."/".." traversal is rejected).
91+
* Pushes `target_repo_malformed` and returns undefined on a bad slug. Shared by the bare-string wire form
92+
* and the canonical `{ kind: "existing", repo }` object shape (#9609). */
93+
function resolveExistingTarget(repo: string, errors: string[]): IdeaTarget | undefined {
94+
const [owner, name, extra] = repo.split("/");
95+
if (!owner || !name || extra !== undefined || !isValidRepoSegment(owner) || !isValidRepoSegment(name)) {
96+
errors.push("target_repo_malformed");
97+
return undefined;
98+
}
99+
return { kind: "existing", repo };
100+
}
101+
89102
/** Validate + normalize a raw renter submission (spec §1). Returns every failure at once (never folds with
90103
* `??`/`||`) so a caller can surface all problems in one pass rather than one-at-a-time. */
91104
export function validateIdeaSubmission(raw: unknown): IdeaValidationResult {
@@ -102,16 +115,19 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult {
102115
// `go`). A `{ kind: "provision" }` object requests a not-yet-created repo (#7589). Anything else is missing.
103116
let resolvedTarget: IdeaTarget | undefined;
104117
if (isNonEmptyString(input.targetRepo)) {
105-
// Same split-and-guard shape as repo-clone.ts's normalizeRepoFullName: exactly two segments, each a
106-
// valid repo segment, so a bare "." / ".." traversal segment is rejected at intake too.
107-
const [owner, repo, extra] = input.targetRepo.split("/");
108-
if (!owner || !repo || extra !== undefined || !isValidRepoSegment(owner) || !isValidRepoSegment(repo)) {
109-
errors.push("target_repo_malformed");
110-
} else {
111-
resolvedTarget = { kind: "existing", repo: input.targetRepo };
112-
}
113-
} else if (typeof input.targetRepo === "object" && input.targetRepo !== null && (input.targetRepo as Record<string, unknown>).kind === "provision") {
114-
resolvedTarget = { kind: "provision" };
118+
// Back-compat wire form: a bare "owner/name" string.
119+
resolvedTarget = resolveExistingTarget(input.targetRepo, errors);
120+
} else if (typeof input.targetRepo === "object" && input.targetRepo !== null) {
121+
// The canonical IdeaTarget object shapes -- including `{ kind: "existing", repo }`, the exact shape this
122+
// validator itself returns, so a value it produced (or any TS caller writing against the exported
123+
// IdeaSubmission type) round-trips back through it (#9609). `{ kind: "provision" }` requests a
124+
// not-yet-created repo (#7589).
125+
const target = input.targetRepo as Record<string, unknown>;
126+
if (target.kind === "provision") {
127+
resolvedTarget = { kind: "provision" };
128+
} else if (target.kind === "existing" && isNonEmptyString(target.repo)) {
129+
resolvedTarget = resolveExistingTarget(target.repo, errors);
130+
} else errors.push("target_repo_required");
115131
} else errors.push("target_repo_required");
116132

117133
const constraints = input.constraints;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { validateIdeaSubmission } from "../dist/index.js";
5+
6+
// Engine-suite (node:test) coverage for validateIdeaSubmission's targetRepo resolution (#9609) so the
7+
// `engine` Codecov flag credits the changed lines, mirroring test/unit/idea-intake-bridge.test.ts.
8+
function rawIdea(targetRepo: unknown) {
9+
return { id: "idea-1", title: "One-line intent", body: "A description.", targetRepo };
10+
}
11+
12+
test("resolves a bare owner/name string to an existing target", () => {
13+
const r = validateIdeaSubmission(rawIdea("owner/name"));
14+
assert.equal(r.ok, true);
15+
if (r.ok) assert.deepEqual(r.idea.targetRepo, { kind: "existing", repo: "owner/name" });
16+
});
17+
18+
test("accepts the canonical { kind: 'existing', repo } object it returns (round-trip)", () => {
19+
const r = validateIdeaSubmission(rawIdea({ kind: "existing", repo: "acme/widgets" }));
20+
assert.equal(r.ok, true);
21+
if (r.ok) assert.deepEqual(r.idea.targetRepo, { kind: "existing", repo: "acme/widgets" });
22+
});
23+
24+
test("accepts a provision object", () => {
25+
const r = validateIdeaSubmission(rawIdea({ kind: "provision" }));
26+
assert.equal(r.ok, true);
27+
if (r.ok) assert.deepEqual(r.idea.targetRepo, { kind: "provision" });
28+
});
29+
30+
test("rejects a malformed slug in both the string and the existing-object form", () => {
31+
for (const bad of ["no-slash", "a/b/c", { kind: "existing", repo: "no-slash" }, { kind: "existing", repo: "a/b/c" }]) {
32+
assert.equal(validateIdeaSubmission(rawIdea(bad)).ok, false);
33+
}
34+
});
35+
36+
test("requires a target for null, a non-object, a non-string repo, a missing repo, and an unknown kind", () => {
37+
for (const bad of [null, 42, { kind: "existing" }, { kind: "existing", repo: 5 }, { kind: "banana" }, {}]) {
38+
const r = validateIdeaSubmission(rawIdea(bad));
39+
assert.equal(r.ok, false);
40+
if (!r.ok) assert.ok(r.errors.includes("target_repo_required"));
41+
}
42+
});

test/unit/idea-intake-bridge.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,23 @@ describe("validateIdeaSubmission", () => {
8282
}
8383
});
8484

85+
it("accepts the canonical { kind: 'existing', repo } object it returns — round-trips — and still validates the slug (#9609)", () => {
86+
const r = validateIdeaSubmission(rawIdea({ targetRepo: { kind: "existing", repo: "acme/widgets" } }));
87+
expect(r.ok).toBe(true);
88+
if (r.ok) expect(r.idea.targetRepo).toEqual({ kind: "existing", repo: "acme/widgets" });
89+
// A malformed slug inside the object shape is rejected the same as the bare-string wire form.
90+
expect(validateIdeaSubmission(rawIdea({ targetRepo: { kind: "existing", repo: "no-slash" } })).ok).toBe(false);
91+
expect(validateIdeaSubmission(rawIdea({ targetRepo: { kind: "existing", repo: "a/b/c" } })).ok).toBe(false);
92+
});
93+
94+
it("still requires a target for null, a non-object, a non-string repo, and an unknown kind (#9609)", () => {
95+
for (const bad of [null, 42, { kind: "existing", repo: 5 }, { kind: "banana" }]) {
96+
const r = validateIdeaSubmission(rawIdea({ targetRepo: bad as unknown as string }));
97+
expect(r.ok).toBe(false);
98+
if (!r.ok) expect(r.errors).toContain("target_repo_required");
99+
}
100+
});
101+
85102
it("flags invalid constraints (non-array, non-string element, over-length entry)", () => {
86103
expect((validateIdeaSubmission(rawIdea({ constraints: "x" as unknown as string[] }))).ok).toBe(false);
87104
expect((validateIdeaSubmission(rawIdea({ constraints: [1] as unknown as string[] }))).ok).toBe(false);

0 commit comments

Comments
 (0)