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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5748-the-waiting-on-you-card-links-each-ask-t → main 5 commits fork 4 threads · 4 unresolved
4 files
changed +61
−4
docs/MCP.md +2 −1
| @@ -283,7 +283,8 @@ answers | ||
| 283 | 283 | `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`, |
| 284 | 284 | `decision`, `wave`, `map`, `feed`, `pull`. The Plan card (`map`) shows one row per pillar with |
| 285 | 285 | 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). | |
| 287 | 288 | The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a |
| 288 | 289 | linked production row when the app has an http(s) URL; the `new_app` card links its |
| 289 | 290 | 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( | ||
| 1019 | 1019 | link: string; |
| 1020 | 1020 | /** The pull's or issue's number, for a row of one that is about one. */ |
| 1021 | 1021 | number?: number; |
| 1022 | /** Whether `number` is a pull's or an issue's. */ | |
| 1023 | numberOf?: "pull" | "issue"; | |
| 1022 | 1024 | /** For a wave proposal: what it says, as plain text (the reader escapes it). */ |
| 1023 | 1025 | proposal?: { id: string; title: string; why: string; issues: string[] }; |
| 1024 | 1026 | items: string[]; |
| @@ -1075,12 +1077,14 @@ export function askLinesFor( | ||
| 1075 | 1077 | ...about, |
| 1076 | 1078 | line, |
| 1077 | 1079 | number: only.issue!.number, |
| 1080 | numberOf: "issue", | |
| 1078 | 1081 | link: `${base}/issues/${only.issue!.number}`, |
| 1079 | 1082 | }; |
| 1080 | 1083 | return { |
| 1081 | 1084 | ...about, |
| 1082 | 1085 | line, |
| 1083 | 1086 | number: only.pull!.number, |
| 1087 | numberOf: "pull", | |
| 1084 | 1088 | link: `${base}/pulls/${only.pull!.number}`, |
| 1085 | 1089 | }; |
| 1086 | 1090 | }); |
src/web/studio/cards.ts +13 −3
| @@ -66,12 +66,16 @@ const obj = (v: unknown): Obj => | ||
| 66 | 66 | const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); |
| 67 | 67 | const text = (v: unknown): string => |
| 68 | 68 | typeof v === "string" ? v : typeof v === "number" ? String(v) : ""; |
| 69 | const web = (u: string): boolean => /^https?:\/\//.test(u); | |
| 70 | 69 | const LIMIT = 12; |
| 71 | 70 | const FEED_LIMIT = 10; |
| 72 | 71 | /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */ |
| 73 | 72 | 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. |
||
| 74 | 73 | |
| 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 | ||
| 75 | 79 | /** The card for a tool's answer, or nothing when the answer has no card. */ |
| 76 | 80 | export function cardFor( |
| 77 | 81 | tool: string, |
| @@ -128,7 +132,7 @@ export function cardFor( | ||
| 128 | 132 | rows.push({ |
| 129 | 133 | label: "Branches", |
| 130 | 134 | value: `${text(p["head"])} → ${text(p["base"])}`, |
| 131 | ...(web(origin) && /^[^/]+\/[^/]+$/.test(slug) && number !== "" | |
| 135 | ...(web(origin) && slugOf(slug) && number !== "" | |
| 132 | 136 | ? { link: `${origin}/${slug}/pulls/${number}` } |
| 133 | 137 | : {}), |
| 134 | 138 | }); |
| @@ -177,9 +181,15 @@ export function cardFor( | ||
| 177 | 181 | status = asks.length === 0 ? "nothing waits" : `${asks.length} waiting`; |
| 178 | 182 | for (const a of asks.slice(0, LIMIT)) { |
| 179 | 183 | 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" : ""; | |
| 180 | 187 | rows.push({ |
| 181 | 188 | label: text(a["line"]), |
| 182 | 189 | 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 | : {}), | |
| 183 | 193 | }); |
| 184 | 194 | } |
| 185 | 195 | const first = asks[0]; |
| @@ -221,7 +231,7 @@ export function cardFor( | ||
| 221 | 231 | rows.push({ |
| 222 | 232 | label: `#${number}`, |
| 223 | 233 | value: text(r["title"]), |
| 224 | ...(/^https?:\/\//.test(origin) && number !== "" ? { link: base + number } : {}), | |
| 234 | ...(web(origin) && number !== "" ? { link: base + number } : {}), | |
| 225 | 235 | }); |
| 226 | 236 | } |
| 227 | 237 | } else if (kind === "feed") { |
test/mcp.test.ts +42
| @@ -1267,6 +1267,48 @@ describe("the forge over MCP", () => { | ||
| 1267 | 1267 | expect(hostile.rows.some((r) => r.label === "Preview")).toBe(false); |
| 1268 | 1268 | }); |
| 1269 | 1269 | |
outdated
These rows are written by hand and already carry |
||
| 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 | ||
| 1270 | 1312 | test("the feed card draws one linked row per item, ten at most, escaped as text", async () => { |
| 1271 | 1313 | const { cardFor } = await import("../src/web/studio/cards.ts"); |
| 1272 | 1314 | const items = Array.from({ length: 12 }, (_, n) => ({ |
new above
offline
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 throughaskLinesForandasks.