feat(pr-ledger): direct-push merges flip PRs to merged; API close endpoint #5508
4 changed files+397−1
Addedsrc/__tests__/pr-merge-detect.test.ts+133−0View fileUnifiedSplit
@@ -0,0 +1,133 @@
1/**
2 * PR ledger — direct-push merge detection (src/lib/pr-merge-detect.ts).
3 *
4 * The git side is the part that can lie: reachability decides whether an
5 * open PR flips to "merged" when its base branch is pushed. These tests
6 * build a real throwaway repo and pin the ancestry decisions; the DB-side
7 * flip is a single guarded UPDATE re-asserting state="open".
8 */
9
10import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11import { mkdtempSync, rmSync } from "fs";
12import { tmpdir } from "os";
13import { join } from "path";
14import { detectMergedPrsOnPush, __test } from "../lib/pr-merge-detect";
15
16const { isAncestor } = __test;
17
18let dir = "";
19let shas: { base: string; feature: string; merge: string; orphan: string } = {
20 base: "",
21 feature: "",
22 merge: "",
23 orphan: "",
24};
25
26function git(args: string[], cwd: string): string {
27 const proc = Bun.spawnSync(["git", ...args], {
28 cwd,
29 env: {
30 ...process.env,
31 GIT_AUTHOR_NAME: "t",
32 GIT_AUTHOR_EMAIL: "t@t",
33 GIT_COMMITTER_NAME: "t",
34 GIT_COMMITTER_EMAIL: "t@t",
35 },
36 });
37 if (proc.exitCode !== 0) {
38 throw new Error(
39 `git ${args.join(" ")} failed: ${proc.stderr.toString()}`
40 );
41 }
42 return proc.stdout.toString().trim();
43}
44
45beforeAll(() => {
46 dir = mkdtempSync(join(tmpdir(), "pr-merge-detect-"));
47 git(["init", "-q", "-b", "main"], dir);
48 Bun.write(join(dir, "a.txt"), "one\n");
49 git(["add", "."], dir);
50 git(["commit", "-q", "-m", "base"], dir);
51 shas.base = git(["rev-parse", "HEAD"], dir);
52
53 // feature branch with one commit on top of base
54 git(["checkout", "-q", "-b", "feature"], dir);
55 Bun.write(join(dir, "b.txt"), "two\n");
56 git(["add", "."], dir);
57 git(["commit", "-q", "-m", "feature work"], dir);
58 shas.feature = git(["rev-parse", "HEAD"], dir);
59
60 // merge feature back into main (a true merge commit)
61 git(["checkout", "-q", "main"], dir);
62 git(["merge", "-q", "--no-ff", "-m", "merge feature", "feature"], dir);
63 shas.merge = git(["rev-parse", "HEAD"], dir);
64
65 // an orphan branch sharing no history with main
66 git(["checkout", "-q", "--orphan", "orphan"], dir);
67 Bun.write(join(dir, "c.txt"), "three\n");
68 git(["add", "."], dir);
69 git(["commit", "-q", "-m", "orphan work"], dir);
70 shas.orphan = git(["rev-parse", "HEAD"], dir);
71});
72
73afterAll(() => {
74 try {
75 rmSync(dir, { recursive: true, force: true });
76 } catch {
77 /* best effort */
78 }
79});
80
81describe("isAncestor — the reachability decision", () => {
82 test("merged head IS an ancestor of the new base tip", async () => {
83 expect(await isAncestor(dir, shas.feature, shas.merge)).toBe(true);
84 });
85
86 test("base is an ancestor of the merge commit", async () => {
87 expect(await isAncestor(dir, shas.base, shas.merge)).toBe(true);
88 });
89
90 test("unmerged tip is NOT an ancestor of an older base", async () => {
91 expect(await isAncestor(dir, shas.merge, shas.base)).toBe(false);
92 });
93
94 test("orphan history never reads as merged", async () => {
95 expect(await isAncestor(dir, shas.orphan, shas.merge)).toBe(false);
96 });
97
98 test("garbage shas fail closed (never flip a PR on a git error)", async () => {
99 expect(await isAncestor(dir, "not-a-sha", shas.merge)).toBe(false);
100 expect(await isAncestor("/nonexistent/path", shas.base, shas.merge)).toBe(
101 false
102 );
103 });
104});
105
106describe("detectMergedPrsOnPush — push-path guards", () => {
107 test("no repositoryId → 0 flips, no throw", async () => {
108 expect(
109 await detectMergedPrsOnPush({
110 owner: "x",
111 repo: "y",
112 repositoryId: "",
113 refs: [
114 { oldSha: "0".repeat(40), newSha: "a".repeat(40), refName: "refs/heads/main" },
115 ],
116 })
117 ).toBe(0);
118 });
119
120 test("tag pushes and branch deletions are ignored", async () => {
121 expect(
122 await detectMergedPrsOnPush({
123 owner: "x",
124 repo: "y",
125 repositoryId: "00000000-0000-0000-0000-000000000000",
126 refs: [
127 { oldSha: "a".repeat(40), newSha: "0".repeat(40), refName: "refs/heads/gone" },
128 { oldSha: "0".repeat(40), newSha: "b".repeat(40), refName: "refs/tags/v1" },
129 ],
130 })
131 ).toBe(0);
132 });
133});
Modifiedsrc/hooks/post-receive.ts+18−0View fileUnifiedSplit
@@ -47,6 +47,7 @@ import { ensureRepoOnboarding } from "../lib/repo-onboarding";
4747import { audit, logActivity } from "../lib/notify";
4848import { fireWebhooks } from "../routes/webhooks";
4949import { syncAndEnqueuePushWorkflows } from "../lib/push-workflow-sync";
50import { detectMergedPrsOnPush } from "../lib/pr-merge-detect";
5051
5152// ---------------------------------------------------------------------------
5253// Test isolation layer — mount /__tests__/ paths read-only during repair loops
@@ -307,6 +308,23 @@ export async function onPostReceive(
307308 });
308309 }
309310
311 // 3b. PR ledger — direct-push merge detection. A workflow that merges via
312 // `git push` (the Vapron agent's gate→merge→push flow) never calls the
313 // merge endpoint, so its PRs stayed "open" forever while the branch
314 // moved on. Flip any open PR whose head is now reachable from the
315 // pushed base tip. Fire-and-forget; never blocks the push path.
316 if (repoId) {
317 void detectMergedPrsOnPush({
318 owner,
319 repo,
320 repositoryId: repoId,
321 refs,
322 pusherUserId: pusherUserId || null,
323 }).catch((err) =>
324 console.warn("[pr-merge-detect] dispatch error:", err)
325 );
326 }
327
310328 // 4. GateTest scan — fire-and-forget notification on every push. The
311329 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
312330 // deployments pay no overhead. Results flow back via the inbound
Addedsrc/lib/pr-merge-detect.ts+163−0View fileUnifiedSplit
@@ -0,0 +1,163 @@
1/**
2 * PR ledger — direct-push merge detection.
3 *
4 * Git-native workflows (the Vapron agent's Rule-1 gate → merge → push Main
5 * flow being the founding case, 2026-08-22) merge branches by pushing the
6 * merge result straight to the base branch, never calling the platform's
7 * merge endpoint. Before this module, such PRs stayed "open" forever — the
8 * PR page said one thing while the branch history said another, the same
9 * ledger-drift lie inverted.
10 *
11 * On every push to a branch, any OPEN pull request whose base is that branch
12 * and whose head tip is now reachable from the new base tip flips to
13 * "merged" (GitHub semantics: reachability, not message parsing). The head
14 * tip is resolved at detection time; a PR whose head branch was already
15 * deleted is left untouched — there is nothing to prove reachability with.
16 *
17 * Everything here is fire-and-forget from the push path: any git or DB
18 * failure skips that PR and never breaks the push.
19 */
20
21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
23import { pullRequests } from "../db/schema";
24import { getRepoPath, resolveRef, gitExecTimeoutMs } from "../git/repository";
25import { logActivity } from "./notify";
26
27interface PushRef {
28 oldSha: string;
29 newSha: string;
30 refName: string;
31}
32
33/**
34 * `git merge-base --is-ancestor ancestor descendant` — true when `ancestor`
35 * is reachable from `descendant`. Exit 0 = ancestor, 1 = not, anything else
36 * (bad sha, missing object) = treated as "not proven", so we never flip a
37 * PR on a git error.
38 */
39async function isAncestor(
40 repoPath: string,
41 ancestor: string,
42 descendant: string
43): Promise<boolean> {
44 try {
45 const proc = Bun.spawn(
46 ["git", "merge-base", "--is-ancestor", ancestor, descendant],
47 {
48 cwd: repoPath,
49 timeout: gitExecTimeoutMs(),
50 killSignal: "SIGKILL",
51 stdout: "ignore",
52 stderr: "ignore",
53 }
54 );
55 return (await proc.exited) === 0;
56 } catch {
57 return false;
58 }
59}
60
61/**
62 * Flip open PRs to "merged" when a push lands their head commits in their
63 * base branch. Returns the number of PRs flipped (0 on any resolution
64 * failure — this must never throw into the push path).
65 */
66export async function detectMergedPrsOnPush(opts: {
67 owner: string;
68 repo: string;
69 repositoryId: string;
70 refs: PushRef[];
71 pusherUserId?: string | null;
72}): Promise<number> {
73 const { owner, repo, repositoryId, refs } = opts;
74 if (!repositoryId) return 0;
75
76 const branchPushes = refs.filter(
77 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
78 );
79 if (branchPushes.length === 0) return 0;
80
81 const repoPath = getRepoPath(owner, repo);
82 let flipped = 0;
83
84 for (const ref of branchPushes) {
85 const baseBranch = ref.refName.replace("refs/heads/", "");
86
87 let openPrs: Array<typeof pullRequests.$inferSelect> = [];
88 try {
89 openPrs = await db
90 .select()
91 .from(pullRequests)
92 .where(
93 and(
94 eq(pullRequests.repositoryId, repositoryId),
95 eq(pullRequests.baseBranch, baseBranch),
96 eq(pullRequests.state, "open")
97 )
98 );
99 } catch {
100 continue;
101 }
102
103 for (const pr of openPrs) {
104 if (pr.headBranch === pr.baseBranch) continue;
105
106 // Head tip at detection time. A deleted head branch resolves to null —
107 // leave the PR alone rather than guessing.
108 const headTip = await resolveRef(
109 owner,
110 repo,
111 `refs/heads/${pr.headBranch}`
112 ).catch(() => null);
113 if (!headTip) continue;
114
115 if (!(await isAncestor(repoPath, headTip, ref.newSha))) continue;
116
117 try {
118 const now = new Date();
119 const updated = await db
120 .update(pullRequests)
121 .set({
122 state: "merged",
123 mergedAt: now,
124 mergedBy: opts.pusherUserId || null,
125 updatedAt: now,
126 })
127 // Re-assert state="open" so a concurrent merge (endpoint or another
128 // push ref) can't double-flip the same PR.
129 .where(
130 and(eq(pullRequests.id, pr.id), eq(pullRequests.state, "open"))
131 )
132 .returning({ id: pullRequests.id });
133 if (updated.length === 0) continue;
134 flipped++;
135 console.log(
136 `[pr-merge-detect] ${owner}/${repo}#${pr.number}: head ${pr.headBranch} (${headTip.slice(0, 7)}) reached ${baseBranch} — marked merged`
137 );
138 void logActivity({
139 repositoryId,
140 userId: opts.pusherUserId || null,
141 action: "pr_merged",
142 targetType: "pull_request",
143 targetId: pr.id,
144 metadata: {
145 number: pr.number,
146 via: "push-detection",
147 baseBranch,
148 headBranch: pr.headBranch,
149 headSha: headTip,
150 baseSha: ref.newSha,
151 },
152 });
153 } catch {
154 /* one PR failing must not stop the rest */
155 }
156 }
157 }
158
159 return flipped;
160}
161
162/** Test-only access to internals. */
163export const __test = { isAncestor };
Modifiedsrc/routes/api-v2.ts+83−1View fileUnifiedSplit
@@ -71,7 +71,7 @@ import type { AgentAuthEnv } from "../middleware/agent-auth";
7171import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
7272import { postCommitStatusHandler } from "./commit-statuses";
7373import { apiTokens } from "../db/schema";
74import { audit } from "../lib/notify";
74import { audit, logActivity } from "../lib/notify";
7575import { performGatedMerge } from "../lib/pr-merge-gated";
7676import { removeTempDir, removeTempFile } from "../lib/tmp-cleanup";
7777import { clientIpFrom } from "../lib/client-ip";
@@ -1450,6 +1450,84 @@ apiv2.post(
14501450 }
14511451);
14521452
1453// POST /api/v2/repos/:owner/:repo/pulls/:number/close
1454//
1455// The other half of the ledger the merge endpoint left open (found
1456// 2026-08-22 when the Vapron agent needed PR-per-branch as its ledger of
1457// record): a PR abandoned without merging had no API path out of "open".
1458// Closes without merging. The PR author may close their own PR; anyone
1459// else needs write access. 409 when the PR is already merged/closed so
1460// callers can tell "no-op" from "done".
1461apiv2.post(
1462 "/repos/:owner/:repo/pulls/:number/close",
1463 requireApiAuth,
1464 requireScope("repo"),
1465 async (c) => {
1466 const { owner, repo } = c.req.param();
1467 const num = (parseIdNumber(c.req.param("number")) ?? -1);
1468 const user = c.get("user")!;
1469
1470 const resolved = await resolveRepo(owner, repo);
1471 if (!resolved) return c.json({ error: "Not found" }, 404);
1472 const repoRow = resolved.repo as any;
1473
1474 const [pr] = await db
1475 .select()
1476 .from(pullRequests)
1477 .where(
1478 and(
1479 eq(pullRequests.repositoryId, repoRow.id),
1480 eq(pullRequests.number, num)
1481 )
1482 )
1483 .limit(1);
1484 if (!pr) return c.json({ error: "PR not found" }, 404);
1485
1486 if (pr.authorId !== user.id) {
1487 const access = await resolveRepoAccess({
1488 repoId: repoRow.id,
1489 userId: user.id,
1490 isPublic: !repoRow.isPrivate,
1491 });
1492 if (!satisfiesAccess(access, "write")) {
1493 return c.json({ error: "Write access required" }, 403);
1494 }
1495 }
1496
1497 if (pr.state !== "open") {
1498 return c.json(
1499 { closed: false, state: pr.state, error: `PR is already ${pr.state}` },
1500 409
1501 );
1502 }
1503
1504 const now = new Date();
1505 const updated = await db
1506 .update(pullRequests)
1507 .set({ state: "closed", closedAt: now, updatedAt: now })
1508 // Re-assert "open" so a concurrent merge/close can't be overwritten.
1509 .where(and(eq(pullRequests.id, pr.id), eq(pullRequests.state, "open")))
1510 .returning({ id: pullRequests.id });
1511 if (updated.length === 0) {
1512 return c.json(
1513 { closed: false, error: "PR state changed concurrently" },
1514 409
1515 );
1516 }
1517
1518 void logActivity({
1519 repositoryId: repoRow.id,
1520 userId: user.id,
1521 action: "pr_closed",
1522 targetType: "pull_request",
1523 targetId: pr.id,
1524 metadata: { number: pr.number, via: "api" },
1525 });
1526
1527 return c.json({ closed: true, state: "closed", number: pr.number });
1528 }
1529);
1530
14531531// ─── Branch previews (migration 0062) ──────────────────────────────────────
14541532//
14551533// GET /api/v2/repos/:owner/:repo/previews
@@ -3288,6 +3366,10 @@ apiv2.get("/", (c) => {
32883366 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
32893367 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
32903368 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
3369 "POST /api/v2/repos/:owner/:repo/pulls/:number/merge":
3370 "Merge PR (same gate chain as the UI; 422 with reason on refusal)",
3371 "POST /api/v2/repos/:owner/:repo/pulls/:number/close":
3372 "Close PR without merging (author or write access; 409 if not open)",
32913373 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
32923374 },
32933375 releases: {
32943376
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts