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

MCP `search` and `issues` draw ui:// cards: each hit or issue one row, linked to its page #11215

Merged opened this

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

Merged

scout/forge:scout/11207-mcp-search-and-issues-draw-ui-cards-each → main 4 commits fork

  • Merged. scout/11207-mcp-search-and-issues-draw-ui-cards-each was merged into main as 44d4ed3.

Review

Sign in to review this change.
8 files changed +374 −13
docs/MCP.md +4 −2
@@ -125,7 +125,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
125125 | `invites` | `list`, `revoke` | Open invites; `id` revokes | 206 |
126126 | `issue` | — | One issue with comments, `blockedBy`, `blocks`; non-writers' text is `untrusted` | 341 |
127127 | `issue_teams` | — | Name up to two crew teams to review | 267 |
128 | `issues` | — | A repository's issues by state, label and author, newest first, paged | 422 |
128 | `issues` | — | A repository's issues by state, label and author, newest first, paged | 473 |
129129 | `iterations` | — | A repo's fork family of build pins and runs, as the Iterations page draws them | 279 |
130130 | `keep_plan` | — | Keep a short plan checklist on your issue, replaced whole each time | 415 |
131131 | `knobs` | — | The crew's five knobs and what each does now | 307 |
@@ -189,7 +189,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
189189 | `say` | `list`, `new`, `rename`, `archive`, `restore`, `build`, `build_state`, `build_diff`, `build_restore`, `build_publish` | Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action) | 585 |
190190 | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 237 |
191191 | `seal` | `private` | Seal or unseal an issue or pull: a sealed one is seen only by writers and crew | 297 |
192 | `search` | `all`, `issues`, `repos`, `code` | Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow | 378 |
192 | `search` | `all`, `issues`, `repos`, `code` | Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow | 429 |
193193 | `secrets` | — | Admin: list\|set\|remove sealed secrets | 248 |
194194 | `sessions` | `list`, `end`, `end_idle`, `signins`, `shells` | Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner; action end + id signs a browser out | 385 |
195195 | `sever` | — | Unlink a spawned forge from its parent (owner of its `forge` repo only) | 214 |
@@ -504,6 +504,8 @@ answers
504504 | `ui://forge/board` | `boards` | One board's panels (person admins). |
505505 | `ui://forge/forks` | `fork_tree` | The fork tree around a repository, each row with how it follows. |
506506 | `ui://forge/milestones` | `milestones` | The milestones and how far each is. |
507 | `ui://forge/search` | `search` | The query on top, one row per hit (repository, issue, pull, code) with its kind, title and link. |
508 | `ui://forge/issues` | `issues` | A repository's issues, one row each: state, pillar, what it waits on, `untrusted` when an outsider wrote it. |
507509 | `ui://forge/release` | `releases` | Kept releases, newest first, each proven, unproven or rolled back. |
508510
509511 The Runs card (`runs`) is one row per run, newest first: glyph, kind, the issue or pull and its title, then the state as a word with how long it ran (or has been running), linked to the run page; failed runs lead the status line. The Plan card (`map`) shows one row per pillar with
src/core/dependencies.ts +27
@@ -164,6 +164,33 @@ export function blockerNumbersFor(
164164 return out;
165165 }
166166
167 /**
168 * The ids each issue waits for, for a batch of issues in one query per chunk.
169 * By id, not number: a blocker may live in another repository.
170 */
171 export function blockerIdsFor(
172 db: Database,
173 issueIds: readonly string[],
174 ): Map<string, string[]> {
175 const out = new Map<string, string[]>();
176 const CHUNK = 500;
177 for (let i = 0; i < issueIds.length; i += CHUNK) {
178 const chunk = issueIds.slice(i, i + CHUNK);
179 const rows = db
180 .query<{ issue_id: string; blocked_by_id: string }, string[]>(
181 `SELECT issue_id, blocked_by_id FROM issue_dependency
182 WHERE issue_id IN (${chunk.map((_, n) => `?${n + 1}`).join(",")})`,
183 )
184 .all(...chunk);
185 for (const row of rows) {
186 const list = out.get(row.issue_id) ?? [];
187 list.push(row.blocked_by_id);
188 out.set(row.issue_id, list);
189 }
190 }
191 return out;
192 }
193
167194 /** What this issue waits for, and what waits for it. */
168195 export function dependenciesOf(
169196 db: Database,
src/http/mcp/tools/issues.ts +51 −5
@@ -1,13 +1,18 @@
1 import { sealedScope } from "../../../core/sealed.ts";
2 import { labelsForIssues, listIssues, LIST_LIMIT, LIST_LIMIT_MAX } from "../../../core/issues.ts";
1 import { mayView, sealedScope } from "../../../core/sealed.ts";
2 import { blockerIdsFor } from "../../../core/dependencies.ts";
3 import { pillarsOf, servedBy } from "../../../core/pillars.ts";
4 import { getIssuesByIds, labelsForIssues, type Issue, listIssues, LIST_LIMIT, LIST_LIMIT_MAX } from "../../../core/issues.ts";
35 import { isCrew } from "../../../core/agents.ts";
4 import { canWrite } from "../../../core/repos.ts";
6 import { canRead, canWrite, getRepoById, type Repo } from "../../../core/repos.ts";
57 import { markReaders } from "../../../crew/direct.ts";
8 import type { Database } from "bun:sqlite";
9 import type { User } from "../../../core/users.ts";
10 import { ISSUES_ROWS } from "../../../web/studio/cards.ts";
611 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
712
813 export const tool: Tool = {
914 name: "issues",
10 doc: "`{ repo, state?, label?, author?, limit?, cursor? }`: a repository's issues, newest first, as `{ items, next_cursor }` like `GET …/issues?fields=brief`: `number`, `title`, `state`, `labels`, `assignee`, `author`, `authorIsAgent`, `createdAt`, `updatedAt`, and `untrusted: true` on text from someone who cannot write; `state` open (default), closed or all, `label` a name, `author` a username, `limit` 1-200 (default 50), `cursor` the last `next_cursor`; a sealed issue you cannot see is left out, a repository you cannot read is not found",
15 doc: "`{ repo, state?, label?, author?, limit?, cursor? }`: a repository's issues, newest first, as `{ items, next_cursor }` like `GET …/issues?fields=brief`: `number`, `title`, `state`, `labels`, `assignee`, `author`, `authorIsAgent`, `createdAt`, `updatedAt`, and `untrusted: true` on text from someone who cannot write; `state` open (default), closed or all, `label` a name, `author` a username, `limit` 1-200 (default 50), `cursor` the last `next_cursor`; a sealed issue you cannot see is left out, a repository you cannot read is not found; draws the `ui://forge/issues` card: state, pillar and what each waits on",
1116 description: "A repository's issues by state, label and author, newest first, paged.",
1217 inputSchema: {
1318 type: "object",
@@ -66,7 +71,8 @@ export const tool: Tool = {
6671 if (marking) for (const m of markReaders(db, repo, rows)) marked.set(m.id, m.untrusted);
6772 const labels = labelsForIssues(db, rows.map((i) => i.id));
6873 const last = rows[rows.length - 1];
69 return good({
74 const card = ctx.auth.through === undefined ? cardExtras(db, repo, user, rows) : undefined;
75 const answer = good({
7076 items: rows.map((i) => ({
7177 number: i.number,
7278 title: i.title,
@@ -81,5 +87,45 @@ export const tool: Tool = {
8187 })),
8288 next_cursor: rows.length >= limit && last !== undefined ? String(last.number) : null,
8389 });
90 return card === undefined ? answer : { ...answer, card };
8491 },
8592 };
93
94 /**
95 * The card's extras, by issue number: the pillar each issue serves and what it waits on. Blockers
96 * are read by id in one query, so one in another repository is named with its repo, and one you
97 * cannot see (sealed, or in a repository you cannot read) is left out.
98 */
99 function cardExtras(
100 db: Database,
101 repo: Repo,
102 user: User,
103 rows: readonly Issue[],
104 ): Record<string, unknown> {
105 const shown = rows.slice(0, ISSUES_ROWS);
106 const served = servedBy(db, pillarsOf(db, repo));
107 const blockerIds = blockerIdsFor(db, shown.map((i) => i.id));
108 const blockers = getIssuesByIds(db, [...blockerIds.values()].flat());
109 const repos = new Map<string, Repo | undefined>([[repo.id, repo]]);
110 const repoOf = (id: string): Repo | undefined => {
111 if (!repos.has(id)) repos.set(id, getRepoById(db, id));
112 return repos.get(id);
113 };
114 const pillars: Record<string, string> = {};
115 const waits: Record<string, string[]> = {};
116 for (const i of shown) {
117 const p = served.get(i.id);
118 if (p !== undefined) pillars[String(i.number)] = p;
119 const on: string[] = [];
120 for (const id of blockerIds.get(i.id) ?? []) {
121 const b = blockers.get(id);
122 const home = b === undefined ? undefined : repoOf(b.repoId);
123 if (b === undefined || home === undefined) continue;
124 if (home.id !== repo.id && !canRead(db, home, user)) continue;
125 if (!mayView(db, home, b, user)) continue;
126 on.push(home.id === repo.id ? String(b.number) : `${home.ownerName}/${home.name}#${b.number}`);
127 }
128 if (on.length > 0) waits[String(i.number)] = on;
129 }
130 return { repo: `${repo.ownerName}/${repo.name}`, pillars, waits };
131 }
src/http/mcp/tools/search.ts +41 −4
@@ -1,11 +1,15 @@
1 import type { Repo } from "../../../core/repos.ts";
1 import type { Database } from "bun:sqlite";
2 import { getRepo, type Repo } from "../../../core/repos.ts";
3 import { getIssue, type Issue } from "../../../core/issues.ts";
4 import { markReaders } from "../../../crew/direct.ts";
5 import { SEARCH_ROWS as CARD_HITS } from "../../../web/studio/cards.ts";
26 import { search, searchRefusal } from "../../../actions/search.ts";
3 import { CODE_BUDGET_MS, searchKind, searchNote } from "../../../core/search.ts";
7 import { CODE_BUDGET_MS, searchKind, searchNote, type SearchResults } from "../../../core/search.ts";
48 import { good, bad, type Tool, repoArg, isAnswer } from "../shared.ts";
59
610 export const tool: Tool = {
711 name: "search",
8 doc: "`{ q, kind?, limit?, repo?, all? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one; `moreCode` says the code grep stopped at its budget, `all` greps the rest",
12 doc: "`{ q, kind?, limit?, repo?, all? }`: issues, pulls, repositories and code you can read, the same results as /search, drawn as the `ui://forge/search` card; `repo` keeps to one; `moreCode` says the code grep stopped at its budget, `all` greps the rest",
913 description:
1014 "Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow.",
1115 inputSchema: {
@@ -44,6 +48,39 @@ export const tool: Tool = {
4448 ...(only ? { repo: only } : {}),
4549 ...(all === true ? {} : { codeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MS }),
4650 });
47 return good(note === undefined ? found : { ...found, note });
51 const answer = good(note === undefined ? found : { ...found, note });
52 return ctx.auth.through === undefined ? { ...answer, card: { q, ...cardMarks(ctx.app.db, found) } } : answer;
4853 },
4954 };
55
56 /**
57 * What the card needs beyond the hits: which local issue and pull hits an outsider wrote
58 * (`untrusted`, as "owner/name#number", by the same `markReaders` rule `issues` uses) and the
59 * default branch of each repository with a code hit, so a code row links to its file.
60 */
61 function cardMarks(db: Database, found: SearchResults): Record<string, unknown> {
62 const repos = new Map<string, Repo | undefined>();
63 const repoOf = (owner: string, name: string): Repo | undefined => {
64 const key = `${owner}/${name}`;
65 if (!repos.has(key)) repos.set(key, getRepo(db, owner, name));
66 return repos.get(key);
67 };
68 const byRepo = new Map<Repo, Issue[]>();
69 for (const h of found.issues.slice(0, CARD_HITS)) {
70 if (h.source !== "local") continue;
71 const repo = repoOf(h.repo.owner, h.repo.name);
72 const issue = repo === undefined ? undefined : getIssue(db, repo.id, h.number);
73 if (repo === undefined || issue === undefined) continue;
74 byRepo.set(repo, [...(byRepo.get(repo) ?? []), issue]);
75 }
76 const untrusted: string[] = [];
77 for (const [repo, issues] of byRepo)
78 for (const m of markReaders(db, repo, issues))
79 if (m.untrusted !== undefined) untrusted.push(`${repo.ownerName}/${repo.name}#${m.number}`);
80 const branches: Record<string, string> = {};
81 for (const c of found.code.slice(0, CARD_HITS)) {
82 const repo = repoOf(c.repo.owner, c.repo.name);
83 if (repo !== undefined) branches[`${c.repo.owner}/${c.repo.name}`] = repo.defaultBranch;
84 }
85 return { untrusted, branches };
86 }
src/web/studio/cards.ts +88 −2
@@ -11,7 +11,7 @@ import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1313
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release";
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "search" | "issues";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4444
4545 export const CARD_MIME = "text/html;profile=mcp-app";
4646 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release"];
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "search", "issues"];
4848
4949 /** The tool whose answer each card draws. */
5050 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -68,6 +68,8 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
6868 scorecard: "scorecard",
6969 boards: "board",
7070 fork_tree: "forks",
71 search: "search",
72 issues: "issues",
7173 };
7274
7375 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
@@ -90,6 +92,8 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
9092 forks: "Forks",
9193 milestones: "Milestones",
9294 release: "Releases",
95 search: "Search",
96 issues: "Issues",
9397 };
9498
9599 type Obj = Record<string, unknown>;
@@ -101,6 +105,8 @@ const text = (v: unknown): string =>
101105 const LIMIT = 12;
102106 const FEED_LIMIT = 10;
103107 const PULLS_LIMIT = 20;
108 export const SEARCH_ROWS = 20;
109 export const ISSUES_ROWS = 20;
104110 const ASKED_WORDS = 80;
105111 const ASKED_LIMIT = 20;
106112 const RELEASE_ROWS = 20;
@@ -132,6 +138,11 @@ const checkWord = (state: string): string =>
132138 : "none";
133139
134140 /** An absolute http(s) address: the only origin a card builds links on. */
141 /** `/blob/<ref>/<path>` with each segment escaped, or nothing when there is no branch or path. */
142 const fileOf = (branch: unknown, path: string): string =>
143 typeof branch === "string" && branch !== "" && path !== "" && !path.split("/").includes("..")
144 ? `/blob/${encodeURIComponent(branch)}/${path.split("/").map(encodeURIComponent).join("/")}`
145 : "";
135146 const web = (u: string): boolean => /^https?:\/\//.test(u);
136147 /** A repository named `owner/name`. */
137148 const slugOf = (s: string): boolean => /^[^/]+\/[^/]+$/.test(s);
@@ -320,6 +331,81 @@ export function cardFor(
320331 if (web(preview)) rows.push({ label: "Preview", value: preview, link: preview });
321332 return { kind, title: `#${number} ${text(p["title"])}`.trim(), status, rows, actions };
322333 }
334 if (kind === "search") {
335 // `search` answers `{ repos, issues, code, … }`; the query comes in `extra`. One row per hit:
336 // its kind and title as the label, where it stands as the value, linked to its page.
337 const v = obj(value);
338 const q = text(extra["q"]);
339 const untrusted = new Set(list(extra["untrusted"]).map(text));
340 const branches = obj(extra["branches"]);
341 const seg = (x: string): boolean => /^[^/\s]+$/.test(x);
342 const page = (owner: string, name: string, rest = ""): { link?: string } =>
343 web(origin) && seg(owner) && seg(name) ? { link: `${origin}/${owner}/${name}${rest}` } : {};
344 const hits: CardRow[] = [];
345 for (const r of list(v["repos"]).map(obj)) {
346 const local = r["source"] === "local";
347 const label = local ? `repo ${text(r["owner"])}/${text(r["name"])}` : `repo ${text(r["fullName"])}`;
348 const ext = text(r["htmlUrl"]);
349 hits.push({
350 label,
351 value: text(r["description"]) || `${text(r["openIssues"])} open issues`,
352 ...(local ? page(text(r["owner"]), text(r["name"])) : web(ext) ? { link: ext } : {}),
353 });
354 }
355 for (const i of list(v["issues"]).map(obj)) {
356 const n = text(i["number"]);
357 const pull = i["kind"] === "pull";
358 const local = i["source"] === "local";
359 const at = obj(i["repo"]);
360 const ext = text(i["htmlUrl"]);
361 hits.push({
362 label: `${pull ? "pull" : "issue"} #${n} ${text(i["title"])}`.trim(),
363 value: [text(i["state"]), ...(local && untrusted.has(`${text(at["owner"])}/${text(at["name"])}#${n}`) ? ["untrusted"] : [])].join(" · "),
364 ...(local && /^[1-9]\d*$/.test(n)
365 ? page(text(at["owner"]), text(at["name"]), `/${pull ? "pulls" : "issues"}/${n}`)
366 : web(ext) ? { link: ext } : {}),
367 });
368 }
369 for (const c of list(v["code"]).map(obj)) {
370 const at = obj(c["repo"]);
371 hits.push({
372 label: `code ${text(at["owner"])}/${text(at["name"])}:${text(c["path"])}:${text(c["line"])}`,
373 value: text(c["text"]).trim().slice(0, SAYS_WORDS),
374 // The file itself, at the repository's default branch, when the tool said which it is.
375 ...page(text(at["owner"]), text(at["name"]), fileOf(branches[`${text(at["owner"])}/${text(at["name"])}`], text(c["path"]))),
376 });
377 }
378 rows.push(...hits.slice(0, SEARCH_ROWS));
379 status = `${hits.length} hit${hits.length === 1 ? "" : "s"}${hits.length > SEARCH_ROWS ? `, first ${SEARCH_ROWS}` : ""}`;
380 return { kind, title: q === "" ? CARD_TITLE[kind] : `Search: ${q}`, status, rows, actions };
381 }
382 if (kind === "issues") {
383 // `issues` answers `{ items, next_cursor }`; the repo, the pillar of each issue and what it
384 // waits on (both by number) come in `extra`. A sealed blocker is left out before it gets here.
385 const items = list(obj(value)["items"]).map(obj);
386 const slug = text(extra["repo"]);
387 const pillars = obj(extra["pillars"]);
388 const waits = obj(extra["waits"]);
389 for (const i of items.slice(0, ISSUES_ROWS)) {
390 const n = text(i["number"]);
391 const path = `/${slug}/issues/${n}`;
392 const parts = [text(i["state"])];
393 const pillar = text(pillars[n]);
394 if (pillar !== "") parts.push(pillar);
395 // A blocker here is its number; one in another repository is `owner/name#number`.
396 const on = list(waits[n]).map(text).filter((x) => /^(?:[^/\s#]+\/[^/\s#]+#)?[1-9]\d*$/.test(x)).slice(0, 5);
397 if (on.length > 0) parts.push(`waits on ${on.map((x) => (x.includes("#") ? x : `#${x}`)).join(" ")}`);
398 if (i["untrusted"] === true) parts.push("untrusted");
399 rows.push({
400 label: `#${n} ${text(i["title"])}`.trim(),
401 value: parts.join(" · "),
402 ...(web(origin) && slugOf(slug) && /^[1-9]\d*$/.test(n) && here(origin, path) ? { link: origin + path } : {}),
403 });
404 }
405 const more = text(obj(value)["next_cursor"]) !== "" ? "+" : "";
406 status = `${items.length}${more} issue${items.length === 1 && more === "" ? "" : "s"}`;
407 return { kind, title: CARD_TITLE[kind], status, rows, actions };
408 }
323409 if (kind === "pulls") {
324410 // `pulls` answers `{ pulls: [{ number, title, check, … }] }`; the repo and
325411 // the state asked for come in `extra`.
test/mcp-issues-card-tool.test.ts added +112
@@ -0,0 +1,112 @@
1 /** The `issues` and `search` tools hand the card its extras from real data: waits (by id, cross-repo and sealed handled), pillar, untrusted. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { addDependency } from "../src/core/dependencies.ts";
9 import { createIssue, getIssue } from "../src/core/issues.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { cardFor } from "../src/web/studio/cards.ts";
14
15 describe("card extras from real data", () => {
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let aliceTok: string;
20 let bobTok: string;
21 let id = 1;
22
23 const call = async (token: string, name: string, args: unknown) => {
24 const res = await fetch(`${servers.url}/mcp`, {
25 method: "POST",
26 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
27 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }),
28 });
29 const body: any = await res.json();
30 return body.result.structuredContent?.card as { title: string; status: string; rows: { label: string; value: string; link?: string }[] };
31 };
32
33 beforeAll(async () => {
34 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-card-tool-"));
35 app = createApp({ dataDir, port: 0 });
36 servers = await serve(app);
37 const mk = async (username: string, first = false) => {
38 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
39 if (!u.ok) throw new Error(u.error);
40 return u.value;
41 };
42 const alice = await mk("alice", true);
43 const bob = await mk("bob");
44 aliceTok = createToken(app.db, alice.id, "ro", "ro").token;
45 bobTok = createToken(app.db, bob.id, "ro", "ro").token;
46 const r = await createRepo(app, alice, { name: "game", visibility: "public" });
47 const other = await createRepo(app, alice, { name: "elsewhere", visibility: "public" });
48 const vault = await createRepo(app, alice, { name: "vault", visibility: "private" });
49 if (!r.ok || !other.ok || !vault.ok) throw new Error("repo");
50 const mkIssue = (repoId: string, authorId: string, title: string, sealed = false) => {
51 const made = createIssue(app.db, { repoId, authorId, title, sealed });
52 if (!made.ok) throw new Error(made.error);
53 return made.value;
54 };
55 const umbrella = mkIssue(r.value.id, alice.id, "P1 Speed: go fast");
56 const work = mkIssue(r.value.id, alice.id, "Work item");
57 const open = mkIssue(r.value.id, alice.id, "Plain blocker");
58 const sealedBlocker = mkIssue(r.value.id, alice.id, "Embargoed blocker", true);
59 mkIssue(r.value.id, bob.id, "Zebra stripes from outside");
60 const foreign = mkIssue(other.value.id, alice.id, "Foreign blocker");
61 const hidden = mkIssue(vault.value.id, alice.id, "Vault blocker");
62 for (const [a, b] of [[umbrella, work], [work, open], [work, sealedBlocker]] as const) addDependency(app.db, getIssue(app.db, r.value.id, a.number)!, getIssue(app.db, r.value.id, b.number)!, alice.id);
63 // addDependency refuses another repository's blocker, but a row can still name one: the card must not read it as a number here.
64 for (const f of [foreign, hidden])
65 app.db.query("INSERT INTO issue_dependency (issue_id, blocked_by_id, actor_id) VALUES (?1, ?2, ?3)").run(work.id, f.id, alice.id);
66 }, 30_000);
67
68 afterAll(async () => {
69 servers.stop();
70 app.close();
71 await rm(dataDir, { recursive: true, force: true });
72 });
73
74 const rowOf = (card: { rows: { label: string; value: string }[] }, title: string) => card.rows.find((r) => r.label.includes(title))!;
75
76 test("the owner sees the pillar, the sealed blocker, the cross-repo one named with its repo, and the private one", async () => {
77 const card = await call(aliceTok, "issues", { repo: "alice/game" });
78 const v = rowOf(card, "Work item").value;
79 expect(v).toContain("P1 Speed");
80 expect(v).toMatch(/waits on #\d+ #\d+ alice\/elsewhere#1 alice\/vault#1/);
81 });
82
83 test("another reader sees neither the sealed blocker nor the private repository's, and the foreign one keeps its repo", async () => {
84 const card = await call(bobTok, "issues", { repo: "alice/game" });
85 const v = rowOf(card, "Work item").value;
86 expect(v).toContain("waits on #3 alice/elsewhere#1");
87 expect(v).not.toContain("#4");
88 expect(v).not.toContain("vault");
89 });
90
91 test("a sealed issue is left out of the rows", async () => {
92 const card = await call(bobTok, "issues", { repo: "alice/game" });
93 expect(card.rows.some((r) => r.label.includes("Embargoed"))).toBe(false);
94 });
95
96 test("search marks an outsider's issue untrusted and not a writer's", async () => {
97 const card = await call(aliceTok, "search", { q: "Zebra", kind: "issues" });
98 expect(card.rows[0]!.value).toBe("open · untrusted");
99 const mine = await call(aliceTok, "search", { q: "Plain blocker", kind: "issues" });
100 expect(mine.rows[0]!.value).toBe("open");
101 });
102
103 test("a code hit links to the file at the default branch, each segment escaped", () => {
104 const card = cardFor(
105 "search",
106 { code: [{ repo: { owner: "a", name: "b" }, path: "src/a b/x.ts", line: 1, text: "t" }] },
107 "https://forge.test",
108 { q: "t", branches: { "a/b": "main" } },
109 )!;
110 expect(card.rows[0]!.link).toBe("https://forge.test/a/b/blob/main/src/a%20b/x.ts");
111 });
112 });
test/mcp-search-issues-card.test.ts added +49
@@ -0,0 +1,49 @@
1 /** MCP `search` and `issues` draw ui:// cards: one row per hit or issue, linked, escaped by the shell, marks kept. */
2 import { describe, expect, test } from "bun:test";
3 import { CARD_KINDS, CARD_OF_TOOL, cardFor } from "../src/web/studio/cards.ts";
4
5 const origin = "https://forge.test";
6
7 describe("search card", () => {
8 test("is registered", () => {
9 expect(CARD_OF_TOOL["search"]).toBe("search");
10 expect(CARD_KINDS).toContain("search");
11 });
12 test("one linked row per hit, the query on top", () => {
13 const card = cardFor("search", {
14 repos: [{ source: "local", owner: "a", name: "b", description: "d" }],
15 issues: [
16 { source: "local", repo: { owner: "a", name: "b" }, number: 3, kind: "issue", title: "<b>x</b>", state: "open" },
17 { source: "local", repo: { owner: "a", name: "b" }, number: 4, kind: "pull", title: "p", state: "closed" },
18 { source: "origin", fullName: "o/r", number: 1, kind: "issue", title: "far", state: "open", htmlUrl: "javascript:alert(1)" },
19 ],
20 code: [{ repo: { owner: "a", name: "b" }, path: "src/x.ts", line: 9, text: " hit " }],
21 }, origin, { q: "needle" })!;
22 expect(card.title).toBe("Search: needle");
23 expect(card.status).toBe("5 hits");
24 expect(card.rows.map((r) => r.link)).toEqual([
25 "https://forge.test/a/b", "https://forge.test/a/b/issues/3", "https://forge.test/a/b/pulls/4", undefined, "https://forge.test/a/b",
26 ]);
27 expect(card.rows[1]!.label).toBe("issue #3 <b>x</b>");
28 expect(card.rows[4]!.value).toBe("hit");
29 });
30 });
31
32 describe("issues card", () => {
33 test("is registered", () => {
34 expect(CARD_OF_TOOL["issues"]).toBe("issues");
35 expect(CARD_KINDS).toContain("issues");
36 });
37 test("state, pillar, waits on, untrusted, linked", () => {
38 const card = cardFor("issues", {
39 items: [
40 { number: 5, title: "t", state: "open", untrusted: true },
41 { number: 6, title: "u", state: "closed" },
42 ],
43 next_cursor: "6",
44 }, origin, { repo: "a/b", pillars: { "5": "P1" }, waits: { "5": [2, 3] } })!;
45 expect(card.rows[0]).toEqual({ label: "#5 t", value: "open · P1 · waits on #2 #3 · untrusted", link: "https://forge.test/a/b/issues/5" });
46 expect(card.rows[1]!.value).toBe("closed");
47 expect(card.status).toBe("2+ issues");
48 });
49 });
test/mcp.test.ts +2
@@ -1295,6 +1295,7 @@ describe("the forge over MCP", () => {
12951295 "ui://forge/flow",
12961296 "ui://forge/forks",
12971297 "ui://forge/issue",
1298 "ui://forge/issues",
12981299 "ui://forge/map",
12991300 "ui://forge/milestones",
13001301 "ui://forge/pull",
@@ -1303,6 +1304,7 @@ describe("the forge over MCP", () => {
13031304 "ui://forge/run",
13041305 "ui://forge/runs",
13051306 "ui://forge/scorecard",
1307 "ui://forge/search",
13061308 "ui://forge/wave",
13071309 ]);
13081310 const read = await call(rw, "resources/read", { uri: "ui://forge/app" });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs