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

GET …/issues?fields=brief lists issues without bodies or comments #4010

Merged opened this

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

Merged

scout/forge:scout/3365-get-issues-fields-brief-lists-issues-wit → main 7 commits fork 5 threads · 5 unresolved

  • Merged. scout/3365-get-issues-fields-brief-lists-issues-wit was merged into main as 447edb0.

Review

Sign in to review this change.
6 files changed +134 −7
README.md +1 −1
@@ -208,7 +208,7 @@ runs no check and no preview until a writer approves its head or lets it run
208208 (**Let it run here**, which is not a review; a new push waits again), and a bare push to `child/…` starts none. A pull
209209 opened with it says "proposed by child … from …" on every tab of the pull's
210210 page, in the terminal's pull view, and as `proposal` in the pull's JSON
211 (`GET …/pulls/:number`, and MCP's `pull` tool). `GET …/pulls/:number?diff=0` reads the pull without its diff (and skips computing it); `diff` is `0` or `1`, default `1`, and anything else answers 422.
211 (`GET …/pulls/:number`, and MCP's `pull` tool). `GET …/pulls/:number?diff=0` reads the pull without its diff (and skips computing it); `diff` is `0` or `1`, default `1`, and anything else answers 422. `GET …/issues?fields=brief` lists issues with only number, kind, title, state, labels, assignee, author, originAuthor, authorIsAgent, createdAt and updatedAt (and `untrusted` where it applies); no bodies or comments. `fields` is `brief` or left out, and anything else answers 422 `invalid_fields`.
212212
213213 ### Proposing to a parent forge
214214
src/contract/openapi.ts +6 −4
@@ -58,7 +58,7 @@ export const ROUTES: readonly Route[] = [
5858 auth: false,
5959 body: { title: "string", body: "string", page: "string" },
6060 },
61 { method: "get", path: `${REPO}/issues`, summary: "List a repository's issues", auth: false, query: LIST_QUERY },
61 { method: "get", path: `${REPO}/issues`, summary: "List a repository's issues; fields=brief leaves out bodies and comments, any other fields value is a 422 invalid_fields", auth: false, query: { ...LIST_QUERY, fields: "string" } },
6262 { method: "get", path: `${REPO}/pulls`, summary: "List a repository's pulls", auth: false, query: LIST_QUERY },
6363 {
6464 method: "post",
@@ -150,9 +150,11 @@ export function openapi(origin: string, version: string): Record<string, unknown
150150 schema:
151151 name === "state"
152152 ? { type: "string", enum: ["open", "closed", "all"] }
153 : name === "diff"
154 ? { type: "string", enum: ["0", "1"], default: "1" }
155 : schema(t),
153 : name === "fields"
154 ? { type: "string", enum: ["brief"] }
155 : name === "diff"
156 ? { type: "string", enum: ["0", "1"], default: "1" }
157 : schema(t),
156158 }));
157159 (paths[r.path] ??= {})[r.method] = {
158160 operationId: `${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}`,
src/http/api.ts +25 −1
@@ -564,6 +564,24 @@ function withLabelsMany<T extends Issue>(ctx: Ctx, issues: T[]) {
564564 }));
565565 }
566566
567 /** An issue for a listing: what to pick from, without bodies. */
568 function briefIssue(i: ReturnType<typeof withLabelsMany>[number]) {
569 return {
570 number: i.number,
571 kind: i.kind,
572 title: i.title,
573 state: i.state,
574 labels: i.labels,
outdated

Nit, not blocking: the full item has assigneeIsAgent next to assigneeName, but brief keeps only authorIsAgent. An agent choosing what to pick up may want to know whether the assignee is an agent. Consider adding assigneeIsAgent for symmetry.

575 assignee: i.assigneeName,
576 author: i.originAuthor ?? i.authorName,

Low: a full item calls this field authorName, and here it becomes author, also folded with originAuthor. A client that switches to fields=brief has to rename the field. Consider matching the full listing's names so brief items are a plain subset.

577 originAuthor: i.originAuthor,
578 ...(i.untrusted === undefined ? {} : { untrusted: i.untrusted }),
579 authorIsAgent: i.authorIsAgent,
580 createdAt: i.createdAt,
581 updatedAt: i.updatedAt,
582 };
583 }
584
567585 const NO_COUNTS: RepoCounts = { openIssues: 0, openPulls: 0 };
568586
569587 /**
@@ -2983,6 +3001,9 @@ export function apiRoutes(router: Router<Handler>): void {
29833001 router.add("GET", "/api/v1/repos/:owner/:name/issues", (ctx) => {
29843002 const repo = loadRepo(ctx);
29853003 if (!repo) return notFound();
outdated

The route now reads fields, but src/contract/openapi.ts still describes this route with the shared LIST_QUERY, so /openapi.json doesn't offer fields=brief or its 422. Add fields to the issues GET's query in the contract and to the issues case in test/openapi.test.ts. That test is the one that holds the contract to "the query the handlers read".

3004 const fields = ctx.url.searchParams.get("fields");

This query is a new public door, so describe it in the README next to ?diff=0 (README.md:211): the fields a brief item carries, and the 422 invalid_fields for any other value.

3005 if (fields !== null && fields !== "brief")
3006 return apiError("invalid_fields", 422, "fields must be brief, or left out");
29863007 const query = listQuery(ctx);
29873008 const items = withLabelsMany(
29883009 ctx,
@@ -2992,7 +3013,10 @@ export function apiRoutes(router: Router<Handler>): void {
29923013 ...sealedScope(ctx.app.db, repo, ctx.auth.user),
29933014 }),
29943015 );
2995 return json(paged(items, query.limit, (i) => i.number));
3016 const page = paged(items, query.limit, (i) => i.number);
3017 return json(
3018 fields === "brief" ? { ...page, items: page.items.map(briefIssue) } : page,
3019 );
29963020 });
29973021
29983022 router.add("POST", "/api/v1/repos/:owner/:name/issues", async (ctx) => {
test/api-reader-marks.test.ts +17
@@ -189,6 +189,23 @@ describe("a reader's words over the API carry the brief's mark", () => {
189189 expect(listed.untrusted).toBe(READER);
190190 });
191191
192 test("a brief listing keeps the mark and shows an imported author as such", async () => {
193 const imported = ok(
194 openIssue(app, alice, repo, { title: "From elsewhere", body: "x" }),
195 );
196 app.db
197 .query("UPDATE issue SET origin_author = 'octocat' WHERE id = ?")
198 .run(imported.id);
199 const list = await get("/issues?fields=brief");
200 const byNumber = (n: number) =>
201 list.items.find((i: { number: number }) => i.number === n);
202 expect(byNumber(issueNumber).untrusted).toBe(READER);
203 expect(byNumber(issueNumber).originAuthor).toBeNull();
204 expect(byNumber(imported.number).untrusted).toBe(READER);
205 expect(byNumber(imported.number).author).toBe("octocat");
206 expect(byNumber(imported.number).originAuthor).toBe("octocat");
207 });
208
192209 test("a pull: its body, its verdicts and its line comments", async () => {
193210 const pull = await get(`/pulls/${pullNumber}`);
194211 expect(pull.untrusted).toBe(READER);
test/issues-brief.test.ts added +82
@@ -0,0 +1,82 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { seedAdmin } from "./helpers/admin.ts";
8
9 describe("issues list ?fields=brief", () => {
10 let dataDir: string;
11 let app: App;
12 let servers: Servers;
13 let base: string;
14 let token = "";
15
16 const send = (path: string, method = "GET", bodyObj?: unknown) =>
17 fetch(`${base}${path}`, {
18 method,
19 headers: {
20 authorization: `Bearer ${token}`,
21 "content-type": "application/json",
22 },
23 ...(bodyObj === undefined ? {} : { body: JSON.stringify(bodyObj) }),
24 });
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-brief-"));
28 app = createApp({ dataDir, port: 0 });
29 servers = await serve(app);
30 base = servers.url;
31 await seedAdmin(app, { username: "dev", email: "[email protected]", password: "brief-pass-1" });
32 token = await fetch(`${base}/api/v1/tokens`, {
33 method: "POST",
34 headers: { "content-type": "application/json" },
35 body: JSON.stringify({
36 adminConfirm: true,
37 username: "dev",
38 password: "brief-pass-1",
39 name: "c",
40 scope: "rw",
41 }),
42 })
43 .then((r) => r.json())
44 .then((j) => (j as { token: string }).token);
45 await send("/api/v1/repos", "POST", { name: "lib", visibility: "public" });
46 const body = "A long description of the problem. ".repeat(40);
47 for (let i = 0; i < 50; i++)
48 await send("/api/v1/repos/dev/lib/issues", "POST", { title: `Issue ${i}`, body });
49 });
50
51 afterAll(async () => {
52 await servers.stop();
53 await rm(dataDir, { recursive: true, force: true });
54 });
55
56 test("brief drops bodies and is under a fifth of the bytes", async () => {
57 const path = "/api/v1/repos/dev/lib/issues?limit=50";
58 const full = await (await send(path)).text();
59 const briefRes = await send(`${path}&fields=brief`);
60 expect(briefRes.status).toBe(200);
61 const brief = await briefRes.text();
62 const items = (JSON.parse(brief) as { items: Record<string, unknown>[] }).items;
63 expect(items).toHaveLength(50);
64 expect(JSON.parse(full).items[0].body).toBeString();
65 for (const item of items) {
66 expect(Object.keys(item).sort()).toEqual(
67 [
68 "assignee", "author", "authorIsAgent", "createdAt", "kind", "labels",
69 "number", "originAuthor", "state", "title", "updatedAt",
70 ].sort(),
71 );
72 }
73 expect(brief.length).toBeLessThan(full.length / 5);
74 });
75
76 test("any other fields value is a 422", async () => {
77 for (const v of ["full", "", "brief,body"]) {
78 const res = await send(`/api/v1/repos/dev/lib/issues?fields=${v}`);

Low: also assert (await res.json()).error === "invalid_fields". The README and OpenAPI promise that code, and a test should pin it.

79 expect(res.status).toBe(422);
80 }
81 });
82 });
test/openapi.test.ts +3 −1
@@ -54,7 +54,9 @@ test("listing issues and pulls is described with the query the handlers read", (
5454 const get = doc.paths[`${R}/${kind}`].get;
5555 expect(get.security).toBeUndefined();
5656 const q = get.parameters.filter((p: any) => p.in === "query");
57 expect(q.map((p: any) => p.name)).toEqual(["state", "limit", "cursor", "label", "author"]);
57 const base = ["state", "limit", "cursor", "label", "author"];
58 expect(q.map((p: any) => p.name)).toEqual(kind === "issues" ? [...base, "fields"] : base);
59 if (kind === "issues") expect(q.find((p: any) => p.name === "fields").schema.enum).toEqual(["brief"]);
5860 expect(q.find((p: any) => p.name === "limit").schema.type).toBe("integer");
5961 expect(q.find((p: any) => p.name === "state").schema.enum).toEqual(["open", "closed", "all"]);
6062 expect(get.parameters.filter((p: any) => p.in === "path").length).toBe(2);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs