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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1413-the-pull-api-and-the-mcp-say-whether-a-p → main 6 commits fork 6 threads · 6 unresolved
docs/MCP.md +1
| @@ -30,6 +30,7 @@ for a tunnel and a forge that sleeps. | ||
| 30 | 30 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 31 | 31 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 32 | 32 | | `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` | | | |
| 33 | 34 | | `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 | |
| 34 | 35 | | `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 | |
| 35 | 36 | | `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. | ||
| 574 | 574 | This is the one way the crew's reviewer brief posts a review. |
| 575 | 575 | `GET …/pulls/:number` gains `status.reviews: |
| 576 | 576 | { 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. | |
| 577 | 580 | |
| 578 | 581 | Repo JSON gains `origin: { originId, kind, fullName, htmlUrl, host, lastFetchAt, |
| 579 | 582 | fetchError, detached, frozenSince, detachedAt } | null` — a detached mirror |
scripts/gate.ts +1
| @@ -92,6 +92,7 @@ export const GATE: readonly string[] = [ | ||
| 92 | 92 | "test/fork-flow.test.ts", |
| 93 | 93 | "test/fork-pull-approval.test.ts", |
| 94 | 94 | "test/cross-repo-pull.test.ts", |
| 95 | "test/pull-mergeable.test.ts", | |
| 95 | 96 | "test/pulls.test.ts", |
| 96 | 97 | "test/api-reader-marks.test.ts", |
| 97 | 98 | "test/app-members.test.ts", |
src/core/pulls.ts +105 −21
| @@ -12,6 +12,7 @@ import { sealedByNumber } from "./sealed.ts"; | ||
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | 13 | import { diffRange, type Diff } from "../git/diff.ts"; |
| 14 | 14 | import { isSafeRef } from "../git/guard.ts"; |
| 15 | import { listRefs } from "../git/read.ts"; | |
| 15 | 16 | import { |
| 16 | 17 | aheadCount, |
| 17 | 18 | fetchInto, |
| @@ -627,29 +628,112 @@ export async function pullStatus( | ||
| 627 | 628 | : `refs/heads/${pull.headBranch}`; |
| 628 | 629 | const headOid = (await resolveOid(dir, headRef)) ?? pull.headOid; |
| 629 | 630 | 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] }; | |
| 630 | 657 | 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; | |
| 640 | 671 | } |
| 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; | |
| 653 | 737 | } |
| 654 | 738 | |
| 655 | 739 | /** |
src/http/api.ts +17 −7
| @@ -160,6 +160,7 @@ import { | ||
| 160 | 160 | listPulls, |
| 161 | 161 | pullDiff, |
| 162 | 162 | pullDir, |
| 163 | pullMergeStates, | |
| 163 | 164 | type Pull, |
| 164 | 165 | } from "../core/pulls.ts"; |
| 165 | 166 | import { |
| @@ -2705,17 +2706,23 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2705 | 2706 | |
| 2706 | 2707 | // ── pull requests — so automation drives the review/merge loop ─────── |
| 2707 | 2708 | |
| 2708 | router.add("GET", "/api/v1/repos/:owner/:name/pulls", (ctx) => { | |
| 2709 | router.add("GET", "/api/v1/repos/:owner/:name/pulls", async (ctx) => { | |
| 2709 | 2710 | const repo = loadRepo(ctx); |
| 2710 | 2711 | if (!repo) return notFound(); |
| 2711 | 2712 | 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( | |
| 2718 | await Promise.all( | |
| 2719 | pulls.map(async (p) => ({ dir: await pullDirOf(ctx, repo, p), pull: p })), | |
Low: this calls |
||
| 2720 | ), | |
| 2718 | 2721 | ); |
| 2722 | const items = withLabelsMany(ctx, pulls).map((item, i) => ({ | |
| 2723 | ...item, | |
| 2724 | ...(states[i] ?? {}), | |
| 2725 | })); | |
| 2719 | 2726 | return json(paged(items, query.limit, (p) => p.number)); |
| 2720 | 2727 | }); |
| 2721 | 2728 | |
| @@ -2777,6 +2784,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2777 | 2784 | const hold = getHold(ctx.app.db, found.pull.id); |
| 2778 | 2785 | return json({ |
| 2779 | 2786 | ...withLabels(ctx, found.pull), |
| 2787 | ...(found.pull.state === "open" && found.pull.mergedAt === null | |
outdated
Low: Leaving the fields out for closed and merged pulls is a behaviour this change adds, and no test covers it. Close the pull in |
||
| 2788 | ? { mergeable: status.mergeable, conflicts: status.conflicts } | |
| 2789 | : {}), | |
| 2780 | 2790 | status, |
| 2781 | 2791 | diff, |
| 2782 | 2792 | hold: hold === undefined ? null : holdJson(hold), |
src/http/mcp.ts +38 −1
| @@ -59,7 +59,7 @@ import { getIssue, getIssueById, listOpenAgentPullsVisibleTo } from "../core/iss | ||
| 59 | 59 | import { waitsForPerson } from "../crew/review.ts"; |
| 60 | 60 | import { attachLook } from "../actions/attach_look.ts"; |
| 61 | 61 | import { lookUrl } from "../core/looks.ts"; |
| 62 | import { getPull } from "../core/pulls.ts"; | |
| 62 | import { getPull, pullDir, pullMergeState } from "../core/pulls.ts"; | |
| 63 | 63 | import { repoGraph } from "../core/dependencies.ts"; |
| 64 | 64 | import { pillarView } from "../core/pillars.ts"; |
| 65 | 65 | import { getAppByRepo } from "../core/apps.ts"; |
| @@ -371,6 +371,43 @@ const TOOLS: readonly Tool[] = [ | ||
| 371 | 371 | : bad("refused", "that could not be said here"); |
| 372 | 372 | }, |
| 373 | 373 | }, |
| 374 | { | |
| 375 | name: "pull", | |
This is a new tool, but |
||
| 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 |
||
| 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 | }, | |
| 374 | 411 | { |
| 375 | 412 | name: "edit", |
| 376 | 413 | description: |
test/mcp.test.ts +1
| @@ -159,6 +159,7 @@ describe("the forge over MCP", () => { | ||
| 159 | 159 | "tell", |
| 160 | 160 | "share", |
| 161 | 161 | "say", |
| 162 | "pull", | |
| 162 | 163 | "edit", |
| 163 | 164 | "asks", |
| 164 | 165 | "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 | }); |
Medium: the cache only stores the merge-tree result. Each open pull still spawns two
git rev-parseinpullStatus, 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.