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

The here-manifest knows /explore and /search: it names their query API and MCP tool #4118

Merged opened this

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

Merged

scout/forge:scout/4107-the-here-manifest-knows-explore-and-sear → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/4107-the-here-manifest-knows-explore-and-sear was merged into main as 9bf2a94.

Review

Sign in to review this change.
5 files changed +78 −4
README.md +5
@@ -2063,3 +2063,8 @@ web returns to `/` with that line.
20632063 `forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out`
20642064 per `repo` and `destination` as Prometheus text, so a leaked upstream socket
20652065 shows as a gauge that never falls (#3719).
2066
2067 The here-manifest (`GET /api/v1/here?path=…`) reads the same for guests and
2068 members. On `/explore` and `/search` it names the page's own read, `?q=` carried
2069 over and encoded: `GET /api/v1/explore?q=…` (MCP tool `explore`) or
2070 `GET /api/v1/search?q=…` (MCP tool `search`), listed once.
src/actions/here.ts +36 −3
@@ -49,7 +49,7 @@ export interface Action {
4949
5050 export interface Manifest {
5151 readonly here:
52 | { readonly kind: "forge" }
52 | { readonly kind: "forge" | "explore" | "search" }
5353 | {
5454 readonly kind: "repository";
5555 readonly name: string;
@@ -106,6 +106,10 @@ export function manifestFor(
106106 return s;
107107 }
108108 });
109 const page =
110 name === undefined && (owner === "explore" || owner === "search")
111 ? owner
112 : undefined;
109113 // Two segments under a name the forge keeps for itself are its own page,
110114 // never a repository (`core/reserved.ts`).
111115 // `/:owner/-/…` is an account's or an org's own page (its teams), never
@@ -117,9 +121,15 @@ export function manifestFor(
117121 SYSTEM.has(owner.toLowerCase())
118122 )
119123 return ok({
120 here: { kind: "forge" },
124 here: { kind: page ?? "forge" },
121125 you: youFor(viewer, scope, undefined, app),
122 actions: forgeActions(viewer),
126 actions:
127 page === undefined
128 ? forgeActions(viewer)
129 : [
130 pageRead(page, path),
131 ...forgeActions(viewer).filter((a) => a.mcp !== page),
132 ],
123133 ...common,
124134 });
125135 const repo = getRepo(app.db, owner, name.replace(/\.git$/, ""));
@@ -203,8 +213,31 @@ function repoHere(
203213 };
204214 }
205215
216 /** The read an explore or search page pages through, its `?q=` carried over. */
217 function pageRead(page: "explore" | "search", path: string): Action {
218 const q = new URL(path, "http://forge.invalid").searchParams.get("q") ?? "";
219 const query =
220 q === "" ? "" : `?q=${encodeURIComponent(q).replace(/%3A/gi, ":")}`;
221 const http = `GET /api/v1/${page}${query}`;
222 return {
223 do: `${page === "explore" ? "browse the forge" : "search the forge"}${q === "" ? "" : `: ${q.slice(0, 200)}`}`,
224 http,
225 mcp: page,
226 };
227 }
228
206229 function forgeActions(viewer: User | undefined): Action[] {
207230 return [
231 {
232 do: "explore apps and repositories",
233 http: "GET /api/v1/explore?q=",
234 mcp: "explore",
235 },
outdated

On the page itself this duplicates pageRead; skip the generic entry when the page matches.

236 {
237 do: "search issues, pulls, repositories and code",
238 http: "GET /api/v1/search?q=",
239 mcp: "search",
240 },
208241 { do: "list public repositories", http: "GET /api/v1/repos" },
209242 {
210243 do: "report a problem",
src/contract/openapi.ts +2
@@ -49,6 +49,8 @@ export const ROUTES: readonly Route[] = [
4949 body: APP_BODY,
5050 refusals: APP_REFUSALS,
5151 },
52 { method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items }", auth: false, query: { q: "string" } },
53 { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string" } },
5254 { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false },
5355 {
5456 method: "get",
test/here-manifest.test.ts +34
@@ -215,6 +215,32 @@ describe("the manifest: what you can do here, as who you are", () => {
215215 expect(JSON.stringify(body)).not.toContain("vault");
216216 });
217217
218 test("/explore and /search name their query API and MCP tool, the query carried over", async () => {
219 for (const who of [undefined, "carol"]) {
220 const e = (await manifest("/explore?q=kind:app", who)).body;
221 expect(e.here).toEqual({ kind: "explore" });
222 expect(e.actions[0].http).toBe("GET /api/v1/explore?q=kind:app");
223 expect(e.actions[0].mcp).toBe("explore");
224 const s = (await manifest("/search", who)).body;
225 expect(s.here).toEqual({ kind: "search" });
226 expect(s.actions[0].http).toBe("GET /api/v1/search");
227 expect(s.actions[0].mcp).toBe("search");
228 const f = (await manifest("/", who)).body;
229 expect(f.here.kind).toBe("forge");
230 expect(f.actions.map((a: any) => a.mcp)).toEqual(
231 expect.arrayContaining(["explore", "search"]),
232 );
233 for (const a of f.actions)
234 if (a.mcp === "explore" || a.mcp === "search")
235 expect(a.account).toBeUndefined();
236 }
237 const q = (await manifest("/search?q=a%20b%26c")).body;
238 expect(q.actions[0].http).toBe("GET /api/v1/search?q=a%20b%26c");
239 const other = (await manifest("/elsewhere")).body;
240 expect(other.here).toEqual({ kind: "forge" });
241 expect(other.actions[0].do).toBe("explore apps and repositories");
242 });
243
218244 test("it is small", async () => {
219245 for (const who of [undefined, "carol", "wes", "alice", "bot"]) {
220246 const res = await here("/acme/game", who);
@@ -322,4 +348,12 @@ describe("the manifest: what you can do here, as who you are", () => {
322348 const ownBody = (await manifest(`/acme/game/pulls/${own.value.number}`)).body;
323349 expect(ownBody.here.pull).toEqual({ number: own.value.number, runs: true });
324350 });
351
352 test("on /explore the page read is not repeated by the generic one", async () => {
353 const { body } = await manifest("/explore?q=kind:app");
354 const reads = body.actions.filter((a: any) => a.mcp === "explore");
355 expect(reads.length).toBe(1);
356 expect(reads[0].http).toBe("GET /api/v1/explore?q=kind:app");
357 expect(body.actions.filter((a: any) => a.mcp === "search").length).toBe(1);
358 });
325359 });
test/openapi.test.ts +1 −1
@@ -7,7 +7,7 @@ test("every http route the manifest names is described", () => {
77 const src = readFileSync("src/actions/here.ts", "utf8");
88 const named = [...src.matchAll(/http: [`"]([^`"]+)[`"]/g)].flatMap((m) =>
99 m[1]!.split(", then ").map((one) => {
10 const [method, path] = one.replace(" {headRepo}", "").split(" ");
10 const [method, path] = one.replace(" {headRepo}", "").replace(/\?.*/, "").split(" ");
1111 return [method!.toLowerCase(), path!.replaceAll("${r}", "/api/v1/repos/{owner}/{name}")] as const;
1212 }),
1313 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs