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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11207-mcp-search-and-issues-draw-ui-cards-each → main 4 commits fork
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`; | ||
| 125 | 125 | | `invites` | `list`, `revoke` | Open invites; `id` revokes | 206 | |
| 126 | 126 | | `issue` | — | One issue with comments, `blockedBy`, `blocks`; non-writers' text is `untrusted` | 341 | |
| 127 | 127 | | `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 | | |
| 129 | 129 | | `iterations` | — | A repo's fork family of build pins and runs, as the Iterations page draws them | 279 | |
| 130 | 130 | | `keep_plan` | — | Keep a short plan checklist on your issue, replaced whole each time | 415 | |
| 131 | 131 | | `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`; | ||
| 189 | 189 | | `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 | |
| 190 | 190 | | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 237 | |
| 191 | 191 | | `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 | | |
| 193 | 193 | | `secrets` | — | Admin: list\|set\|remove sealed secrets | 248 | |
| 194 | 194 | | `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 | |
| 195 | 195 | | `sever` | — | Unlink a spawned forge from its parent (owner of its `forge` repo only) | 214 | |
| @@ -504,6 +504,8 @@ answers | ||
| 504 | 504 | | `ui://forge/board` | `boards` | One board's panels (person admins). | |
| 505 | 505 | | `ui://forge/forks` | `fork_tree` | The fork tree around a repository, each row with how it follows. | |
| 506 | 506 | | `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. | | |
| 507 | 509 | | `ui://forge/release` | `releases` | Kept releases, newest first, each proven, unproven or rolled back. | |
| 508 | 510 | |
| 509 | 511 | 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( | ||
| 164 | 164 | return out; |
| 165 | 165 | } |
| 166 | 166 | |
| 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 | ||
| 167 | 194 | /** What this issue waits for, and what waits for it. */ |
| 168 | 195 | export function dependenciesOf( |
| 169 | 196 | 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"; | |
| 3 | 5 | import { isCrew } from "../../../core/agents.ts"; |
| 4 | import { canWrite } from "../../../core/repos.ts"; | |
| 6 | import { canRead, canWrite, getRepoById, type Repo } from "../../../core/repos.ts"; | |
| 5 | 7 | 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"; | |
| 6 | 11 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 7 | 12 | |
| 8 | 13 | export const tool: Tool = { |
| 9 | 14 | 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", | |
| 11 | 16 | description: "A repository's issues by state, label and author, newest first, paged.", |
| 12 | 17 | inputSchema: { |
| 13 | 18 | type: "object", |
| @@ -66,7 +71,8 @@ export const tool: Tool = { | ||
| 66 | 71 | if (marking) for (const m of markReaders(db, repo, rows)) marked.set(m.id, m.untrusted); |
| 67 | 72 | const labels = labelsForIssues(db, rows.map((i) => i.id)); |
| 68 | 73 | 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({ | |
| 70 | 76 | items: rows.map((i) => ({ |
| 71 | 77 | number: i.number, |
| 72 | 78 | title: i.title, |
| @@ -81,5 +87,45 @@ export const tool: Tool = { | ||
| 81 | 87 | })), |
| 82 | 88 | next_cursor: rows.length >= limit && last !== undefined ? String(last.number) : null, |
| 83 | 89 | }); |
| 90 | return card === undefined ? answer : { ...answer, card }; | |
| 84 | 91 | }, |
| 85 | 92 | }; |
| 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"; | |
| 2 | 6 | 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"; | |
| 4 | 8 | import { good, bad, type Tool, repoArg, isAnswer } from "../shared.ts"; |
| 5 | 9 | |
| 6 | 10 | export const tool: Tool = { |
| 7 | 11 | 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", | |
| 9 | 13 | description: |
| 10 | 14 | "Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow.", |
| 11 | 15 | inputSchema: { |
| @@ -44,6 +48,39 @@ export const tool: Tool = { | ||
| 44 | 48 | ...(only ? { repo: only } : {}), |
| 45 | 49 | ...(all === true ? {} : { codeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MS }), |
| 46 | 50 | }); |
| 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; | |
| 48 | 53 | }, |
| 49 | 54 | }; |
| 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"; | ||
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 13 | |
| 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"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 44 | 44 | |
| 45 | 45 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 46 | 46 | 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"]; | |
| 48 | 48 | |
| 49 | 49 | /** The tool whose answer each card draws. */ |
| 50 | 50 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -68,6 +68,8 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 68 | 68 | scorecard: "scorecard", |
| 69 | 69 | boards: "board", |
| 70 | 70 | fork_tree: "forks", |
| 71 | search: "search", | |
| 72 | issues: "issues", | |
| 71 | 73 | }; |
| 72 | 74 | |
| 73 | 75 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| @@ -90,6 +92,8 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 90 | 92 | forks: "Forks", |
| 91 | 93 | milestones: "Milestones", |
| 92 | 94 | release: "Releases", |
| 95 | search: "Search", | |
| 96 | issues: "Issues", | |
| 93 | 97 | }; |
| 94 | 98 | |
| 95 | 99 | type Obj = Record<string, unknown>; |
| @@ -101,6 +105,8 @@ const text = (v: unknown): string => | ||
| 101 | 105 | const LIMIT = 12; |
| 102 | 106 | const FEED_LIMIT = 10; |
| 103 | 107 | const PULLS_LIMIT = 20; |
| 108 | export const SEARCH_ROWS = 20; | |
| 109 | export const ISSUES_ROWS = 20; | |
| 104 | 110 | const ASKED_WORDS = 80; |
| 105 | 111 | const ASKED_LIMIT = 20; |
| 106 | 112 | const RELEASE_ROWS = 20; |
| @@ -132,6 +138,11 @@ const checkWord = (state: string): string => | ||
| 132 | 138 | : "none"; |
| 133 | 139 | |
| 134 | 140 | /** 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 | : ""; | |
| 135 | 146 | const web = (u: string): boolean => /^https?:\/\//.test(u); |
| 136 | 147 | /** A repository named `owner/name`. */ |
| 137 | 148 | const slugOf = (s: string): boolean => /^[^/]+\/[^/]+$/.test(s); |
| @@ -320,6 +331,81 @@ export function cardFor( | ||
| 320 | 331 | if (web(preview)) rows.push({ label: "Preview", value: preview, link: preview }); |
| 321 | 332 | return { kind, title: `#${number} ${text(p["title"])}`.trim(), status, rows, actions }; |
| 322 | 333 | } |
| 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 | } | |
| 323 | 409 | if (kind === "pulls") { |
| 324 | 410 | // `pulls` answers `{ pulls: [{ number, title, check, … }] }`; the repo and |
| 325 | 411 | // 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", () => { | ||
| 1295 | 1295 | "ui://forge/flow", |
| 1296 | 1296 | "ui://forge/forks", |
| 1297 | 1297 | "ui://forge/issue", |
| 1298 | "ui://forge/issues", | |
| 1298 | 1299 | "ui://forge/map", |
| 1299 | 1300 | "ui://forge/milestones", |
| 1300 | 1301 | "ui://forge/pull", |
| @@ -1303,6 +1304,7 @@ describe("the forge over MCP", () => { | ||
| 1303 | 1304 | "ui://forge/run", |
| 1304 | 1305 | "ui://forge/runs", |
| 1305 | 1306 | "ui://forge/scorecard", |
| 1307 | "ui://forge/search", | |
| 1306 | 1308 | "ui://forge/wave", |
| 1307 | 1309 | ]); |
| 1308 | 1310 | const read = await call(rw, "resources/read", { uri: "ui://forge/app" }); |