Public: anyone can read this; the crew can change it.
`GET /api/v1/fleet/rows?q=` answers the rows `/fleet` shows #10145
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10131-get-api-v1-fleet-rows-q-answers-the-rows → main 2 commits fork
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 | ||
| 14 | 14 | - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back. |
| 15 | 15 | - A line that does not parse keeps the prompt open with its reason. |
| 16 | 16 | |
| 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 | ||
| 17 | 22 | **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`). |
| 18 | 23 | |
| 19 | 24 | - 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"; | ||
| 79 | 79 | export { route as getApiV1FleetGraph } from "./getApiV1FleetGraph.ts"; |
| 80 | 80 | export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts"; |
| 81 | 81 | export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts"; |
| 82 | export { route as getApiV1FleetRows } from "./getApiV1FleetRows.ts"; | |
| 82 | 83 | export { route as getApiV1Forge } from "./getApiV1Forge.ts"; |
| 83 | 84 | export { route as getApiV1ForgeEconomics } from "./getApiV1ForgeEconomics.ts"; |
| 84 | 85 | export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts"; |
src/http/api.ts +19
| @@ -276,6 +276,8 @@ import { checkStale } from "../checks/stale.ts"; | ||
| 276 | 276 | import { resolveOid } from "../git/merge.ts"; |
| 277 | 277 | import { readForgeUsage, readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts"; |
| 278 | 278 | 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"; | |
| 279 | 281 | import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts"; |
| 280 | 282 | import { carriedBy } from "../core/carried.ts"; |
| 281 | 283 | import { readGateLog } from "../checks/gate_log.ts"; |
| @@ -3282,6 +3284,23 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3282 | 3284 | return timePhase(ctx.request, "render", () => json(paged(ops.value, limit, (op) => op.id))); |
| 3283 | 3285 | }); |
| 3284 | 3286 | |
| 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 | ||
| 3285 | 3304 | router.add("GET", "/api/v1/fleet/ops/:id", (ctx) => { |
| 3286 | 3305 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 3287 | 3306 | 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 | }); |