Public: anyone can read this; the crew can change it.
A search hit in an archived repository wears an `Archived` badge, on the page and in MCP `search` #6114
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6084-a-search-hit-in-an-archived-repository-w → main 4 commits fork 2 threads · 2 unresolved
5 files
changed +92
−5
src/core/search.ts +6
| @@ -64,6 +64,8 @@ export type RepoHit = | ||
| 64 | 64 | readonly name: string; |
| 65 | 65 | readonly description: string; |
| 66 | 66 | readonly visibility: "public" | "private"; |
| 67 | /** Put away by an admin: still found, but not live. */ | |
| 68 | readonly archived: boolean; | |
| 67 | 69 | readonly openIssues: number; |
| 68 | 70 | readonly openPulls: number; |
| 69 | 71 | } |
| @@ -108,6 +110,8 @@ export type IssueHit = | ||
| 108 | 110 | |
| 109 | 111 | export interface CodeHit { |
| 110 | 112 | readonly repo: { owner: string; name: string }; |
| 113 | /** The repository is archived. */ | |
| 114 | readonly archived: boolean; | |
| 111 | 115 | readonly path: string; |
| 112 | 116 | readonly line: number; |
| 113 | 117 | readonly text: string; |
| @@ -177,6 +181,7 @@ function toRepoHit(repo: Repo): RepoHit { | ||
| 177 | 181 | name: repo.name, |
| 178 | 182 | description: repo.description, |
| 179 | 183 | visibility: repo.visibility, |
| 184 | archived: repo.archivedAt !== null, | |
| 180 | 185 | openIssues: repo.counts?.openIssues ?? 0, |
| 181 | 186 | openPulls: repo.counts?.openPulls ?? 0, |
| 182 | 187 | }; |
| @@ -518,6 +523,7 @@ export async function grepRepos( | ||
| 518 | 523 | if (hits.length >= limit) return { hits, truncated: true, more: false }; |
| 519 | 524 | hits.push({ |
| 520 | 525 | repo: { owner: repo.ownerName, name: repo.name }, |
| 526 | archived: repo.archivedAt !== null, | |
| 521 | 527 | ...hit, |
| 522 | 528 | }); |
| 523 | 529 | } |
src/web/pages/search.ts +5 −1
| @@ -75,6 +75,9 @@ interface Apps { | ||
| 75 | 75 | const privateBadge = (yes: boolean): Html => |
| 76 | 76 | when(yes, () => badge("Private", { icon: "lock", variant: "secondary" })); |
| 77 | 77 | |
| 78 | const archivedBadge = (yes: boolean): Html => | |
| 79 | when(yes, () => badge("Archived", { variant: "secondary" })); | |
| 80 | ||
| 78 | 81 | /** Whether an app is live. No query — `runtime.status` reads what is |
| 79 | 82 | * already in memory. Undefined for a private app the viewer may not enter: |
| 80 | 83 | * its state is its writers' and crew's (`envsFor`), and "not running" would |
| @@ -129,7 +132,7 @@ function repoHitRow(ctx: Ctx, apps: Apps, hit: RepoHit): Html { | ||
| 129 | 132 | ? counts |
| 130 | 133 | : badge(live.word, { tone: live.tone })}${privateBadge( |
| 131 | 134 | hit.visibility === "private", |
| 132 | )}`, | |
| 135 | )}${archivedBadge(hit.archived)}`, | |
| 133 | 136 | }); |
| 134 | 137 | } |
| 135 | 138 | const mirror = hit.mirroredAs; |
| @@ -237,6 +240,7 @@ function codeHitRow(hit: CodeHit, query: string): Html { | ||
| 237 | 240 | >${markMatch(hit.text.trim(), query)}</span |
| 238 | 241 | > |
| 239 | 242 | </span> |
| 243 | ${when(hit.archived, () => html`<span class="row-trail">${archivedBadge(true)}</span>`)} | |
| 240 | 244 | </a> |
| 241 | 245 | </li>`; |
| 242 | 246 | } |
test/mcp-search.test.ts +76 −1
| @@ -8,7 +8,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createRepo, setArchived } from "../src/actions/repos.ts"; | |
| 12 | 12 | import { createIssue } from "../src/core/issues.ts"; |
| 13 | 13 | import { createToken } from "../src/core/tokens.ts"; |
| 14 | 14 | import { SEARCH_LIMIT } from "../src/actions/search.ts"; |
| @@ -108,3 +108,78 @@ describe("MCP search", () => { | ||
| 108 | 108 | } |
| 109 | 109 | }); |
| 110 | 110 | }); |
| 111 | ||
| 112 | describe("search marks an archived repository", () => { | |
| 113 | test("the flag in MCP rows and the badge on the page, for repos and code", async () => { | |
| 114 | const dataDir = await mkdtemp(join(tmpdir(), "forge-search-archived-")); | |
| 115 | const app = createApp({ dataDir, port: 0 }); | |
| 116 | const servers = await serve(app); | |
| 117 | try { | |
The title says repos and code, but only |
||
| 118 | const u = await createUser(app.db, { | |
| 119 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 120 | password: "a-good-passphrase", adminIfFirst: true, | |
| 121 | }); | |
| 122 | if (!u.ok) throw new Error(u.error); | |
| 123 | const made = await createRepo(app, u.value, { name: "shelved", visibility: "public" }); | |
| 124 | if (!made.ok) throw new Error(String(made.error)); | |
| 125 | const live = await createRepo(app, u.value, { name: "shelvedlive", visibility: "public" }); | |
| 126 | if (!live.ok) throw new Error(String(live.error)); | |
| 127 | const token = createToken(app.db, u.value.id, "ro", "ro").token; | |
| 128 | const rw = createToken(app.db, u.value.id, "rw", "rw").token; | |
| 129 | for (const name of ["shelved", "shelvedlive"]) { | |
| 130 | const pushed = await fetch(`${servers.url}/api/v1/repos/alice/${name}/contents`, { | |
| 131 | method: "POST", | |
| 132 | headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" }, | |
| 133 | body: JSON.stringify({ | |
| 134 | branch: "main", message: "needle", | |
| 135 | files: [{ path: "src/a-rather-long-path/to/needle.txt", content: "a quixotic needle\n" }], | |
| 136 | }), | |
| 137 | }); | |
| 138 | expect(pushed.status).toBeLessThan(300); | |
| 139 | } | |
| 140 | const call = async (kind = "repos") => { | |
| 141 | const res = await fetch(`${servers.url}/mcp`, { | |
| 142 | method: "POST", | |
| 143 | headers: { | |
| 144 | "content-type": "application/json", | |
| 145 | accept: "application/json, text/event-stream", | |
| 146 | authorization: `Bearer ${token}`, | |
| 147 | }, | |
| 148 | body: JSON.stringify({ | |
| 149 | jsonrpc: "2.0", id: 1, method: "tools/call", | |
| 150 | params: { name: "search", arguments: { q: kind === "repos" ? "shelved" : "quixotic", kind } }, | |
| 151 | }), | |
| 152 | }); | |
| 153 | const body: any = await res.json(); | |
| 154 | return JSON.parse(body.result.content[0].text)[kind] as any[]; | |
| 155 | }; | |
| 156 | const before = await call(); | |
| 157 | expect(before.map((r) => r.archived)).toEqual([false, false]); | |
| 158 | const done = await setArchived(app, u.value, made.value, true, false); | |
| 159 | expect(done.ok).toBe(true); | |
| 160 | const repoRows = await call(); | |
| 161 | expect(repoRows.find((r) => r.name === "shelved").archived).toBe(true); | |
| 162 | expect(repoRows.find((r) => r.name === "shelvedlive").archived).toBe(false); | |
| 163 | ||
| 164 | const hits = await call("code"); | |
| 165 | expect(hits.find((h) => h.repo.name === "shelved").archived).toBe(true); | |
| 166 | expect(hits.find((h) => h.repo.name === "shelvedlive").archived).toBe(false); | |
| 167 | const codePage = await (await fetch(`${servers.url}/search?q=quixotic&kind=code`)).text(); | |
| 168 | const rows = codePage.split('<li>').filter((r) => r.includes("code-hit")); | |
| 169 | const hitRow = (name: string) => rows.find((r) => r.includes(`/alice/${name}/blob`))!.split("</li>")[0]!; | |
| 170 | expect(hitRow("shelved")).toContain("Archived"); | |
| 171 | expect(hitRow("shelvedlive")).not.toContain("Archived"); | |
| 172 | ||
| 173 | const page = await (await fetch(`${servers.url}/search?q=shelved&kind=repos`)).text(); | |
| 174 | const parts = page.split('href="/alice/'); | |
| 175 | const archivedRow = parts.find((p) => p.startsWith("shelved\""))!; | |
| 176 | const liveRow = parts.find((p) => p.startsWith("shelvedlive\""))!; | |
| 177 | expect(archivedRow).toContain("Archived"); | |
| 178 | expect(liveRow.split("</li>")[0]).not.toContain("Archived"); | |
| 179 | } finally { | |
| 180 | servers.stop(); | |
| 181 | app.close(); | |
| 182 | await rm(dataDir, { recursive: true, force: true }); | |
| 183 | } | |
| 184 | }, 30_000); | |
| 185 | }); |
test/search-cache.test.ts +3 −3
| @@ -28,7 +28,7 @@ beforeAll(async () => { | ||
| 28 | 28 | git(root, "clone", "-q", "--bare", work, join(root, "u", "r0.git")); |
| 29 | 29 | for (let i = 0; i < 200; i++) { |
| 30 | 30 | if (i > 0) await cp(join(root, "u", "r0.git"), join(root, "u", `r${i}.git`), { recursive: true }); |
| 31 | repos.push({ id: `id${i}`, ownerName: "u", name: `r${i}`, defaultBranch: "main" } as Repo); | |
| 31 | repos.push({ id: `id${i}`, ownerName: "u", name: `r${i}`, defaultBranch: "main", archivedAt: null } as Repo); | |
| 32 | 32 | } |
| 33 | 33 | }, 60_000); |
| 34 | 34 | |
| @@ -48,7 +48,7 @@ test("a repeat search answers identically and far faster than the greps", async | ||
| 48 | 48 | expect((performance.now() - t) * 4).toBeLessThan(cold); |
| 49 | 49 | expect(grepCount()).toBe(ran); |
| 50 | 50 | expect(again).toEqual(first); |
| 51 | expect(first.hits[0]).toEqual({ repo: { owner: "u", name: "r0" }, path: "a.txt", line: 1, text: "needle" }); | |
| 51 | expect(first.hits[0]).toEqual({ repo: { owner: "u", name: "r0" }, archived: false, path: "a.txt", line: 1, text: "needle" }); | |
| 52 | 52 | }); |
| 53 | 53 | |
| 54 | 54 | test("a new commit is a new key, never the old answer", async () => { |
| @@ -68,7 +68,7 @@ test("a repo without commits answers nothing", async () => { | ||
| 68 | 68 | const bare = join(root, "u", "empty.git"); |
| 69 | 69 | await mkdir(bare, { recursive: true }); |
| 70 | 70 | git(bare, "init", "-q", "--bare", "-b", "main"); |
| 71 | const empty = { id: "e", ownerName: "u", name: "empty", defaultBranch: "main" } as Repo; | |
| 71 | const empty = { id: "e", ownerName: "u", name: "empty", defaultBranch: "main", archivedAt: null } as Repo; | |
| 72 | 72 | expect((await grepRepos(root, [empty], "needle")).hits).toEqual([]); |
| 73 | 73 | }); |
| 74 | 74 |
test/search.test.ts +2
| @@ -60,6 +60,7 @@ interface Results { | ||
| 60 | 60 | )[]; |
| 61 | 61 | code: { |
| 62 | 62 | repo: { owner: string; name: string }; |
| 63 | archived?: boolean; | |
| 63 | 64 | path: string; |
| 64 | 65 | line: number; |
| 65 | 66 | text: string; |
| @@ -453,6 +454,7 @@ describe("search: one query, four sources", () => { | ||
| 453 | 454 | expect(results.code).toEqual([ |
| 454 | 455 | { |
| 455 | 456 | repo: { owner: "alice", name: "widget-engine" }, |
| 457 | archived: false, | |
| 456 | 458 | path: "notes/a:1:b.txt", |
| 457 | 459 | line: 2, |
| 458 | 460 | text: "colon needle here", |
new above
offline
This code-hit badge has no test that fails without it. Pin it, and check the row at 360px.