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

`GET /api/v1/fleet/rows?q=` answers the rows `/fleet` shows #10145

Merged opened this

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

Merged

scout/forge:scout/10131-get-api-v1-fleet-rows-q-answers-the-rows → main 2 commits fork

  • Merged. scout/10131-get-api-v1-fleet-rows-q-answers-the-rows was merged into main as 1f20ea0.

Review

Sign in to review this change.
5 files changed +138 −0
docs/features/fleet.md +5
@@ -14,6 +14,11 @@ Before a fleet operation acts, see the rows its query line selects. There are tw
1414 - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back.
1515 - A line that does not parse keeps the prompt open with its reason.
1616
17 **In the API:** `GET /api/v1/fleet/rows?q=<query line>&sort=<column>` answers `{ kind, columns, items }`: the same rows `/fleet` shows, for the caller, public repositories only when signed out.
18
19 - `sort` is a column key, `-` leading to reverse; an unknown one is `invalid_sort` (422).
20 - A line that does not parse, an empty `q`, or a `q` over 500 characters is `invalid_query` (422) with the reason.
21
1722 **The query line:** a kind, then `field:value` terms that must all hold. Both doors show the same grammar under the line: one sentence, then each kind with its fields (`GRAMMAR` and `fieldsOf` in `src/fleet/selection.ts`).
1823
1924 - A value starting with `!` negates its term: `repo:!trevato/forge`.
src/contract/openapi/routes/getApiV1FleetRows.ts added +14
@@ -0,0 +1,14 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/fleet/rows",
6 summary: "The rows a fleet query line selects, as /fleet shows them, only those the caller may read; answers { kind, columns, items }",
7 auth: false,
8 query: { q: "string", sort: "string" },
9 refusals: {
10 invalid_query: "q is empty, over 500 characters, or does not parse; the reason is the message (422)",
11 invalid_sort: "sort is not a column of the kind (422)",
12 },
13 reply: { kind: "string", columns: "array", items: "array" },
14 };
src/contract/openapi/routes/index.ts +1
@@ -79,6 +79,7 @@ export { route as getApiV1Flags } from "./getApiV1Flags.ts";
7979 export { route as getApiV1FleetGraph } from "./getApiV1FleetGraph.ts";
8080 export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts";
8181 export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts";
82 export { route as getApiV1FleetRows } from "./getApiV1FleetRows.ts";
8283 export { route as getApiV1Forge } from "./getApiV1Forge.ts";
8384 export { route as getApiV1ForgeEconomics } from "./getApiV1ForgeEconomics.ts";
8485 export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts";
src/http/api.ts +19
@@ -276,6 +276,8 @@ import { checkStale } from "../checks/stale.ts";
276276 import { resolveOid } from "../git/merge.ts";
277277 import { readForgeUsage, readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts";
278278 import { readReturns, reportSpend, RETURNS_DAYS } from "../actions/returns.ts";
279 import { COLUMNS, readSort, sortRows } from "../fleet/columns.ts";
280 import { viewFleet } from "../fleet/rows.ts";
279281 import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts";
280282 import { carriedBy } from "../core/carried.ts";
281283 import { readGateLog } from "../checks/gate_log.ts";
@@ -3282,6 +3284,23 @@ export function apiRoutes(router: Router<Handler>): void {
32823284 return timePhase(ctx.request, "render", () => json(paged(ops.value, limit, (op) => op.id)));
32833285 });
32843286
3287 // The rows a query line selects, as `/fleet` shows them (`fleet/rows.ts`).
3288 router.add("GET", "/api/v1/fleet/rows", (ctx) => {
3289 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
3290 const q = ctx.url.searchParams.get("q") ?? "";
3291 if (q.length > 500) return apiError("invalid_query", 422, "q is over 500 characters");
3292 if (q.trim() === "") return apiError("invalid_query", 422, "q is a fleet query line, like: repo autonomy:autonomous");
3293 const view = timePhase(ctx.request, "rows", () => viewFleet(ctx.app.db, ctx.auth.user, q));
3294 if (!view.ok) return apiError("invalid_query", 422, view.error);
3295 const asked = ctx.url.searchParams.get("sort");
3296 const sort = asked === null || asked === "" ? undefined : readSort(view.kind, asked);
3297 if (asked !== null && asked !== "" && sort === undefined)
3298 return apiError("invalid_sort", 422, "sort is a column of the kind, optionally led by -");
3299 return timePhase(ctx.request, "render", () =>
3300 json({ kind: view.kind, columns: COLUMNS[view.kind], items: sortRows(view.rows, sort) }),
3301 );
3302 });
3303
32853304 router.add("GET", "/api/v1/fleet/ops/:id", (ctx) => {
32863305 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
32873306 const op = timePhase(ctx.request, "op", () => getFleetOp(ctx.app, ctx.auth.user, ctx.params.id!));
test/fleet-rows-api.test.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * `GET /api/v1/fleet/rows`: the rows `/fleet` shows for one query line, per
3 * kind; a private repository hidden from a stranger; refusals in 422.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createIssue } from "../src/core/issues.ts";
12 import { createRun } from "../src/core/runs.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { promote } from "./helpers/members.ts";
17
18 describe("GET /api/v1/fleet/rows", () => {
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let cookie: string;
23
24 const get = (path: string, withCookie?: string) =>
25 fetch(`${servers.url}${path}`, withCookie ? { headers: { cookie: withCookie } } : {});
26 const api = (q: string, extra = "") =>
27 get(`/api/v1/fleet/rows?${new URLSearchParams({ q })}${extra}`, cookie);
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-rows-api-"));
31 app = createApp({ dataDir, port: 0 });
32 servers = await serve(app);
33 const made = await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username: "alice",
37 email: "[email protected]",
38 password: "a-good-passphrase",
39 adminIfFirst: true,
40 });
41 if (!made.ok) throw new Error(made.error);
42 promote(app.db, made.value);
43 const alice: User = made.value;
44 for (const [name, visibility] of [["engine", "private"], ["tools", "public"]] as const) {
45 const repo = await createRepo(app, alice, { name, visibility });
46 if (!repo.ok) throw new Error(String(repo.error));
47 createRun(app.db, { repoId: repo.value.id, kind: "agent" });
48 const i = createIssue(app.db, { repoId: repo.value.id, authorId: alice.id, title: `issue in ${name}` });
49 if (!i.ok) throw new Error(i.error);
50 }
51 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
52 }, 30_000);
53
54 afterAll(async () => {
55 servers.stop();
56 app.close();
57 await rm(dataDir, { recursive: true, force: true });
58 });
59
60 for (const kind of ["repo", "run", "issue"] as const) {
61 test(`${kind}: the API and /fleet select the same rows`, async () => {
62 const line = `${kind} owner:alice`;
63 const res = await api(line);
64 expect(res.status).toBe(200);
65 const body = (await res.json()) as { kind: string; columns: { key: string }[]; items: Record<string, string>[] };
66 expect(body.kind).toBe(kind);
67 expect(body.columns.length).toBeGreaterThan(0);
68 expect(body.items.length).toBe(2);
69 const page = await (await get(`/fleet?${new URLSearchParams({ q: line })}`, cookie)).text();
70 expect(page).toContain(`data-count="${body.items.length}"`);
71 for (const item of body.items) expect(page).toContain(`href="/${item.repo}"`);
72 });
73 }
74
75 test("a private repository's rows are hidden from a stranger", async () => {
76 for (const kind of ["repo", "run", "issue"]) {
77 const res = await get(`/api/v1/fleet/rows?${new URLSearchParams({ q: `${kind} owner:alice` })}`);
78 expect(res.status).toBe(200);
79 const body = (await res.json()) as { items: { repo: string }[] };
80 expect(body.items.map((r) => r.repo)).toEqual(["alice/tools"]);
81 }
82 });
83
84 test("sort orders the items", async () => {
85 const names = async (sort: string) =>
86 ((await (await api("repo owner:alice", `&sort=${sort}`)).json()) as { items: { repo: string }[] }).items.map((r) => r.repo);
87 expect(await names("repo")).toEqual(["alice/engine", "alice/tools"]);
88 expect(await names("-repo")).toEqual(["alice/tools", "alice/engine"]);
89 expect((await api("repo", "&sort=nope")).status).toBe(422);
90 });
91
92 test("a line that does not parse, an empty one and an overlong one are 422", async () => {
93 const bad = await api("repo spent:4");
94 expect(bad.status).toBe(422);
95 expect(((await bad.json()) as { message: string }).message).toContain('repo has no field "spent"');
96 expect((await api("")).status).toBe(422);
97 expect((await api(`repo name:${"a".repeat(500)}`)).status).toBe(422);
98 });
99 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs