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

Merged opened this

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

Merged

scout/forge:scout/6084-a-search-hit-in-an-archived-repository-w → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/6084-a-search-hit-in-an-archived-repository-w was merged into main as 7fb5677.

Review

Sign in to review this change.
5 files changed +92 −5
src/core/search.ts +6
@@ -64,6 +64,8 @@ export type RepoHit =
6464 readonly name: string;
6565 readonly description: string;
6666 readonly visibility: "public" | "private";
67 /** Put away by an admin: still found, but not live. */
68 readonly archived: boolean;
6769 readonly openIssues: number;
6870 readonly openPulls: number;
6971 }
@@ -108,6 +110,8 @@ export type IssueHit =
108110
109111 export interface CodeHit {
110112 readonly repo: { owner: string; name: string };
113 /** The repository is archived. */
114 readonly archived: boolean;
111115 readonly path: string;
112116 readonly line: number;
113117 readonly text: string;
@@ -177,6 +181,7 @@ function toRepoHit(repo: Repo): RepoHit {
177181 name: repo.name,
178182 description: repo.description,
179183 visibility: repo.visibility,
184 archived: repo.archivedAt !== null,
180185 openIssues: repo.counts?.openIssues ?? 0,
181186 openPulls: repo.counts?.openPulls ?? 0,
182187 };
@@ -518,6 +523,7 @@ export async function grepRepos(
518523 if (hits.length >= limit) return { hits, truncated: true, more: false };
519524 hits.push({
520525 repo: { owner: repo.ownerName, name: repo.name },
526 archived: repo.archivedAt !== null,
521527 ...hit,
522528 });
523529 }
src/web/pages/search.ts +5 −1
@@ -75,6 +75,9 @@ interface Apps {
7575 const privateBadge = (yes: boolean): Html =>
7676 when(yes, () => badge("Private", { icon: "lock", variant: "secondary" }));
7777
78 const archivedBadge = (yes: boolean): Html =>
79 when(yes, () => badge("Archived", { variant: "secondary" }));
80
7881 /** Whether an app is live. No query — `runtime.status` reads what is
7982 * already in memory. Undefined for a private app the viewer may not enter:
8083 * 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 {
129132 ? counts
130133 : badge(live.word, { tone: live.tone })}${privateBadge(
131134 hit.visibility === "private",
132 )}`,
135 )}${archivedBadge(hit.archived)}`,
133136 });
134137 }
135138 const mirror = hit.mirroredAs;
@@ -237,6 +240,7 @@ function codeHitRow(hit: CodeHit, query: string): Html {
237240 >${markMatch(hit.text.trim(), query)}</span
238241 >
239242 </span>
243 ${when(hit.archived, () => html`<span class="row-trail">${archivedBadge(true)}</span>`)}

This code-hit badge has no test that fails without it. Pin it, and check the row at 360px.

240244 </a>
241245 </li>`;
242246 }
test/mcp-search.test.ts +76 −1
@@ -8,7 +8,7 @@ import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 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";
1212 import { createIssue } from "../src/core/issues.ts";
1313 import { createToken } from "../src/core/tokens.ts";
1414 import { SEARCH_LIMIT } from "../src/actions/search.ts";
@@ -108,3 +108,78 @@ describe("MCP search", () => {
108108 }
109109 });
110110 });
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 kind: "repos" is exercised. Add a code-kind search over MCP and the page for an archived repo and a live one.

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 () => {
2828 git(root, "clone", "-q", "--bare", work, join(root, "u", "r0.git"));
2929 for (let i = 0; i < 200; i++) {
3030 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);
3232 }
3333 }, 60_000);
3434
@@ -48,7 +48,7 @@ test("a repeat search answers identically and far faster than the greps", async
4848 expect((performance.now() - t) * 4).toBeLessThan(cold);
4949 expect(grepCount()).toBe(ran);
5050 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" });
5252 });
5353
5454 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 () => {
6868 const bare = join(root, "u", "empty.git");
6969 await mkdir(bare, { recursive: true });
7070 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;
7272 expect((await grepRepos(root, [empty], "needle")).hits).toEqual([]);
7373 });
7474
test/search.test.ts +2
@@ -60,6 +60,7 @@ interface Results {
6060 )[];
6161 code: {
6262 repo: { owner: string; name: string };
63 archived?: boolean;
6364 path: string;
6465 line: number;
6566 text: string;
@@ -453,6 +454,7 @@ describe("search: one query, four sources", () => {
453454 expect(results.code).toEqual([
454455 {
455456 repo: { owner: "alice", name: "widget-engine" },
457 archived: false,
456458 path: "notes/a:1:b.txt",
457459 line: 2,
458460 text: "colon needle here",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs