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

MCP `pulls` with no repo answers every open pull you can read, with verdict and why it sits; a pull's comments are one read #7738

Merged opened this

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

Merged

scout/forge:scout/7655-mcp-pulls-with-no-repo-answers-every-ope → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/7655-mcp-pulls-with-no-repo-answers-every-ope was merged into main as 8bfbf33.

Review

Sign in to review this change.
6 files changed +286 −13
docs/MCP.md +2 −2
@@ -133,8 +133,8 @@ for a tunnel and a forge that sleeps.
133133 | `prompts` | `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes |
134134 | `proposal_tokens` | `{ repo, verb, child?, origin?, childRepo?, id? }`: give a child forge a propose-only credential: `mint` one (`child`, `origin`), `list` them, or `revoke` one by `id`; writers only | yes |
135135 | `propose_to_parent_forge` | `{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull | yes |
136 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | |
137 | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | |
136 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | |
137 | `pulls` | `{ repo?, state?, limit?, cursor? }`: without `repo`, every open pull you may read, oldest first, as `{ pulls, next_cursor }` (limit 1-200, default 50): `repo`, `number`, `title`, `check`, `verdict` (approved, changes_requested or none) and `gate` (what holds it, or null; the `why` module's cause); with `repo`, a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | |
138138 | `queue_gates` | `{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky` | |
139139 | `quotas` | `{ key?, value?, dryRun? }` (forge admin): the per-account quotas `repositories`, `apps`, `storage` (MB), `previews`, `runs`, `checks`, each a number or null for none. `repositories` bounds repos on create, fork and copy; `apps` the apps made; `storage` the MB of repos plus app data, asked on push, create and fork; `previews` live previews; `runs` concurrent agent runs; `checks` concurrent checks. Each is asked before the thing is made or started and a forge admin is never limited; with `key` and `value` (`""` clears), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes |
140140 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says} | |
src/core/pulls.ts +31
@@ -522,6 +522,37 @@ export function openPullsForRepos(
522522 return out;
523523 }
524524
525 /** An open, unmerged pull as the pile lists it before it is hydrated. */
526 export interface PileRow {
527 readonly id: string;
528 readonly repoId: string;
529 readonly sealed: boolean;
530 readonly authorId: string;
531 }
532
533 /**
534 * Every open, unmerged pull on the forge, oldest first, in one query: the
535 * pile an MCP caller pages (#7655). Reading rights are the caller's to apply
536 * to the rows (`canRead`, `mayView`); no git is touched.
537 */
538 export function openPullPileRows(db: Database): PileRow[] {
539 return db
540 .query<{ id: string; repo_id: string; sealed: number; author_id: string }, []>(
541 `SELECT issue.id, issue.repo_id, issue.sealed, issue.author_id
542 FROM issue JOIN pull ON pull.issue_id = issue.id
543 WHERE issue.state = 'open' AND pull.merged_at IS NULL
544 ORDER BY issue.created_at, issue.id`,
545 )
546 .all()
547 .map((r) => ({ id: r.id, repoId: r.repo_id, sealed: r.sealed === 1, authorId: r.author_id }));
548 }
549
550 /** These pulls by issue id, in the order asked. */
551 export function pullsByIssueIds(db: Database, ids: readonly string[]): Pull[] {
552 const issues = getIssuesByIds(db, ids);
553 return joinMany(db, ids.flatMap((id) => issues.get(id) ?? []));
554 }
555
525556 export type ListPullsOptions = Omit<ListIssuesOptions, "kind">;
526557
527558 export function listPulls(
src/flow/why.ts +34 −3
@@ -141,15 +141,24 @@ function headroomSpent(db: Database, now: Date): boolean {
141141 );
142142 }
143143
144 function sharedFacts(app: App, repo: Repo, now: Date): Pick<WhyFacts, "ceilingUsd" | "builders" | "noHeadroom"> {
144 type Shared = Pick<WhyFacts, "ceilingUsd" | "builders" | "noHeadroom">;
145
146 /** The facts that hold for the whole forge, read once for a batch. */
147 function forgeFacts(app: App, now: Date): Pick<Shared, "ceilingUsd" | "noHeadroom"> {
145148 const cap = forgeCeiling(app.config);
146149 return {
147150 ...(cap !== null && ceilingHitAt(app.db, now, cap) !== null ? { ceilingUsd: cap } : {}),
148 builders: listCrewRoles(app.db, repo.id).filter((c) => c.role === "builder").length,
149151 noHeadroom: headroomSpent(app.db, now),
150152 };
151153 }
152154
155 const buildersOf = (app: App, repo: Repo): number =>
156 listCrewRoles(app.db, repo.id).filter((c) => c.role === "builder").length;
157
158 function sharedFacts(app: App, repo: Repo, now: Date): Shared {
159 return { ...forgeFacts(app, now), builders: buildersOf(app, repo) };
160 }
161
153162 /** Why the repository's builders are held, whatever the issue. */
154163 export function whyRepo(app: App, repo: Repo, now = new Date()): Why {
155164 return { cause: firstCause({ actor: "builder", flowOnly: repo.flowOnly, ...sharedFacts(app, repo, now) }) };
@@ -195,6 +204,7 @@ export function whyPull(
195204 pull: Pull,
196205 live: { readonly headOid: string; readonly conflicts: number },
197206 now = new Date(),
207 shared: Shared = sharedFacts(app, repo, now),
198208 ): Why {
199209 if (pull.mergedAt !== null) return { cause: null, settled: "merged" };
200210 if (pull.state !== "open") return { cause: null, settled: "closed" };
@@ -205,7 +215,28 @@ export function whyPull(
205215 actor: next.who,
206216 redCheck: next.who === "builder" && check === "failed",
207217 waitingReview: next.who === "reviewer",
208 ...sharedFacts(app, repo, now),
218 ...shared,
209219 });
210220 return { cause, next };
211221 }
222
223 /**
224 * `whyPull` for a pile of pulls (#7655): the forge-wide facts and each
225 * repository's builders are read once, not once per pull. The stored head
226 * stands for the live one and no conflict is looked for: a pile touches no git.
227 */
228 export function whyPulls(
229 app: App,
230 rows: readonly { readonly repo: Repo; readonly pull: Pull }[],
231 now = new Date(),
232 ): Why[] {
233 const forge = forgeFacts(app, now);
234 const builders = new Map<string, number>();
235 return rows.map(({ repo, pull }) => {
236 if (!builders.has(repo.id)) builders.set(repo.id, buildersOf(app, repo));
237 return whyPull(app, repo, pull, { headOid: pull.headOid, conflicts: 0 }, now, {
238 ...forge,
239 builders: builders.get(repo.id)!,
240 });
241 });
242 }
src/http/mcp/tools/pull.ts +18 −1
@@ -7,11 +7,14 @@ import { checkStale } from "../../../checks/stale.ts";
77 import { checkSummary } from "../../../core/runs.ts";
88 import { nextOfPull } from "../../../flow/next_actor.ts";
99 import { appView } from "../../../actions/apps.ts";
10 import { countThread, EMBEDDED_COMMENTS, listComments } from "../../../core/issues.ts";
11 import { isCrew } from "../../../core/agents.ts";
12 import { markReaders } from "../../../crew/direct.ts";
1013 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
1114
1215 export const tool: Tool = {
1316 name: "pull",
14 doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment",
17 doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment",
1518 description:
1619 "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.",
1720 inputSchema: {
@@ -40,7 +43,21 @@ export const tool: Tool = {
4043 pull.state === "open" &&
4144 pull.mergedAt === null &&
4245 checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`));
46 const db = ctx.app.db;
47 // Marked only for a crew agent, as the issue and the API do.
48 const marking = user.kind === "agent" && isCrew(db, repo.id, user.id);

The crew-only untrusted marking has no test; add one for a crew caller and one for a non-crew caller.

49 const rows = listComments(db, pull.id, { newest: true, limit: EMBEDDED_COMMENTS });
50 const comments = marking ? markReaders(db, repo, rows) : rows;
51 const total = countThread(db, pull.id);
4352 const answer = good({
53 comments: comments.map((c) => ({
54 author: c.authorName,
55 body: c.body,
56 at: c.createdAt,
57 ...("untrusted" in c && c.untrusted !== undefined ? { untrusted: true } : {}),
58 })),
59 commentsTotal: total,
60 commentsNext: comments.length > 0 && comments.length < total ? comments[0]!.id : null,
4461 number: pull.number,
4562 title: pull.title,
4663 state: pull.mergedAt !== null ? "merged" : pull.state,
src/http/mcp/tools/pulls.ts +52 −7
@@ -1,25 +1,31 @@
1 import { sealedScope } from "../../../core/sealed.ts";
1 import { canRead, getReposByIds } from "../../../core/repos.ts";
2 import { mayView, sealedScope } from "../../../core/sealed.ts";
23 import { reviewSummariesFor } from "../../../core/reviews.ts";
3 import { listPulls, pullDir, pullMergeStates } from "../../../core/pulls.ts";
4 import { listPulls, openPullPileRows, pullDir, pullMergeStates, pullsByIssueIds } from "../../../core/pulls.ts";
45 import { checkSummary } from "../../../core/runs.ts";
5 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6 import type { Ctx } from "../../context.ts";
7 import type { User } from "../../../core/users.ts";
8 import { whyPulls } from "../../../flow/why.ts";
9 import { pageRows } from "../page.ts";
10 import { good, bad, type Args, type Answer, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
611
712 export const tool: Tool = {
813 name: "pulls",
9 doc: "`{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs",
14 doc: "`{ repo?, state?, limit?, cursor? }`: without `repo`, every open pull you may read, oldest first, as `{ pulls, next_cursor }` (limit 1-200, default 50): `repo`, `number`, `title`, `check`, `verdict` (approved, changes_requested or none) and `gate` (what holds it, or null; the `why` module's cause); with `repo`, a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs",
1015 description:
11 "A repository's pulls, newest first. `state` open (default) or closed; `limit` 1-50.",
16 "A repository's pulls, newest first; no `repo`: all open pulls you may read, oldest first.",
1217 inputSchema: {
1318 type: "object",
1419 properties: {
1520 repo: REPO_ARG,
1621 state: { type: "string", enum: ["open", "closed"] },
17 limit: { type: "integer", minimum: 1, maximum: 50 },
22 limit: { type: "integer", minimum: 1, maximum: 200 },
23 cursor: { type: "string" },
1824 },
19 required: ["repo"],
2025 },
2126 writes: false,
2227 async run(ctx, user, args) {
28 if (args["repo"] === undefined) return pile(ctx, user, args);
2329 const repo = repoArg(ctx, user, args);
2430 if (isAnswer(repo)) return repo;
2531 const state = args["state"] ?? "open";
@@ -52,3 +58,42 @@ export const tool: Tool = {
5258 return { ...answer, card: { repo: `${repo.ownerName}/${repo.name}`, state, limit } };
5359 },
5460 };
61
62 /** Every open pull the caller may read, oldest first, one page: no git, no per-pull query beyond the check. */
63 function pile(ctx: Ctx, user: User, args: Args): Answer {
64 const db = ctx.app.db;
65 if (args["state"] !== undefined && args["state"] !== "open")
66 return bad("invalid", "without `repo` only open pulls are listed");
67 const all = openPullPileRows(db);
68 const repos = getReposByIds(db, [...new Set(all.map((x) => x.repoId))]);
69 const readable = new Map<string, boolean>();
70 const rows = all.filter((row) => {
71 const repo = repos.get(row.repoId);
72 if (repo === undefined) return false;
73 let may = readable.get(row.repoId);
74 if (may === undefined) readable.set(row.repoId, (may = canRead(db, repo, user)));
75 return may && mayView(db, repo, row, user);
76 });
77 const page = pageRows(rows, (r) => r.id, args);
78 if (!page.ok) return page;
79 const { items, next_cursor } = page.value as { items: typeof rows; next_cursor: string | null };
80 const pulls = pullsByIssueIds(db, items.map((r) => r.id));
81 const reviews = reviewSummariesFor(db, pulls);
82 const whys = whyPulls(ctx.app, pulls.map((pull) => ({ repo: repos.get(pull.repoId)!, pull })));
83 return good({
84 pulls: pulls.map((p, i) => {
85 const repo = repos.get(p.repoId)!;
86 const r = reviews.get(p.id);
87 return {
88 repo: `${repo.ownerName}/${repo.name}`,
89 number: p.number,
90 title: p.title,
91 check: checkSummary(db, p.id, p.headOid).state,
92 verdict: (r?.changesRequested ?? 0) > 0 ? "changes_requested" : (r?.approvals ?? 0) > 0 ? "approved" : "none",
93 gate: whys[i]?.cause?.name ?? null,
94 why: whys[i]?.cause?.says ?? whys[i]?.next?.why ?? null,
95 };
96 }),
97 next_cursor,
98 });
99 }
test/mcp-pulls-pile.test.ts added +149
@@ -0,0 +1,149 @@
1 /** MCP `pulls` without `repo` (#7655): the pile across readable repositories; a pull's comments are one read. */
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 { commitToBranch } from "../src/actions/repos.ts";
8 import { createPull, type Pull } from "../src/core/pulls.ts";
9 import { addComment } from "../src/core/issues.ts";
10 import { submitReview } from "../src/core/reviews.ts";
11 import { setSealed } from "../src/core/sealed.ts";
12 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
13 import { createRepo } from "../src/core/repos.ts";
14 import { repoDir } from "../src/git/repos.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, getUserByName } from "../src/core/users.ts";
17 import { addToCrew, createAgent } from "../src/actions/crew.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 const tok: Record<string, string> = {};
27 let green: Pull;
28
29 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
30 if (!r.ok) throw new Error(JSON.stringify(r.error));
31 return r.value;
32 };
33
34 async function call(token: string, name: string, args: unknown): Promise<{ error?: string; value?: any }> {
35 const res = await fetch(`${servers.url}/mcp`, {
36 method: "POST",
37 headers: {
38 "content-type": "application/json",
39 accept: "application/json, text/event-stream",
40 authorization: `Bearer ${token}`,
41 },
42 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
43 });
44 const body = JSON.parse(await res.text());
45 const out: string = body.result.content[0].text;
46 return body.result.isError === true ? { error: out } : { value: JSON.parse(out) };
47 }
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-pile-"));
51 app = createApp({ dataDir, port: 0 });
52 servers = await serve(app);
53 const make = (username: string, adminIfFirst = false) =>
54 createUser(app.db, {
55 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
56 password: "a-good-passphrase", adminIfFirst,
57 });
58 const ada = ok(await make("ada", true));
59 const rita = ok(await make("rita"));
60 const sam = ok(await make("sam"));
61 const open = async (repo: any, owner: any, b: string, author = owner): Promise<Pull> => {
62 ok(await commitToBranch(app, owner, repo, { message: b, branch: b, files: [{ path: `${b}.txt`, content: `${b}\n` }] } as any));
63 return ok(await createPull(app.db, repoDir(app.reposRoot, owner.username, repo.name), {
64 repoId: repo.id, authorId: author.id, title: `Pull ${b}`, baseBranch: "main", headBranch: b,
65 }));
66 };
67 const pub = ok(await createRepo(app.db, app.reposRoot, { ownerId: ada.id, name: "svc", visibility: "public" }));
68 const priv = ok(await createRepo(app.db, app.reposRoot, { ownerId: ada.id, name: "vault", visibility: "private" }));
69 for (const repo of [pub, priv])
70 ok(await commitToBranch(app, ada, repo, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }));
71 green = await open(pub, ada, "green");
72 await open(priv, ada, "secret");
73 const sealed = await open(pub, ada, "sealed", sam);
74 await open(pub, ada, "third");
75 setSealed(app.db, sealed.id, true);
76 ok(submitReview(app.db, { issueId: green.id, authorId: rita.id, state: "approved", commitOid: green.headOid }));
77 const run = createRun(app.db, { repoId: pub.id, kind: "check", issueId: green.id, commitOid: green.headOid });
78 claimRun(app.db, run.id);
79 finishRun(app.db, run.id, { state: "ok", summary: "green" });
80 ok(addComment(app.db, { issueId: green.id, authorId: sam.id, body: "looks fine to me" }));
81 // A crew agent on the repository: it is told which commenters are outsiders.
82 const bot = ok(await createAgent(app, ada, { username: "scoutbot", brief: "x" }));
83 ok(await addToCrew(app, ada, pub, "scoutbot"));
84 tok["bot"] = createToken(app.db, bot.user.id, "rw", "rw").token;
85 tok["ada"] = createToken(app.db, ada.id, "rw", "rw").token;
86 tok["rita"] = createToken(app.db, rita.id, "rw", "rw").token;
87 }, 30_000);
88
89 afterAll(async () => {
90 servers?.stop();
91 app?.close();
92 await rm(dataDir, { recursive: true, force: true });
93 });
94
95 test("without repo: open pulls the caller may read, oldest first, with verdict and gate", async () => {
96 const { value } = await call(tok["rita"]!, "pulls", {});
97 const titles = value.pulls.map((p: any) => p.title);
98 // The private repository and the sealed pull are absent for rita.
99 expect(titles).toEqual(["Pull green", "Pull third"]);
100 expect(value.pulls[0]).toMatchObject({ repo: "ada/svc", number: 1, check: "ok", verdict: "approved" });
101 expect(value.pulls[1]).toMatchObject({ verdict: "none", check: "none", gate: "waiting_review" });
102 expect(value.next_cursor).toBeNull();
103 // The owner reads the private repository and the sealed pull too.
104 const all = (await call(tok["ada"]!, "pulls", {})).value.pulls.map((p: any) => p.title);
105 expect(all).toEqual(["Pull green", "Pull secret", "Pull sealed", "Pull third"]);
106 });
107
108 test("pages with next_cursor and refuses a stale cursor", async () => {
109 const first = (await call(tok["ada"]!, "pulls", { limit: 3 })).value;
110 expect(first.pulls).toHaveLength(3);
111 expect(first.next_cursor).not.toBeNull();
112 const rest = (await call(tok["ada"]!, "pulls", { limit: 3, cursor: first.next_cursor })).value;
113 expect(rest.pulls.map((p: any) => p.title)).toEqual(["Pull third"]);
114 expect(rest.next_cursor).toBeNull();
115 expect((await call(tok["ada"]!, "pulls", { cursor: "nope" })).error).toContain("cursor");
116 expect((await call(tok["ada"]!, "pulls", { state: "closed" })).error).toBeDefined();
117 });
118
119 test("MCP pull carries the comments; the API read of a pull number already answered them", async () => {
120 const res = await fetch(`${servers.url}/api/v1/repos/ada/svc/issues/${green.number}/comments`, {
121 headers: { authorization: `Bearer ${tok["rita"]}` },
122 });
123 expect(res.status).toBe(200);
124 expect(((await res.json()) as any).comments.map((c: any) => c.body)).toEqual(["looks fine to me"]);
outdated

Add a case with more than 50 comments that asserts commentsTotal, the 50 returned and commentsNext.

125 const { value } = await call(tok["rita"]!, "pull", { repo: "ada/svc", number: green.number });
126 expect(value.comments).toMatchObject([{ author: "sam", body: "looks fine to me" }]);
127 expect(value.commentsTotal).toBe(1);
128 expect(value.commentsNext).toBeNull();
129 });
130
131 test("the outsider's comment is untrusted for a crew agent and unmarked for anyone else", async () => {
132 const crew = (await call(tok["bot"]!, "pull", { repo: "ada/svc", number: green.number })).value;
133 expect(crew.comments[0]).toMatchObject({ author: "sam", untrusted: true });
134 const other = (await call(tok["rita"]!, "pull", { repo: "ada/svc", number: green.number })).value;
135 expect("untrusted" in other.comments[0]).toBe(false);
136 });
137
138 test("past 50 comments the newest 50 come back with the total and the cursor of the older rest", async () => {
139 const ada = getUserByName(app.db, "ada")!;
140 const ids: string[] = [];
141 for (let n = 0; n < 54; n++)
142 ids.push(ok(addComment(app.db, { issueId: green.id, authorId: ada.id, body: `c${n}` })).id);
143 const { value } = await call(tok["ada"]!, "pull", { repo: "ada/svc", number: green.number });
144 expect(value.commentsTotal).toBe(55);
145 expect(value.comments).toHaveLength(50);
146 expect(value.comments.map((c: any) => c.body)).toEqual(ids.slice(4).map((_, n) => `c${n + 4}`));
147 // The oldest comment returned is c4; its id is the cursor for the older rest.
148 expect(value.commentsNext).toBe(ids[4]!);
149 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs