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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7655-mcp-pulls-with-no-repo-answers-every-ope → main 2 commits fork 2 threads · 2 unresolved
6 files
changed +286
−13
docs/MCP.md +2 −2
| @@ -133,8 +133,8 @@ for a tunnel and a forge that sleeps. | ||
| 133 | 133 | | `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 | |
| 134 | 134 | | `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 | |
| 135 | 135 | | `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 | | | |
| 138 | 138 | | `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` | | |
| 139 | 139 | | `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 | |
| 140 | 140 | | `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( | ||
| 522 | 522 | return out; |
| 523 | 523 | } |
| 524 | 524 | |
| 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 | ||
| 525 | 556 | export type ListPullsOptions = Omit<ListIssuesOptions, "kind">; |
| 526 | 557 | |
| 527 | 558 | export function listPulls( |
src/flow/why.ts +34 −3
| @@ -141,15 +141,24 @@ function headroomSpent(db: Database, now: Date): boolean { | ||
| 141 | 141 | ); |
| 142 | 142 | } |
| 143 | 143 | |
| 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"> { | |
| 145 | 148 | const cap = forgeCeiling(app.config); |
| 146 | 149 | return { |
| 147 | 150 | ...(cap !== null && ceilingHitAt(app.db, now, cap) !== null ? { ceilingUsd: cap } : {}), |
| 148 | builders: listCrewRoles(app.db, repo.id).filter((c) => c.role === "builder").length, | |
| 149 | 151 | noHeadroom: headroomSpent(app.db, now), |
| 150 | 152 | }; |
| 151 | 153 | } |
| 152 | 154 | |
| 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 | ||
| 153 | 162 | /** Why the repository's builders are held, whatever the issue. */ |
| 154 | 163 | export function whyRepo(app: App, repo: Repo, now = new Date()): Why { |
| 155 | 164 | return { cause: firstCause({ actor: "builder", flowOnly: repo.flowOnly, ...sharedFacts(app, repo, now) }) }; |
| @@ -195,6 +204,7 @@ export function whyPull( | ||
| 195 | 204 | pull: Pull, |
| 196 | 205 | live: { readonly headOid: string; readonly conflicts: number }, |
| 197 | 206 | now = new Date(), |
| 207 | shared: Shared = sharedFacts(app, repo, now), | |
| 198 | 208 | ): Why { |
| 199 | 209 | if (pull.mergedAt !== null) return { cause: null, settled: "merged" }; |
| 200 | 210 | if (pull.state !== "open") return { cause: null, settled: "closed" }; |
| @@ -205,7 +215,28 @@ export function whyPull( | ||
| 205 | 215 | actor: next.who, |
| 206 | 216 | redCheck: next.who === "builder" && check === "failed", |
| 207 | 217 | waitingReview: next.who === "reviewer", |
| 208 | ...sharedFacts(app, repo, now), | |
| 218 | ...shared, | |
| 209 | 219 | }); |
| 210 | 220 | return { cause, next }; |
| 211 | 221 | } |
| 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"; | ||
| 7 | 7 | import { checkSummary } from "../../../core/runs.ts"; |
| 8 | 8 | import { nextOfPull } from "../../../flow/next_actor.ts"; |
| 9 | 9 | 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"; | |
| 10 | 13 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 11 | 14 | |
| 12 | 15 | export const tool: Tool = { |
| 13 | 16 | 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", | |
| 15 | 18 | description: |
| 16 | 19 | "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.", |
| 17 | 20 | inputSchema: { |
| @@ -40,7 +43,21 @@ export const tool: Tool = { | ||
| 40 | 43 | pull.state === "open" && |
| 41 | 44 | pull.mergedAt === null && |
| 42 | 45 | 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); | |
| 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); | |
| 43 | 52 | 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, | |
| 44 | 61 | number: pull.number, |
| 45 | 62 | title: pull.title, |
| 46 | 63 | 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"; | |
| 2 | 3 | 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"; | |
| 4 | 5 | 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"; | |
| 6 | 11 | |
| 7 | 12 | export const tool: Tool = { |
| 8 | 13 | 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", | |
| 10 | 15 | 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.", | |
| 12 | 17 | inputSchema: { |
| 13 | 18 | type: "object", |
| 14 | 19 | properties: { |
| 15 | 20 | repo: REPO_ARG, |
| 16 | 21 | 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" }, | |
| 18 | 24 | }, |
| 19 | required: ["repo"], | |
| 20 | 25 | }, |
| 21 | 26 | writes: false, |
| 22 | 27 | async run(ctx, user, args) { |
| 28 | if (args["repo"] === undefined) return pile(ctx, user, args); | |
| 23 | 29 | const repo = repoArg(ctx, user, args); |
| 24 | 30 | if (isAnswer(repo)) return repo; |
| 25 | 31 | const state = args["state"] ?? "open"; |
| @@ -52,3 +58,42 @@ export const tool: Tool = { | ||
| 52 | 58 | return { ...answer, card: { repo: `${repo.ownerName}/${repo.name}`, state, limit } }; |
| 53 | 59 | }, |
| 54 | 60 | }; |
| 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 |
||
| 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 | }); |
new above
offline
The crew-only
untrustedmarking has no test; add one for a crew caller and one for a non-crew caller.