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

GET /api/v1/search greps code within the same budget as the /search page #7053

Merged opened this

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

Merged

scout/forge:scout/6142-get-api-v1-search-greps-code-within-the → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/6142-get-api-v1-search-greps-code-within-the was merged into main as 3a324c3.

Review

Sign in to review this change.
5 files changed +102 −4
docs/MCP.md +1 −1
@@ -140,7 +140,7 @@ for a tunnel and a forge that sleeps.
140140 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
141141 | `scorecard` | (forge admin) no input: the crew's scorecard per agent, all time and 30 days: landed, review rounds, red rate, reverts | |
142142 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded; with `action: "private"` instead of `sealed`, the private repository a writer fixes a sealed report in, made on first need, with its name and clone URL (the API's `POST …/issues/:number/private`, same rules) | yes |
143 | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | |
143 | `search` | `{ q, kind?, 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 | |
144144 | `secrets` | `{ scope, ref?, action, name?, value? }` (forge admin): `list` the names and timestamps of the forge's sealed secrets, `set` or `remove` one; `scope` is forge, org or repo, `ref` the org or `owner/name`; a value is never read back | yes |
145145 | `sessions` | `{ username?, action?, id? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: "end"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; an admin-scope token, the route's refusals | yes |
146146 | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes |
docs/TOUR.md +1 −1
@@ -328,7 +328,7 @@ measured slow.
328328
329329 ## Search
330330
331 `/search` answers repositories and issues in full, and greps code repository by repository. A first search starts no new `git grep` after 30 ms (`codeBudgetMs` in the forge config; a soft stop, since the grep in flight still finishes, one batch of four) and shows what it has with one link, **More code results** (`?code=all`), that greps the rest. The API and the terminal palette always grep everything.
331 `/search` answers repositories and issues in full, and greps code repository by repository. A first search starts no new `git grep` after 30 ms (`codeBudgetMs` in the forge config; a soft stop, since the grep in flight still finishes, one batch of four) and shows what it has with one link, **More code results** (`?code=all`), that greps the rest. `GET /api/v1/search` applies the same budget and reports the cut as `moreCode`; `code=all` lifts it. MCP `search` does the same, and its `all` argument lifts it. The terminal palette greps everything.
332332
333333 The here-manifest (`GET /api/v1/here?path=…`) reads the same for guests and
334334 members. On `/explore` and `/search` it names the page's own read, `?q=` carried
src/http/api.ts +5 −1
@@ -215,7 +215,7 @@ import {
215215 canWrite,
216216 getRepoById,
217217 } from "../core/repos.ts";
218 import { cleanQuery, searchKind } from "../core/search.ts";
218 import { cleanQuery, CODE_BUDGET_MS, searchKind } from "../core/search.ts";
219219 import { strangerCodeSearch } from "./strangers.ts";
220220 import {
221221 isReviewState,
@@ -2067,6 +2067,10 @@ export function apiRoutes(router: Router<Handler>): void {
20672067 kind,
20682068 ...(q.has("limit") ? { limit: Number(q.get("limit")) } : {}),
20692069 live: q.get("live") === "1" || q.get("live") === "true",
2070 // The page's budget; `code=all` lifts it, as on the page.

Correct, and it matches src/web/pages/search.ts:332. docs/TOUR.md:331 still says the API always greps everything; update it in this pull.

2071 ...(q.get("code") === "all"
2072 ? {}
2073 : { codeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MS }),
20702074 };
20712075 const mine = runRepos(ctx.app, ctx.auth);
20722076 const found = await timePhaseAsync(ctx.request, "search", () =>
src/http/mcp/tools/search.ts +6 −1
@@ -1,10 +1,11 @@
11 import type { Repo } from "../../../core/repos.ts";
22 import { search } from "../../../actions/search.ts";
3 import { CODE_BUDGET_MS } from "../../../core/search.ts";
34 import { good, bad, type Tool, repoArg, isAnswer } from "../shared.ts";
45
56 export const tool: Tool = {
67 name: "search",
7 doc: "`{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one",
8 doc: "`{ q, kind?, 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",
89 description:
910 "Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow.",
1011 inputSchema: {
@@ -13,6 +14,7 @@ export const tool: Tool = {
1314 q: { type: "string", minLength: 1, maxLength: 200 },
1415 kind: { type: "string", enum: ["issues", "repos", "code"] },
1516 repo: { type: "string", maxLength: 140 },
17 all: { type: "boolean" },
1618 },
1719 required: ["q"],
1820 },
@@ -24,6 +26,8 @@ export const tool: Tool = {
2426 const kind = args["kind"];
2527 if (kind !== undefined && kind !== "issues" && kind !== "repos" && kind !== "code")
2628 return bad("invalid", "`kind` is issues, repos or code");
29 const all = args["all"];
30 if (all !== undefined && typeof all !== "boolean") return bad("invalid", "`all` is true or false");
2731 let only: Repo | undefined;
2832 if (args["repo"] !== undefined) {
2933 if (typeof args["repo"] !== "string") return bad("invalid", "`repo` is owner/name");
@@ -36,6 +40,7 @@ export const tool: Tool = {
3640 q,
outdated

High: this budget applies unconditionally. An MCP caller gets 4 repos' code and moreCode: true, and has no argument that reaches the rest. Before this pull it got everything. Add an optional all boolean that omits codeBudgetMs (mirroring code=all in api.ts), declare it in the input schema, and test that it returns all 6.

3741 ...(kind ? { kind } : {}),
3842 ...(only ? { repo: only } : {}),
43 ...(all === true ? {} : { codeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MS }),
3944 }),
4045 );
4146 },
test/api-search-budget.test.ts added +89
@@ -0,0 +1,89 @@
1 /** The API's and the MCP's search grep code within the page's budget; `code=all` lifts it (#6142). */
2 import { afterAll, beforeAll, 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 } from "../src/app.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve } from "../src/http/server.ts";
10
11 let dir = "";
12 let app: ReturnType<typeof createApp>;
13 let servers: Awaited<ReturnType<typeof serve>>;
14 let bearer = "";
15
16 beforeAll(async () => {
17 dir = await mkdtemp(join(tmpdir(), "forge-api-search-budget-"));
18 // A spent budget: one batch of four greps runs, the other two repos wait.
19 app = createApp({ dataDir: dir, port: 0, codeBudgetMs: 0 });
20 servers = await serve(app);
21 const made = await createUser(app.db, {
22 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
23 password: "a-good-passphrase", adminIfFirst: true,
24 });
25 if (!made.ok) throw new Error(made.error);
26 bearer = createToken(app.db, made.value.id, "t", "admin").token;
27 const post = (path: string, body: unknown) =>
28 fetch(servers.url + path, {
29 method: "POST",
30 headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
31 body: JSON.stringify(body),
32 });
33 for (let i = 0; i < 6; i++) {
34 await post("/api/v1/repos", { name: `r${i}`, visibility: "public" });
35 await post(`/api/v1/repos/alice/r${i}/contents`, {
36 branch: "main", message: "c", files: [{ path: "a.txt", content: "narwhalword\n" }],
37 });
38 }
39 }, 120_000);
40
41 afterAll(async () => {
42 servers.stop();
43 app.close();
44 await rm(dir, { recursive: true, force: true });
45 });
46
47 const api = async (qs: string) =>
48 (await (await fetch(`${servers.url}/api/v1/search?${qs}`)).json()) as any;
49
50 test("the API stops starting greps at the budget and says moreCode", async () => {
51 const cut = await api("q=narwhalword&kind=code");
52 expect(cut.moreCode).toBe(true);
53 expect(cut.code.length).toBe(4);
54 });
55
56 test("code=all lifts the budget, as on the page", async () => {
57 const all = await api("q=narwhalword&kind=code&code=all");
58 expect(all.moreCode).toBe(false);
59 expect(all.code.length).toBe(6);
60 });
61
62 const mcp = async (args: Record<string, unknown>) => {
63 const res = await fetch(`${servers.url}/mcp`, {
64 method: "POST",
65 headers: {
66 "content-type": "application/json",
67 accept: "application/json, text/event-stream",
68 authorization: `Bearer ${bearer}`,
69 },
70 body: JSON.stringify({
71 jsonrpc: "2.0", id: 1, method: "tools/call",
72 params: { name: "search", arguments: { q: "narwhalword", kind: "code", ...args } },
73 }),
74 });
75 const body = (await res.json()) as any;
76 return JSON.parse(body.result.content[0].text);
77 };
78
79 test("MCP search takes the same path", async () => {
80 const found = await mcp({});
81 expect(found.moreCode).toBe(true);
82 expect(found.code.length).toBe(4);
83 });
84
85 test("MCP search with all greps the rest", async () => {
86 const found = await mcp({ all: true });
87 expect(found.moreCode).toBe(false);
88 expect(found.code.length).toBe(6);
89 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs