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

GET an issue embeds its newest 50 comments and a cursor for the rest: #334's answer under 100 KB #7164

Merged opened this

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

Merged

scout/forge:scout/6198-get-an-issue-embeds-its-newest-50-commen → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6198-get-an-issue-embeds-its-newest-50-commen was merged into main as effc4fb.

Review

Sign in to review this change.
9 files changed +230 −23
docs/MCP.md +1 −1
@@ -91,7 +91,7 @@ for a tunnel and a forge that sleeps.
9191 | `hold` | `{ app, pull | path, why?, until?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; or with `path` (no `pull`) hold new work on a file so the picker starts no build whose issue names it, or list the holds and who set each; an admin of the app; plans unless `dryRun: false` | yes |
9292 | `hooks` | `{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; an admin-scope token on a repository you administer, and a secret is never shown | yes |
9393 | `invites` | `{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read | yes |
94 | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | |
94 | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, the newest 50 comments (author, authorIsAgent, body, at), `commentsTotal` and `commentsNext` (older ones: `GET …/comments?cursor=`), and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | |
9595 | `issue_teams` | `{ repo, number, teams? }`: the crew teams an issue names; with `teams` (at most two slugs, `[]` clears) names them, and each reviews the closing pull | yes |
9696 | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes |
9797 | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes |
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumber.ts +1 −1
@@ -1,3 +1,3 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/issues/{number}`, summary: "Read one issue with its comments; comments=N (0 to 200) answers only the newest N and commentCount, any other value is a 422 invalid_comments", auth: false, query: { comments: "integer" } };
3 export const route: Route = { method: "get", path: `${REPO}/issues/{number}`, summary: "Read one issue with its newest 50 comments, oldest first, commentsTotal and commentsNext (a cursor for …/comments?cursor=, null when no older comment is left to fetch, and with comments=0, which shows none: read …/comments); comments=N (0 to 200) asks for another count, adds commentCount, any other value is a 422 invalid_comments", auth: false, query: { comments: "integer" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumberComments.ts +1 −1
@@ -1,3 +1,3 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id", auth: false, query: { after: "string", limit: "integer" } };
3 export const route: Route = { method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id; cursor (an issue's commentsNext) answers the page of older comments before it, next being the cursor for older still", auth: false, query: { after: "string", cursor: "string", limit: "integer" } };
src/core/issues.ts +26 −4
@@ -665,10 +665,17 @@ export interface ListCommentsOptions {
665665 kind?: "comment" | "review" | "all";
666666 /** Only comments after this id (ids sort oldest first). */
667667 after?: string;
668 /** Only comments before this id. */
669 before?: string;
670 /** With `limit`: the newest ones, still answered oldest first. */
671 newest?: boolean;
668672 /** At most this many. */
669673 limit?: number;
670674 }
671675
676 /** How many of a thread's newest comments an issue read embeds, on the API and in the MCP (#6198). */
677 export const EMBEDDED_COMMENTS = 50;
678
672679 /** How many timeline items a thread page shows before it folds the earlier ones behind `?all=1`. */
673680 export const THREAD_WINDOW = 50;
674681
@@ -699,6 +706,11 @@ export function listComments(
699706 kind === "all"
700707 ? "comment.issue_id = ?1"
701708 : `comment.issue_id = ?1 AND comment.kind = '${kind}'`;
709 const args: string[] = [issueId];
710 let range = "";
711 if (options.after !== undefined) range += ` AND comment.id > ?${args.push(options.after)}`;
712 if (options.before !== undefined) range += ` AND comment.id < ?${args.push(options.before)}`;
713 const newest = options.newest === true && options.limit !== undefined;
702714 const rows = db
703715 .query<
704716 {
@@ -711,15 +723,16 @@ export function listComments(
711723 origin_author: string | null;
712724 heard_ok: string | null;
713725 },
714 [string]
726 string[]
715727 >(
716728 `SELECT comment.*, user.username AS author_name, heard.id AS heard_ok
717729 FROM comment JOIN user ON user.id = comment.author_id
718730 LEFT JOIN run heard ON heard.id = comment.heard_in AND heard.state = 'ok'
719 WHERE ${where}${options.after === undefined ? "" : " AND comment.id > ?2"}
720 ORDER BY comment.id${options.limit === undefined ? "" : ` LIMIT ${Math.trunc(options.limit)}`}`,
731 WHERE ${where}${range}
732 ORDER BY comment.id${newest ? " DESC" : ""}${options.limit === undefined ? "" : ` LIMIT ${Math.trunc(options.limit)}`}`,
721733 )
722 .all(...((options.after === undefined ? [issueId] : [issueId, options.after]) as [string]));
734 .all(...args);
735 if (newest) rows.reverse();
723736 return rows.map((row) => ({
724737 id: row.id,
725738 issueId: row.issue_id,
@@ -746,6 +759,15 @@ export function countComments(db: Database, issueId: string): number {
746759 return row?.n ?? 0;
747760 }
748761
762 /** Every comment a thread holds, review lines included: what the API's `commentsTotal` says. */
763 export function countThread(db: Database, issueId: string): number {
764 return (
765 db
766 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM comment WHERE issue_id = ?1")
767 .get(issueId)?.n ?? 0
768 );
769 }
770
749771 // ---- labels ----
750772
751773 export interface Label {
src/http/api.ts +35 −11
@@ -173,6 +173,8 @@ import {
173173 labelsFor,
174174 labelsForIssues,
175175 listComments,
176 countThread,
177 EMBEDDED_COMMENTS,
176178 listIssues,
177179 LIST_LIMIT,
178180 LIST_LIMIT_MAX,
@@ -2676,23 +2678,23 @@ export function apiRoutes(router: Router<Handler>): void {
26762678 const found = timePhase(ctx.request, "issue", () => loadConversation(ctx));
26772679 if ("error" in found) return found.error;
26782680 const { repo, issue } = found;
2679 // comments=N answers the newest N, oldest first, and the total (#4097).
2681 // The newest 50 comments, oldest first, and a cursor for the older rest
2682 // (#6198); comments=N (0 to 200) asks for another count (#4097).
26802683 const asked = ctx.url.searchParams.get("comments");
2681 let tail: number | null = null;
2684 let tail = EMBEDDED_COMMENTS;
26822685 if (asked !== null) {
26832686 if (!/^\d{1,3}$/.test(asked) || Number(asked) > 200)
26842687 return apiError("invalid_comments", 422, "comments must be a whole number from 0 to 200");
26852688 tail = Number(asked);
26862689 }
2687 const { all, shown } = timePhase(ctx.request, "comments", () => {
2688 const rows = listComments(ctx.app.db, found.issue.id);
2690 const { total, shown } = timePhase(ctx.request, "comments", () => {
2691 const rows =
2692 tail === 0
2693 ? []
2694 : listComments(ctx.app.db, found.issue.id, { newest: true, limit: tail });
26892695 return {
2690 all: rows,
2691 shown: readerMarked(
2692 ctx,
2693 found.repo.id,
2694 tail === null ? rows : tail === 0 ? [] : rows.slice(-tail),
2695 ),
2696 total: countThread(ctx.app.db, found.issue.id),
2697 shown: readerMarked(ctx, found.repo.id, rows),
26962698 };
26972699 });
26982700 const labelled = timePhase(ctx.request, "labels", () => withLabels(ctx, issue));
@@ -2705,7 +2707,9 @@ export function apiRoutes(router: Router<Handler>): void {
27052707 }
27062708 : {}),
27072709 comments: shown,
2708 ...(tail === null ? {} : { commentCount: all.length }),
2710 commentsTotal: total,
2711 commentsNext: shown.length > 0 && shown.length < total ? shown[0]!.id : null,
2712 ...(asked === null ? {} : { commentCount: total }),
27092713 }),
outdated

With comments=0 and a non-empty thread, commentsNext is null. The OpenAPI summary says null means "all are shown", and here none are. Either hand over a cursor that pages from the newest comment, or change the summary to match, and pin whichever you choose in the tail test.

27102714 );
27112715 });
@@ -2723,6 +2727,26 @@ export function apiRoutes(router: Router<Handler>): void {
27232727 const after = ctx.url.searchParams.get("after");
27242728 if (after !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(after))
27252729 return apiError("invalid_after", 422, "after is a comment id");
2730 // `cursor` is what an issue's `commentsNext` hands over: the page just
2731 // older than that comment, still oldest first, and `next` the cursor of
2732 // the page older again (null at the first comment) (#6198).
2733 const cursor = ctx.url.searchParams.get("cursor");
2734 if (cursor !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(cursor))
2735 return apiError("invalid_cursor", 422, "cursor is a comment id from commentsNext");
2736 if (cursor !== null && after !== null)
2737 return apiError("invalid_cursor", 422, "cursor and after are two ways to page: send one");
2738 if (cursor !== null) {
2739 const older = timePhase(ctx.request, "comments", () =>
2740 listComments(ctx.app.db, found.issue.id, { before: cursor, newest: true, limit: limit + 1 }),
2741 );
2742 const page = older.slice(older.length - Math.min(limit, older.length));
2743 return timePhase(ctx.request, "render", () =>
2744 json({
2745 comments: readerMarked(ctx, found.repo.id, page),
2746 next: older.length > limit ? page[0]!.id : null,
2747 }),
2748 );
2749 }
27262750 const { rows, page } = timePhase(ctx.request, "comments", () => {
27272751 const read = listComments(ctx.app.db, found.issue.id, {
27282752 limit: limit + 1,
src/http/mcp/tools/issue.ts +6 −3
@@ -1,5 +1,5 @@
11 import { mayView } from "../../../core/sealed.ts";
2 import { getIssue, labelsFor, listComments } from "../../../core/issues.ts";
2 import { countThread, EMBEDDED_COMMENTS, getIssue, labelsFor, listComments } from "../../../core/issues.ts";
33 import { isCrew } from "../../../core/agents.ts";
44 import { dependenciesOf } from "../../../core/dependencies.ts";
55 import { getUsersByIds } from "../../../core/users.ts";
@@ -8,7 +8,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
88
99 export const tool: Tool = {
1010 name: "issue",
11 doc: "`{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments",
11 doc: "`{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, the newest 50 comments (author, authorIsAgent, body, at), `commentsTotal` and `commentsNext` (older ones: `GET …/comments?cursor=`), and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments",
1212 description:
1313 "One issue with comments, `blockedBy`, `blocks`; non-writers' text is `untrusted`.",
1414 inputSchema: {
@@ -40,7 +40,8 @@ export const tool: Tool = {
4040 items: readonly T[],
4141 ): (T & { untrusted?: string })[] =>
4242 marking ? markReaders(db, repo, items) : [...items];
43 const comments = mark(listComments(db, issue.id));
43 const comments = mark(listComments(db, issue.id, { newest: true, limit: EMBEDDED_COMMENTS }));
outdated

This cap (and the two fields at line 76) has no test: revert this file and every suite stays green. Add an MCP test with more than 50 comments that asserts the count, the order, commentsTotal, and that commentsNext works as an API cursor. Also, the 50 here repeats EMBEDDED_COMMENTS in api.ts. Export one constant and use it in both places, so the two doors can't drift apart.

44 const total = countThread(db, issue.id);
4445 const authors = getUsersByIds(db, [...new Set(comments.map((c) => c.authorId))]);
4546 const deps = dependenciesOf(db, issue.id);
4647 const brief = (i: typeof issue) => ({
@@ -71,6 +72,8 @@ export const tool: Tool = {
7172 at: c.createdAt,
7273 ...flag(c),
7374 })),
75 commentsTotal: total,
76 commentsNext: comments.length > 0 && comments.length < total ? comments[0]!.id : null,
7477 blockedBy: deps.blockedBy.filter(seen).map(brief),
7578 blocks: deps.blocks.filter(seen).map(brief),
7679 });
test/api-issue-comments-embed.test.ts added +66
@@ -0,0 +1,66 @@
1 /** An issue read embeds its newest 50 comments and a cursor for the rest (#6198). */
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, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { addComment, createIssue } from "../src/core/issues.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11
12 let dataDir = "";
13 let app: App, servers: Servers;
14 let number = 0;
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-commentsembed-"));
18 app = createApp({ dataDir, port: 0 });
19 servers = await serve(app);
20 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
21 if (!admin.ok) throw new Error(admin.error);
22 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
23 if (!made.ok) throw new Error(String(made.error));
24 const issue = createIssue(app.db, { repoId: made.value.id, title: "long", body: "b", authorId: admin.value.id });
25 if (!issue.ok) throw new Error(String(issue.error));
26 number = issue.value.number;
27 for (let i = 1; i <= 300; i++)
28 addComment(app.db, { issueId: issue.value.id, authorId: admin.value.id, body: `c${i} ${"x".repeat(1500)}` });
29 });
30
31 afterAll(async () => {
32 servers.stop();
33 app.close();
34 await rm(dataDir, { recursive: true, force: true });
35 });
36
37 type C = { id: string; body: string };
38 const base = () => `${servers.url}/api/v1/repos/alice/notes/issues/${number}`;
39 const n = (c: C) => Number(c.body.slice(1).split(" ")[0]);
40
41 test("300 comments: 50 embedded under 100 KB, and the cursor walks back to the first", async () => {
42 const res = await fetch(base());
43 const text = await res.text();
44 expect(text.length).toBeLessThan(100_000);
45 const body = JSON.parse(text) as { comments: C[]; commentsTotal: number; commentsNext: string | null };
46 expect(body.comments.length).toBe(50);
47 expect(body.commentsTotal).toBe(300);
48 expect(body.comments.map(n)).toEqual(Array.from({ length: 50 }, (_, i) => 251 + i));
49 let cursor = body.commentsNext;
50 const seen = [...body.comments];
51 let pages = 0;
52 while (cursor !== null && pages < 10) {
outdated

If next never becomes null, this loop runs until the test times out instead of failing fast. Bound it, for example while (cursor !== null && pages < 10); the pages assertion below will still catch a runaway. Separately, the renamed tail test promises "with the total" but never asserts commentsTotal. Add expect(body.commentsTotal).toBe(30) and expect(body.commentsNext).toBeNull() there.

53 const page = (await (await fetch(`${base()}/comments?cursor=${cursor}`)).json()) as { comments: C[]; next: string | null };
54 expect(page.comments.length).toBe(50);
55 seen.unshift(...page.comments);
56 cursor = page.next;
57 pages++;
58 }
59 expect(pages).toBe(5);
60 expect(seen.map(n)).toEqual(Array.from({ length: 300 }, (_, i) => i + 1));
61 });
62
63 test("a bad cursor is a 422, and cursor with after is refused", async () => {
64 expect((await fetch(`${base()}/comments?cursor=a%20b`)).status).toBe(422);
65 expect((await fetch(`${base()}/comments?cursor=x&after=y`)).status).toBe(422);
66 });
test/api-issue-comments-tail.test.ts +16 −2
@@ -35,7 +35,12 @@ afterAll(async () => {
3535 });
3636
3737 const read = (q: string) => fetch(`${servers.url}/api/v1/repos/alice/notes/issues/${number}${q}`);
38 type Body = { comments: { body: string }[]; commentCount?: number };
38 type Body = {
39 comments: { id: string; body: string }[];
40 commentCount?: number;
41 commentsTotal: number;
42 commentsNext: string | null;
43 };
3944
4045 test("comments=5 answers the newest five, oldest first, with the total", async () => {
4146 const res = await read("?comments=5");
@@ -65,8 +70,17 @@ test("anything but 0 to 200 is a 422 invalid_comments", async () => {
6570 }
6671 });
6772
68 test("without the parameter all comments come back and no commentCount", async () => {
73 test("without the parameter the newest 50 come back with the total and no commentCount", async () => {
6974 const body = (await (await read("")).json()) as Body;
7075 expect(body.comments.length).toBe(30);
7176 expect("commentCount" in body).toBe(false);
77 expect(body.commentsTotal).toBe(30);
78 expect(body.commentsNext).toBeNull();
79 });
80
81 test("comments=5 hands a cursor to the older ones; comments=0 hands none", async () => {
82 const some = (await (await read("?comments=5")).json()) as Body;
83 expect(some.commentsNext).toBe(some.comments[0]!.id);
84 const none = (await (await read("?comments=0")).json()) as Body;
85 expect(none.commentsNext).toBeNull();
7286 });
test/mcp-issue-comments.test.ts added +78
@@ -0,0 +1,78 @@
1 /** MCP `issue` embeds the newest 50 comments, the total and a cursor the API honours (#6198). */
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, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { addComment, createIssue, EMBEDDED_COMMENTS } from "../src/core/issues.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { seedAdmin } from "./helpers/admin.ts";
12
13 let dataDir = "";
14 let app: App, servers: Servers;
15 let token = "";
16 let number = 0;
17 const SEEDED = 120;
18
19 beforeAll(async () => {
20 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-issue-comments-"));
21 app = createApp({ dataDir, port: 0 });
22 servers = await serve(app);
23 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
24 if (!admin.ok) throw new Error(admin.error);
25 token = createToken(app.db, admin.value.id, "rw", "rw").token;
26 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
27 if (!made.ok) throw new Error(String(made.error));
28 const issue = createIssue(app.db, { repoId: made.value.id, title: "long", body: "b", authorId: admin.value.id });
29 if (!issue.ok) throw new Error(String(issue.error));
30 number = issue.value.number;
31 for (let i = 1; i <= SEEDED; i++)
32 addComment(app.db, { issueId: issue.value.id, authorId: admin.value.id, body: `c${i}` });
33 });
34
35 afterAll(async () => {
36 servers.stop();
37 app.close();
38 await rm(dataDir, { recursive: true, force: true });
39 });
40
41 test("issue answers the newest 50 oldest first, the total, and a working cursor", async () => {
42 const res = await fetch(`${servers.url}/mcp`, {
43 method: "POST",
44 headers: {
45 "content-type": "application/json",
46 accept: "application/json, text/event-stream",
47 authorization: `Bearer ${token}`,
48 },
49 body: JSON.stringify({
50 jsonrpc: "2.0",
51 id: 1,
52 method: "tools/call",
53 params: { name: "issue", arguments: { repo: "alice/notes", number } },
54 }),
55 });
56 const out: any = await res.json();
57 expect(out.result.isError).not.toBe(true);
58 const issue = JSON.parse(out.result.content[0].text) as {
59 comments: { body: string }[];
60 commentsTotal: number;
61 commentsNext: string | null;
62 };
63 expect(issue.comments.length).toBe(EMBEDDED_COMMENTS);
64 expect(issue.comments[0]!.body).toBe(`c${SEEDED - EMBEDDED_COMMENTS + 1}`);
65 expect(issue.comments.at(-1)!.body).toBe(`c${SEEDED}`);
66 expect(issue.commentsTotal).toBe(SEEDED);
67 // The cursor is the first embedded comment's id: the API's page before it ends just under it.
68 const first = (await (await fetch(`${servers.url}/api/v1/repos/alice/notes/issues/${number}`)).json()) as {
69 comments: { id: string }[];
70 };
71 expect(issue.commentsNext).toBe(first.comments[0]!.id);
72 const page = (await (
73 await fetch(`${servers.url}/api/v1/repos/alice/notes/issues/${number}/comments?cursor=${issue.commentsNext}`)
74 ).json()) as { comments: { body: string }[]; next: string | null };
75 expect(page.comments.at(-1)!.body).toBe(`c${SEEDED - EMBEDDED_COMMENTS}`);
76 expect(page.comments[0]!.body).toBe("c21");
77 expect(page.comments.length).toBe(50);
78 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs