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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6198-get-an-issue-embeds-its-newest-50-commen → main 2 commits fork 3 threads · 3 unresolved
docs/MCP.md +1 −1
| @@ -91,7 +91,7 @@ for a tunnel and a forge that sleeps. | ||
| 91 | 91 | | `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 | |
| 92 | 92 | | `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 | |
| 93 | 93 | | `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 | | | |
| 95 | 95 | | `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 | |
| 96 | 96 | | `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 | |
| 97 | 97 | | `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 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 { | ||
| 665 | 665 | kind?: "comment" | "review" | "all"; |
| 666 | 666 | /** Only comments after this id (ids sort oldest first). */ |
| 667 | 667 | after?: string; |
| 668 | /** Only comments before this id. */ | |
| 669 | before?: string; | |
| 670 | /** With `limit`: the newest ones, still answered oldest first. */ | |
| 671 | newest?: boolean; | |
| 668 | 672 | /** At most this many. */ |
| 669 | 673 | limit?: number; |
| 670 | 674 | } |
| 671 | 675 | |
| 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 | ||
| 672 | 679 | /** How many timeline items a thread page shows before it folds the earlier ones behind `?all=1`. */ |
| 673 | 680 | export const THREAD_WINDOW = 50; |
| 674 | 681 | |
| @@ -699,6 +706,11 @@ export function listComments( | ||
| 699 | 706 | kind === "all" |
| 700 | 707 | ? "comment.issue_id = ?1" |
| 701 | 708 | : `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; | |
| 702 | 714 | const rows = db |
| 703 | 715 | .query< |
| 704 | 716 | { |
| @@ -711,15 +723,16 @@ export function listComments( | ||
| 711 | 723 | origin_author: string | null; |
| 712 | 724 | heard_ok: string | null; |
| 713 | 725 | }, |
| 714 | [string] | |
| 726 | string[] | |
| 715 | 727 | >( |
| 716 | 728 | `SELECT comment.*, user.username AS author_name, heard.id AS heard_ok |
| 717 | 729 | FROM comment JOIN user ON user.id = comment.author_id |
| 718 | 730 | 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)}`}`, | |
| 721 | 733 | ) |
| 722 | .all(...((options.after === undefined ? [issueId] : [issueId, options.after]) as [string])); | |
| 734 | .all(...args); | |
| 735 | if (newest) rows.reverse(); | |
| 723 | 736 | return rows.map((row) => ({ |
| 724 | 737 | id: row.id, |
| 725 | 738 | issueId: row.issue_id, |
| @@ -746,6 +759,15 @@ export function countComments(db: Database, issueId: string): number { | ||
| 746 | 759 | return row?.n ?? 0; |
| 747 | 760 | } |
| 748 | 761 | |
| 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 | ||
| 749 | 771 | // ---- labels ---- |
| 750 | 772 | |
| 751 | 773 | export interface Label { |
src/http/api.ts +35 −11
| @@ -173,6 +173,8 @@ import { | ||
| 173 | 173 | labelsFor, |
| 174 | 174 | labelsForIssues, |
| 175 | 175 | listComments, |
| 176 | countThread, | |
| 177 | EMBEDDED_COMMENTS, | |
| 176 | 178 | listIssues, |
| 177 | 179 | LIST_LIMIT, |
| 178 | 180 | LIST_LIMIT_MAX, |
| @@ -2676,23 +2678,23 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2676 | 2678 | const found = timePhase(ctx.request, "issue", () => loadConversation(ctx)); |
| 2677 | 2679 | if ("error" in found) return found.error; |
| 2678 | 2680 | 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). | |
| 2680 | 2683 | const asked = ctx.url.searchParams.get("comments"); |
| 2681 | let tail: number | null = null; | |
| 2684 | let tail = EMBEDDED_COMMENTS; | |
| 2682 | 2685 | if (asked !== null) { |
| 2683 | 2686 | if (!/^\d{1,3}$/.test(asked) || Number(asked) > 200) |
| 2684 | 2687 | return apiError("invalid_comments", 422, "comments must be a whole number from 0 to 200"); |
| 2685 | 2688 | tail = Number(asked); |
| 2686 | 2689 | } |
| 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 }); | |
| 2689 | 2695 | 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), | |
| 2696 | 2698 | }; |
| 2697 | 2699 | }); |
| 2698 | 2700 | const labelled = timePhase(ctx.request, "labels", () => withLabels(ctx, issue)); |
| @@ -2705,7 +2707,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2705 | 2707 | } |
| 2706 | 2708 | : {}), |
| 2707 | 2709 | 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 }), | |
| 2709 | 2713 | }), |
| 2710 | 2714 | ); |
| 2711 | 2715 | }); |
| @@ -2723,6 +2727,26 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2723 | 2727 | const after = ctx.url.searchParams.get("after"); |
| 2724 | 2728 | if (after !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(after)) |
| 2725 | 2729 | 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 | } | |
| 2726 | 2750 | const { rows, page } = timePhase(ctx.request, "comments", () => { |
| 2727 | 2751 | const read = listComments(ctx.app.db, found.issue.id, { |
| 2728 | 2752 | limit: limit + 1, |
src/http/mcp/tools/issue.ts +6 −3
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { isCrew } from "../../../core/agents.ts"; |
| 4 | 4 | import { dependenciesOf } from "../../../core/dependencies.ts"; |
| 5 | 5 | import { getUsersByIds } from "../../../core/users.ts"; |
| @@ -8,7 +8,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 8 | 8 | |
| 9 | 9 | export const tool: Tool = { |
| 10 | 10 | 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", | |
| 12 | 12 | description: |
| 13 | 13 | "One issue with comments, `blockedBy`, `blocks`; non-writers' text is `untrusted`.", |
| 14 | 14 | inputSchema: { |
| @@ -40,7 +40,8 @@ export const tool: Tool = { | ||
| 40 | 40 | items: readonly T[], |
| 41 | 41 | ): (T & { untrusted?: string })[] => |
| 42 | 42 | 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, |
||
| 44 | const total = countThread(db, issue.id); | |
| 44 | 45 | const authors = getUsersByIds(db, [...new Set(comments.map((c) => c.authorId))]); |
| 45 | 46 | const deps = dependenciesOf(db, issue.id); |
| 46 | 47 | const brief = (i: typeof issue) => ({ |
| @@ -71,6 +72,8 @@ export const tool: Tool = { | ||
| 71 | 72 | at: c.createdAt, |
| 72 | 73 | ...flag(c), |
| 73 | 74 | })), |
| 75 | commentsTotal: total, | |
| 76 | commentsNext: comments.length > 0 && comments.length < total ? comments[0]!.id : null, | |
| 74 | 77 | blockedBy: deps.blockedBy.filter(seen).map(brief), |
| 75 | 78 | blocks: deps.blocks.filter(seen).map(brief), |
| 76 | 79 | }); |
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 |
||
| 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 () => { | ||
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | 37 | 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 | }; | |
| 39 | 44 | |
| 40 | 45 | test("comments=5 answers the newest five, oldest first, with the total", async () => { |
| 41 | 46 | const res = await read("?comments=5"); |
| @@ -65,8 +70,17 @@ test("anything but 0 to 200 is a 422 invalid_comments", async () => { | ||
| 65 | 70 | } |
| 66 | 71 | }); |
| 67 | 72 | |
| 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 () => { | |
| 69 | 74 | const body = (await (await read("")).json()) as Body; |
| 70 | 75 | expect(body.comments.length).toBe(30); |
| 71 | 76 | 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(); | |
| 72 | 86 | }); |
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 | }); |
With
comments=0and a non-empty thread,commentsNextisnull. The OpenAPI summary saysnullmeans "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.