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

The pull API and the MCP say whether a pull merges: `mergeable` and `conflicts` on every reading of a pull #1466

Merged opened this

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

Merged

scout/forge:scout/1413-the-pull-api-and-the-mcp-say-whether-a-p → main 6 commits fork 6 threads · 6 unresolved

  • Merged. scout/1413-the-pull-api-and-the-mcp-say-whether-a-p was merged into main as ed8659f.

Review

Sign in to review this change.
8 files changed +373 −29
docs/MCP.md +1
@@ -30,6 +30,7 @@ for a tunnel and a forge that sleeps.
3030 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
3131 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
3232 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
33 | `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`); the same fields as `GET …/pulls/:number` | |
3334 | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes |
3435 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded | yes |
3536 | `now` | what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`) | |
docs/ORIGINS.md +3
@@ -574,6 +574,9 @@ whole review with `bad_anchor`, and the message says how an anchor is written.
574574 This is the one way the crew's reviewer brief posts a review.
575575 `GET …/pulls/:number` gains `status.reviews:
576576 { approved, changesRequested, required, mergeable }`.
577 `GET …/pulls` and `GET …/pulls/:number` carry `mergeable` (does the head merge
578 into the base's current head) and `conflicts` (the paths that stop it) on open
579 pulls only; a merged or closed pull leaves both out.
577580
578581 Repo JSON gains `origin: { originId, kind, fullName, htmlUrl, host, lastFetchAt,
579582 fetchError, detached, frozenSince, detachedAt } | null` — a detached mirror
scripts/gate.ts +1
@@ -92,6 +92,7 @@ export const GATE: readonly string[] = [
9292 "test/fork-flow.test.ts",
9393 "test/fork-pull-approval.test.ts",
9494 "test/cross-repo-pull.test.ts",
95 "test/pull-mergeable.test.ts",
9596 "test/pulls.test.ts",
9697 "test/api-reader-marks.test.ts",
9798 "test/app-members.test.ts",
src/core/pulls.ts +105 −21
@@ -12,6 +12,7 @@ import { sealedByNumber } from "./sealed.ts";
1212 import type { Database } from "bun:sqlite";
1313 import { diffRange, type Diff } from "../git/diff.ts";
1414 import { isSafeRef } from "../git/guard.ts";
15 import { listRefs } from "../git/read.ts";
1516 import {
1617 aheadCount,
1718 fetchInto,
@@ -627,29 +628,112 @@ export async function pullStatus(
627628 : `refs/heads/${pull.headBranch}`;
628629 const headOid = (await resolveOid(dir, headRef)) ?? pull.headOid;
629630 const reviews = gate === undefined ? null : reviewGate(gate, pull, headOid);
631 const { ahead, mergeable: clean, conflicts } = await mergeReading(
632 dir,
633 baseOid,
634 headOid,
635 );
636 return { baseOid, headOid, ahead, mergeable: clean, conflicts, reviews };
637 }
638
639 interface MergeReading {
640 readonly ahead: number;
641 readonly mergeable: boolean;
642 readonly conflicts: string[];
643 }
644
645 /** Readings by `head:base` oid: a merge-tree over two commits never changes. */
646 const readings = new Map<string, MergeReading>();
647 const READINGS_MAX = 500;
648
649 async function mergeReading(
650 dir: string,
651 baseOid: string,
652 headOid: string,
653 ): Promise<MergeReading> {
654 const key = `${headOid}:${baseOid}`;
655 const known = readings.get(key);
656 if (known !== undefined) return { ...known, conflicts: [...known.conflicts] };
630657 const ahead = await aheadCount(dir, baseOid, headOid);
631 if (ahead === 0) {
632 return {
633 baseOid,
634 headOid,
635 ahead,
636 mergeable: false,
637 conflicts: [],
638 reviews,
639 };
658 let reading: MergeReading;
659 if (ahead === 0) reading = { ahead, mergeable: false, conflicts: [] };
660 else {
661 const check = await mergeable(dir, baseOid, headOid);
662 reading = check.ok
663 ? { ahead, mergeable: true, conflicts: [] }
664 : {
665 ahead,
666 mergeable: false,
667 conflicts: check.error.kind === "conflict" ? check.error.files : [],
668 };
669 // A git failure is not a verdict about the commits: do not remember it.
670 if (!check.ok && check.error.kind !== "conflict") return reading;
640671 }
641 const check = await mergeable(dir, baseOid, headOid);
642 if (check.ok)
643 return {
644 baseOid,
645 headOid,
646 ahead,
647 mergeable: true,
648 conflicts: [],
649 reviews,
650 };
651 const conflicts = check.error.kind === "conflict" ? check.error.files : [];
652 return { baseOid, headOid, ahead, mergeable: false, conflicts, reviews };
672 if (readings.size >= READINGS_MAX)
673 readings.delete(readings.keys().next().value!);
674 readings.set(key, reading);
675 return { ...reading, conflicts: [...reading.conflicts] };
676 }
677
678 /**
679 * Whether an open pull merges into its base's current head, and the paths
680 * that stop it — what the API and the MCP say on every reading of a pull.
681 * Null for a pull that is no longer open: there is nothing left to merge.
682 */
683 export async function pullMergeState(
684 dir: string,
685 pull: Pull,
686 ): Promise<{ mergeable: boolean; conflicts: string[] } | null> {
687 if (pull.state !== "open" || pull.mergedAt !== null) return null;
688 const { mergeable: clean, conflicts } = await pullStatus(dir, pull);
689 return { mergeable: clean, conflicts };
690 }
691
692 /** How many merge readings a list runs at once: a page is never a fork bomb. */
693 const READ_AT_ONCE = 4;
694
695 /**
696 * `pullMergeState` for a page of pulls: one `for-each-ref` per repository
697 * directory instead of two `rev-parse` per pull, and at most `READ_AT_ONCE`
698 * readings in flight. Same answers, same order as the input.
699 */
700 export async function pullMergeStates(
701 items: readonly { dir: string; pull: Pull }[],
702 ): Promise<({ mergeable: boolean; conflicts: string[] } | null)[]> {
703 const tips = new Map<string, Promise<Map<string, string>>>();
704 const tipsOf = (dir: string): Promise<Map<string, string>> => {
705 let known = tips.get(dir);
706 if (known === undefined) {
707 known = listRefs(dir).then((refs) => {
708 const out = new Map<string, string>();
709 if (refs.ok) for (const r of refs.value) out.set(r.name, r.oid);
710 return out;
711 });
712 tips.set(dir, known);
713 }
714 return known;
715 };
716 const out: ({ mergeable: boolean; conflicts: string[] } | null)[] =
717 new Array(items.length).fill(null);
718 let next = 0;
719 const worker = async (): Promise<void> => {
720 for (let i = next++; i < items.length; i = next++) {
721 const { dir, pull } = items[i]!;
722 if (pull.state !== "open" || pull.mergedAt !== null) continue;
723 const live = await tipsOf(dir);
724 const baseOid = live.get(`refs/heads/${pull.baseBranch}`) ?? pull.baseOid;
725 const headRef = pull.crossRepo
726 ? `refs/pull/${pull.number}/head`
727 : `refs/heads/${pull.headBranch}`;
728 const headOid = live.get(headRef) ?? pull.headOid;
729 const r = await mergeReading(dir, baseOid, headOid);
730 out[i] = { mergeable: r.mergeable, conflicts: r.conflicts };
731 }
732 };
733 await Promise.all(
734 Array.from({ length: Math.min(READ_AT_ONCE, items.length) }, worker),
735 );
736 return out;
653737 }
654738
655739 /**
src/http/api.ts +17 −7
@@ -160,6 +160,7 @@ import {
160160 listPulls,
161161 pullDiff,
162162 pullDir,
163 pullMergeStates,
163164 type Pull,
164165 } from "../core/pulls.ts";
165166 import {
@@ -2705,17 +2706,23 @@ export function apiRoutes(router: Router<Handler>): void {
27052706
27062707 // ── pull requests — so automation drives the review/merge loop ───────
27072708
2708 router.add("GET", "/api/v1/repos/:owner/:name/pulls", (ctx) => {
2709 router.add("GET", "/api/v1/repos/:owner/:name/pulls", async (ctx) => {
27092710 const repo = loadRepo(ctx);
27102711 if (!repo) return notFound();
27112712 const query = listQuery(ctx);
2712 const items = withLabelsMany(
2713 ctx,
2714 listPulls(ctx.app.db, repo.id, {
2715 ...query,
2716 ...sealedScope(ctx.app.db, repo, ctx.auth.user),
2717 }),
2713 const pulls = listPulls(ctx.app.db, repo.id, {
2714 ...query,
2715 ...sealedScope(ctx.app.db, repo, ctx.auth.user),
2716 });
2717 const states = await pullMergeStates(
outdated

Medium: the cache only stores the merge-tree result. Each open pull still spawns two git rev-parse in pullStatus, all at once and on every request, and anonymous readers of a public repo can trigger it. Resolve refs once per request (one for-each-ref, or the stored oids) and cap how many run at once.

2718 await Promise.all(
2719 pulls.map(async (p) => ({ dir: await pullDirOf(ctx, repo, p), pull: p })),

Low: this calls pullDirOf for every pull on the page, including closed and merged ones whose state is then dropped. For a sealed cross-repo pull, pullDir runs followBase, which is a git fetch. That makes a plain GET do unbounded fetches, outside the READ_AT_ONCE cap. Filter first: resolve a dir only for p.state === "open" && p.mergedAt === null, or let pullMergeStates resolve dirs inside its capped workers.

2720 ),
27182721 );
2722 const items = withLabelsMany(ctx, pulls).map((item, i) => ({
2723 ...item,
2724 ...(states[i] ?? {}),
2725 }));
27192726 return json(paged(items, query.limit, (p) => p.number));
27202727 });
27212728
@@ -2777,6 +2784,9 @@ export function apiRoutes(router: Router<Handler>): void {
27772784 const hold = getHold(ctx.app.db, found.pull.id);
27782785 return json({
27792786 ...withLabels(ctx, found.pull),
2787 ...(found.pull.state === "open" && found.pull.mergedAt === null
outdated

Low: status from pullDiff above already carries mergeable and conflicts. Reuse it here (when the pull is open) instead of running pullStatus again.

Leaving the fields out for closed and merged pulls is a behaviour this change adds, and no test covers it. Close the pull in test/pull-mergeable.test.ts, then assert that the detail route, the list (?state=all) and the MCP tool all omit mergeable and conflicts.

2788 ? { mergeable: status.mergeable, conflicts: status.conflicts }
2789 : {}),
27802790 status,
27812791 diff,
27822792 hold: hold === undefined ? null : holdJson(hold),
src/http/mcp.ts +38 −1
@@ -59,7 +59,7 @@ import { getIssue, getIssueById, listOpenAgentPullsVisibleTo } from "../core/iss
5959 import { waitsForPerson } from "../crew/review.ts";
6060 import { attachLook } from "../actions/attach_look.ts";
6161 import { lookUrl } from "../core/looks.ts";
62 import { getPull } from "../core/pulls.ts";
62 import { getPull, pullDir, pullMergeState } from "../core/pulls.ts";
6363 import { repoGraph } from "../core/dependencies.ts";
6464 import { pillarView } from "../core/pillars.ts";
6565 import { getAppByRepo } from "../core/apps.ts";
@@ -371,6 +371,43 @@ const TOOLS: readonly Tool[] = [
371371 : bad("refused", "that could not be said here");
372372 },
373373 },
374 {
375 name: "pull",

This is a new tool, but docs/MCP.md has no pull row in its tool table. Add one so it is reachable by documentation as well as by tools/list.

376 description:
377 "Read a pull: its title, state and 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`). The same two fields as GET /api/v1/repos/:owner/:name/pulls/:number.",
378 inputSchema: {
379 type: "object",
380 properties: {
381 repo: REPO_ARG,
382 number: { type: "integer", minimum: 1 },
383 },
384 required: ["repo", "number"],
385 },
386 writes: false,
387 async run(ctx, user, args) {
388 const repo = repoArg(ctx, user, args);
389 if (isAnswer(repo)) return repo;
390 const n = args["number"];
391 const pull =
392 typeof n === "number" && Number.isInteger(n) && n > 0
393 ? getPull(ctx.app.db, repo.id, n)
394 : undefined;
395 if (pull === undefined || !mayView(ctx.app.db, repo, pull, user))
396 return bad("no_such_pull", `no pull #${String(n)}`);

The mayView gate is correct here. Add a test that would fail without it: a sealed pull, or a pull in a private repository, read by someone who cannot see it, must answer no_such_pull.

397 const state = await pullMergeState(
398 await pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull),
399 pull,
400 );
401 return good({
402 number: pull.number,
403 title: pull.title,
404 state: pull.mergedAt !== null ? "merged" : pull.state,
405 base: pull.baseBranch,
406 head: pull.headBranch,
407 ...(state ?? {}),
408 });
409 },
410 },
374411 {
375412 name: "edit",
376413 description:
test/mcp.test.ts +1
@@ -159,6 +159,7 @@ describe("the forge over MCP", () => {
159159 "tell",
160160 "share",
161161 "say",
162 "pull",
162163 "edit",
163164 "asks",
164165 "asks_act",
test/pull-mergeable.test.ts added +207
@@ -0,0 +1,207 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { gitClientEnv } from "./helpers/git-client.ts";
8
9 const gitEnv = gitClientEnv();
10
11 describe("a pull says whether it merges", () => {
12 let dataDir: string;
13 let work: string;
14 let app: App;
15 let servers: Servers;
16 let base: string;
17 let token = "";
18 let dir: string;
19
20 async function gitOk(args: string[]): Promise<void> {
21 const p = Bun.spawn(["git", ...args], {
22 cwd: dir,
23 env: gitEnv,
24 stdout: "ignore",
25 stderr: "pipe",
26 });
27 const [err, code] = await Promise.all([
28 new Response(p.stderr).text(),
29 p.exited,
30 ]);
31 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
32 }
33 const auth = { authorization: "" };
34 const api = async (path: string, init: RequestInit = {}) =>
35 (await fetch(`${base}/api/v1/repos/dev/app${path}`, {
36 ...init,
37 headers: { ...auth, "content-type": "application/json" },
38 })).json() as Promise<any>;
39 const mcp = async (name: string, args: unknown, as?: string) => {
40 const res = await fetch(`${base}/mcp`, {
41 method: "POST",
42 headers: {
43 "content-type": "application/json",
44 accept: "application/json, text/event-stream",
45 ...auth,
46 ...(as ? { authorization: `Bearer ${as}` } : {}),
47 },
48 body: JSON.stringify({
49 jsonrpc: "2.0",
50 id: 1,
51 method: "tools/call",
52 params: { name, arguments: args },
53 }),
54 });
55 const body = (await res.json()) as any;
56 const text = body.result.content[0].text as string;
57 try {
58 return JSON.parse(text);
59 } catch {
60 return { error: text };
61 }
62 };
63 const write = async (text: string, message: string) => {
64 await Bun.write(join(dir, "code.txt"), text);
65 await gitOk(["commit", "-am", message]);
66 };
67
68 beforeAll(async () => {
69 dataDir = await mkdtemp(join(tmpdir(), "forge-pm-"));
70 work = await mkdtemp(join(tmpdir(), "forge-pm-work-"));
71 app = createApp({ dataDir, port: 0 });
72 servers = await serve(app);
73 base = servers.url;
74 await fetch(`${base}/signup`, {
75 method: "POST",
76 body: new URLSearchParams({
77 username: "dev",
78 email: "[email protected]",
79 password: "pull-mergeable-pass",
80 }),
81 redirect: "manual",
82 });
83 token = await fetch(`${base}/api/v1/tokens`, {
84 method: "POST",
85 headers: { "content-type": "application/json" },
86 body: JSON.stringify({
87 adminConfirm: true,
88 username: "dev",
89 password: "pull-mergeable-pass",
90 name: "cli",
91 scope: "rw",
92 }),
93 })
94 .then((r) => r.json())
95 .then((j) => (j as { token: string }).token);
96 auth.authorization = `Bearer ${token}`;
97 await fetch(`${base}/api/v1/repos`, {
98 method: "POST",
99 headers: { ...auth, "content-type": "application/json" },
100 body: JSON.stringify({ name: "app", visibility: "public" }),
101 });
102 const remote = `${base.replace("http://", `http://dev:${token}@`)}/dev/app.git`;
103 dir = work;
104 await gitOk(["clone", remote, "app"]);
105 dir = join(work, "app");
106 await Bun.write(join(dir, "code.txt"), "alpha\nbeta\ngamma\n");
107 await gitOk(["add", "."]);
108 await gitOk(["commit", "-m", "base"]);
109 await gitOk(["push", "origin", "main"]);
110 });
111
112 afterAll(async () => {
113 servers.stop();
114 app.close();
115 await rm(dataDir, { recursive: true, force: true });
116 await rm(work, { recursive: true, force: true });
117 });
118
119 test("a conflicting pull reads false with the path; once the base resolves it, true", async () => {
120 await gitOk(["checkout", "-b", "loud"]);
121 await write("alpha\nBETA\ngamma\n", "loud beta");
122 await gitOk(["push", "origin", "loud"]);
123 const made = await api("/pulls", {
124 method: "POST",
125 body: JSON.stringify({ base: "main", head: "loud", title: "Loud" }),
126 });
127 expect(made.number).toBe(1);
128
129 // The base takes a different edit of the same line.
130 await gitOk(["checkout", "main"]);
131 await write("alpha\nbeta!\ngamma\n", "quiet beta");
132 await gitOk(["push", "origin", "main"]);
133
134 const one = await api("/pulls/1");
135 expect(one.mergeable).toBe(false);
136 expect(one.conflicts).toEqual(["code.txt"]);
137 const listed = await api("/pulls");
138 expect(listed.items[0].mergeable).toBe(false);
139 expect(listed.items[0].conflicts).toEqual(["code.txt"]);
140 const seen = await mcp("pull", { repo: "dev/app", number: 1 });
141 expect(seen.mergeable).toBe(false);
142 expect(seen.conflicts).toEqual(["code.txt"]);
143
144 // The base moves to hold the same line the pull wants.
145 await write("alpha\nBETA\ngamma\n", "the same edit");
146 await gitOk(["push", "origin", "main"]);
147
148 const after = await api("/pulls/1");
149 expect(after.mergeable).toBe(true);
150 expect(after.conflicts).toEqual([]);
151 expect((await api("/pulls")).items[0].mergeable).toBe(true);
152 const mcpAfter = await mcp("pull", { repo: "dev/app", number: 1 });
153 expect(mcpAfter.mergeable).toBe(true);
154 expect(mcpAfter.conflicts).toEqual([]);
155 });
156
157 test("a sealed pull answers no_such_pull to a reader who cannot see it", async () => {
158 const sealed = await mcp("seal", { repo: "dev/app", number: 1, sealed: true });
159 expect(sealed.sealed).toBe(true);
160 await fetch(`${base}/signup`, {
161 method: "POST",
162 body: new URLSearchParams({
163 username: "eve",
164 email: "[email protected]",
165 password: "eve-reader-pass-1",
166 }),
167 redirect: "manual",
168 });
169 const eve = await fetch(`${base}/api/v1/tokens`, {
170 method: "POST",
171 headers: { "content-type": "application/json" },
172 body: JSON.stringify({
173 adminConfirm: true,
174 username: "eve",
175 password: "eve-reader-pass-1",
176 name: "cli",
177 scope: "rw",
178 }),
179 })
180 .then((r) => r.json())
181 .then((j) => (j as { token: string }).token);
182 const hidden = await mcp("pull", { repo: "dev/app", number: 1 }, eve);
183 expect(hidden.error).toContain("no_such_pull");
184 expect((await mcp("pull", { repo: "dev/app", number: 1 })).number).toBe(1);
185 await mcp("seal", { repo: "dev/app", number: 1, sealed: false });
186 });
187
188 test("a closed pull leaves out mergeable and conflicts everywhere", async () => {
189 const closed = await api("/pulls/1", {
190 method: "PATCH",
191 body: JSON.stringify({ state: "closed" }),
192 });
193 expect(closed.state).toBe("closed");
194 const one = await api("/pulls/1");
195 expect(one.state).toBe("closed");
196 expect("mergeable" in one).toBe(false);
197 expect("conflicts" in one).toBe(false);
198 const listed = await api("/pulls?state=all");
199 expect(listed.items[0].state).toBe("closed");
200 expect("mergeable" in listed.items[0]).toBe(false);
201 expect("conflicts" in listed.items[0]).toBe(false);
202 const seen = await mcp("pull", { repo: "dev/app", number: 1 });
203 expect(seen.state).toBe("closed");
204 expect("mergeable" in seen).toBe(false);
205 expect("conflicts" in seen).toBe(false);
206 });
207 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs