Public: anyone can read this; the crew can change it.

The Waiting-on-you card links each ask to its pull or issue, so the owner can look deeper without losing the place #5750

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5748-the-waiting-on-you-card-links-each-ask-t → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/5748-the-waiting-on-you-card-links-each-ask-t was merged into main as 81b4324.

Review

Sign in to review this change.
4 files changed +61 −4
docs/MCP.md +2 −1
@@ -283,7 +283,8 @@ answers
283283 `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`,
284284 `decision`, `wave`, `map`, `feed`, `pull`. The Plan card (`map`) shows one row per pillar with
285285 its ready, working and waiting counts, and the first five ready issues linked
286 to their pages; it has no actions.
286 to their pages; it has no actions. The decision card's rows link to the pull or issue
287 they are about (a paused repository's row has none).
287288 The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a
288289 linked production row when the app has an http(s) URL; the `new_app` card links its
289290 Production row the same way. Any other address stays plain text, never a link.
src/web/studio/asks.ts +4
@@ -1019,6 +1019,8 @@ export function askLinesFor(
10191019 link: string;
10201020 /** The pull's or issue's number, for a row of one that is about one. */
10211021 number?: number;
1022 /** Whether `number` is a pull's or an issue's. */
1023 numberOf?: "pull" | "issue";
10221024 /** For a wave proposal: what it says, as plain text (the reader escapes it). */
10231025 proposal?: { id: string; title: string; why: string; issues: string[] };
10241026 items: string[];
@@ -1075,12 +1077,14 @@ export function askLinesFor(
10751077 ...about,
10761078 line,
10771079 number: only.issue!.number,
outdated

This field (and numberOf: "pull" below) is the only thing that makes a decision row link in a real harness, and no test fails if it's removed. Cover it through askLinesFor and asks.

1080 numberOf: "issue",
10781081 link: `${base}/issues/${only.issue!.number}`,
10791082 };
10801083 return {
10811084 ...about,
10821085 line,
10831086 number: only.pull!.number,
1087 numberOf: "pull",
10841088 link: `${base}/pulls/${only.pull!.number}`,
10851089 };
10861090 });
src/web/studio/cards.ts +13 −3
@@ -66,12 +66,16 @@ const obj = (v: unknown): Obj =>
6666 const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
6767 const text = (v: unknown): string =>
6868 typeof v === "string" ? v : typeof v === "number" ? String(v) : "";
69 const web = (u: string): boolean => /^https?:\/\//.test(u);
7069 const LIMIT = 12;
7170 const FEED_LIMIT = 10;
7271 /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */
7372 const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/;
outdated

This doc comment described cardFor; the helpers inserted below separate it from the function. Put web/slugOf above it.

7473
74 /** An absolute http(s) address: the only origin a card builds links on. */
75 const web = (u: string): boolean => /^https?:\/\//.test(u);
76 /** A repository named `owner/name`. */
77 const slugOf = (s: string): boolean => /^[^/]+\/[^/]+$/.test(s);
78
7579 /** The card for a tool's answer, or nothing when the answer has no card. */
7680 export function cardFor(
7781 tool: string,
@@ -128,7 +132,7 @@ export function cardFor(
128132 rows.push({
129133 label: "Branches",
130134 value: `${text(p["head"])} → ${text(p["base"])}`,
131 ...(web(origin) && /^[^/]+\/[^/]+$/.test(slug) && number !== ""
135 ...(web(origin) && slugOf(slug) && number !== ""
132136 ? { link: `${origin}/${slug}/pulls/${number}` }
133137 : {}),
134138 });
@@ -177,9 +181,15 @@ export function cardFor(
177181 status = asks.length === 0 ? "nothing waits" : `${asks.length} waiting`;
178182 for (const a of asks.slice(0, LIMIT)) {
179183 const words = obj(a["words"]);
184 const repo = text(a["repo"]);
185 const number = Number(a["number"]);
186 const page = a["numberOf"] === "pull" ? "pulls" : a["numberOf"] === "issue" ? "issues" : "";
180187 rows.push({
181188 label: text(a["line"]),
182189 value: `${text(words["decision"])} ${list(words["options"]).map(text).join(" / ")}`.trim(),
190 ...(web(origin) && slugOf(repo) && Number.isInteger(number) && number > 0 && page !== ""
191 ? { link: `${origin}/${repo}/${page}/${number}` }
192 : {}),
183193 });
184194 }
185195 const first = asks[0];
@@ -221,7 +231,7 @@ export function cardFor(
221231 rows.push({
222232 label: `#${number}`,
223233 value: text(r["title"]),
224 ...(/^https?:\/\//.test(origin) && number !== "" ? { link: base + number } : {}),
234 ...(web(origin) && number !== "" ? { link: base + number } : {}),
225235 });
226236 }
227237 } else if (kind === "feed") {
test/mcp.test.ts +42
@@ -1267,6 +1267,48 @@ describe("the forge over MCP", () => {
12671267 expect(hostile.rows.some((r) => r.label === "Preview")).toBe(false);
12681268 });
12691269
outdated

These rows are written by hand and already carry numberOf, so the asks.ts half of the change isn't exercised. Remove both numberOf: lines in asks.ts and this still passes. Build the rows from askLinesFor (or call the asks tool) with a real pull ask and a real issue ask.

1270 test("the decision card links each ask to its pull or issue, and to nothing unsafe", async () => {
1271 const { cardFor } = await import("../src/web/studio/cards.ts");
1272 const words = { decision: "Ship it?", options: ["ship", "close"] };
1273 const rows = [
1274 { line: "a", repo: "alice/game", number: 3, numberOf: "pull", words },
1275 { line: "b", repo: "alice/game", number: 4, numberOf: "issue", words },
1276 { line: "c", repo: "alice/game", words },
1277 { line: "d", repo: "alice", number: 5, numberOf: "pull", words },
1278 { line: "e", repo: "alice/game", number: 0, numberOf: "pull", words },
1279 ];
1280 const card = cardFor("asks", { rows }, "https://forge.test")!;
1281 expect(card.rows.map((r) => r.link)).toEqual([
1282 "https://forge.test/alice/game/pulls/3",
1283 "https://forge.test/alice/game/issues/4",
1284 undefined,
1285 undefined,
1286 undefined,
1287 ]);
1288 expect(cardFor("asks", { rows }, "javascript:x")!.rows.every((r) => r.link === undefined)).toBe(true);
1289 });
1290
1291 test("the real asks result carries a link on a pull row and an issue row", async () => {
1292 await commitToBranch(app, alice, game, { branch: "ask-links", message: "x", files: [{ path: "ask.txt", content: "a\n" }] });
1293 const pull = await openPull(app, alice, game, { title: "Ask link pull", baseBranch: "main", headBranch: "ask-links" });
1294 const issue = await openIssue(app, alice, game, { title: "Ask link issue", body: "" });
1295 if (!pull.ok || !issue.ok) throw new Error("seed");
1296 const { setAssignee } = await import("../src/core/issues.ts");
1297 setAssignee(app.db, pull.value.id, alice.id);
1298 setAssignee(app.db, issue.value.id, alice.id);
1299 const { body } = await call(rw, "tools/call", { name: "asks", arguments: {} });
1300 const rows: any[] = body.result.structuredContent.card.rows;
1301 const linkOf = (label: string) => rows.find((r) => r.label.includes(label))?.link;
1302 expect(linkOf("Ask link pull")).toMatch(new RegExp(`^https?://[^/]+/alice/game/pulls/${pull.value.number}$`));
1303 expect(linkOf("Ask link issue")).toMatch(new RegExp(`^https?://[^/]+/alice/game/issues/${issue.value.number}$`));
1304 const { askLinesFor } = await import("../src/web/studio/asks.ts");
1305 const { cardFor } = await import("../src/web/studio/cards.ts");
1306 const lines = askLinesFor(app.db, alice);
1307 expect(lines.find((l) => l.id === `assigned:${pull.value.id}`)?.numberOf).toBe("pull");
1308 expect(lines.find((l) => l.id === `assigned:${issue.value.id}`)?.numberOf).toBe("issue");
1309 expect(cardFor("asks", { rows: lines }, "javascript:x")!.rows.every((r) => r.link === undefined)).toBe(true);
1310 });
1311
12701312 test("the feed card draws one linked row per item, ten at most, escaped as text", async () => {
12711313 const { cardFor } = await import("../src/web/studio/cards.ts");
12721314 const items = Array.from({ length: 12 }, (_, n) => ({

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs