CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(api): implement the documented v2 PR merge endpoint (closes #194) #3525

MergedXLccantynz wants to mergefeat/api-v2-merge-endpointmainopened 25d ago
5 changed files+466−276
Modifiedsrc/__tests__/api-v2.test.ts+11−0View fileUnifiedSplit
236236 const body = await res.json();
237237 expect(body.error).toBeDefined();
238238 });
239
240 it("POST /api/v2/repos/nobody/nothing/pulls/1/merge without auth returns 401", async () => {
241 const res = await app.request(
242 apiUrl("/repos/nobody/nothing/pulls/1/merge"),
243 { method: "POST" }
244 );
245 expect(res.status).toBe(401);
246
247 const body = await res.json();
248 expect(body.error).toBeDefined();
249 });
239250});
240251
241252// ---------------------------------------------------------------------------
Modifiedsrc/lib/mcp-tools.ts+12−275View fileUnifiedSplit
2626 users,
2727 codebaseExplanations,
2828} from "../db/schema";
29import { getBlob, repoExists, resolveRef, getRepoPath, getDefaultBranch, listBranches, refRange } from "../git/repository";
29import { getBlob, repoExists, resolveRef, getRepoPath, getDefaultBranch, listBranches } from "../git/repository";
3030import { computeHealthScore } from "./intelligence";
3131import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
3232import type { McpContext } from "./mcp";
3434import type { RepoAccessLevel } from "../middleware/repo-access";
3535import { notify, audit, logActivity } from "./notify";
3636import { fireWebhooks } from "../routes/webhooks";
37import { runAllGateChecks } from "./gate";
38import {
39 matchProtection,
40 countHumanApprovals,
41 listRequiredChecks,
42 passingCheckNames,
43 evaluateProtection,
44} from "./branch-protection";
45import { mergeWithAutoResolve } from "./merge-resolver";
46import { isAiReviewEnabled, triggerAiReview, isAiReviewApproved } from "./ai-review";
37import { isAiReviewEnabled, triggerAiReview } from "./ai-review";
4738import { triggerPrTriage } from "./pr-triage";
48import { requiredOwnersApproved } from "./codeowners";
49import {
50 computePrRiskForPullRequest,
51 getCachedPrRisk,
52 getLatestCachedPrRisk,
53 type PrRiskScore,
54} from "./pr-risk";
39import { performGatedMerge } from "./pr-merge-gated";
5540import { expandedTools } from "./mcp-tools-expanded";
5641
5742/**
13361321 `pr not found: ${owner}/${repo}#${number}`
13371322 );
13381323 }
1339 if (pr.state !== "open") {
1340 return { merged: false, reason: `pr is ${pr.state}, not open` };
1341 }
1342 if (pr.isDraft) {
1343 return {
1344 merged: false,
1345 reason: "This PR is a draft. Mark it as ready for review before merging.",
1346 };
1347 }
13481324
1349 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1350 // fall back to most-recent cached row; finally compute on demand so the
1351 // MCP caller always gets a score (HTTP path is async + tolerant of a
1352 // missing score, but MCP callers want an answer in one round trip).
1353 let risk: PrRiskScore | null = null;
1354 try {
1355 risk =
1356 (await getCachedPrRisk(pr.id)) ||
1357 (await getLatestCachedPrRisk(pr.id)) ||
1358 (await computePrRiskForPullRequest(pr.id));
1359 } catch {
1360 risk = null;
1361 }
1362
1363 if (risk && risk.band === "critical" && !confirmHighRisk) {
1364 return {
1365 merged: false,
1366 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1367 riskScore: serialisePrRiskForResponse(risk),
1368 };
1369 }
1370
1371 const headSha = await resolveRef(owner, repo, pr.headBranch);
1372 if (!headSha) {
1373 return { merged: false, reason: "Head branch not found" };
1374 }
1375
1376 const aiApproved = await isAiReviewApproved(pr.id);
1377
1378 const gateResult = await runAllGateChecks(
1325 // Caller-typed owner/repo, exactly as the pre-extraction chain used them —
1326 // getRepoPath canonicalises casing at the disk layer.
1327 return performGatedMerge({
13791328 owner,
13801329 repo,
1381 pr.baseBranch,
1382 pr.headBranch,
1383 headSha,
1384 aiApproved
1385 );
1386
1387 const hardFailures = gateResult.checks.filter(
1388 (check) => !check.passed && check.name !== "Merge check"
1389 );
1390 if (hardFailures.length > 0) {
1391 return {
1392 merged: false,
1393 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1394 };
1395 }
1396
1397 // D5 — branch-protection enforcement
1398 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1399 if (protectionRule) {
1400 const humanApprovals = await countHumanApprovals(pr.id);
1401 const required = await listRequiredChecks(protectionRule.id);
1402 const passingNames =
1403 required.length > 0
1404 ? await passingCheckNames(gate.repoId, headSha)
1405 : [];
1406 const decision = evaluateProtection(
1407 protectionRule,
1408 {
1409 aiApproved,
1410 humanApprovalCount: humanApprovals,
1411 gateResultGreen: hardFailures.length === 0,
1412 hasFailedGates: hardFailures.length > 0,
1413 passingCheckNames: passingNames,
1414 },
1415 required.map((r) => r.checkName)
1416 );
1417
1418 // CODEOWNERS enforcement — additive to evaluateProtection(), only
1419 // when the rule already requires human review at all (no new DB
1420 // column for this pass). Fail-open on any internal error: a bug here
1421 // must never hard-block every merge platform-wide. Mirrors the HTTP
1422 // merge path in routes/pulls.tsx.
1423 // The range builder is part of the condition, not inside the try: a
1424 // stored branch name beginning with a dash would otherwise reach git as
1425 // an option. This block fails open by design, so an unsafe ref skips
1426 // CODEOWNERS enforcement rather than erroring.
1427 const codeownersRange = refRange(pr.baseBranch, pr.headBranch);
1428 if (
1429 (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) &&
1430 codeownersRange
1431 ) {
1432 try {
1433 const codeownersDiffProc = Bun.spawn(
1434 ["git", "diff", "--name-only", codeownersRange],
1435 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
1436 );
1437 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
1438 await codeownersDiffProc.exited;
1439 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
1440 if (changedPaths.length > 0) {
1441 const { satisfied, missingOwners } = await requiredOwnersApproved(
1442 owner,
1443 repo,
1444 gate.defaultBranch,
1445 pr.id,
1446 changedPaths
1447 );
1448 if (!satisfied) {
1449 decision.allowed = false;
1450 decision.reasons.push(
1451 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
1452 );
1453 }
1454 }
1455 } catch (err) {
1456 console.warn(
1457 "[codeowners] merge enforcement failed:",
1458 err instanceof Error ? err.message : err
1459 );
1460 }
1461 }
1462
1463 if (!decision.allowed) {
1464 return { merged: false, reason: decision.reasons.join(" ") };
1465 }
1466 }
1467
1468 const repoDir = getRepoPath(owner, repo);
1469 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1470 const hasConflicts = mergeCheck && !mergeCheck.passed;
1471
1472 if (hasConflicts && isAiReviewEnabled()) {
1473 const mergeResult = await mergeWithAutoResolve(
1474 owner,
1475 repo,
1476 pr.baseBranch,
1477 pr.headBranch,
1478 `Merge pull request #${pr.number}: ${pr.title}`
1479 );
1480 if (!mergeResult.success) {
1481 return {
1482 merged: false,
1483 reason: mergeResult.error || "Auto-merge failed",
1484 };
1485 }
1486 if (mergeResult.resolvedFiles.length > 0) {
1487 await db.insert(prComments).values({
1488 pullRequestId: pr.id,
1489 authorId: gate.userId,
1490 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1491 .map((f) => `- \`${f}\``)
1492 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1493 isAiReview: true,
1494 });
1495 }
1496 } else {
1497 const ffProc = Bun.spawn(
1498 [
1499 "git",
1500 "update-ref",
1501 `refs/heads/${pr.baseBranch}`,
1502 `refs/heads/${pr.headBranch}`,
1503 ],
1504 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1505 );
1506 const ffExit = await ffProc.exited;
1507 if (ffExit !== 0) {
1508 return {
1509 merged: false,
1510 reason: "Merge failed — unable to update branch ref",
1511 };
1512 }
1513 }
1514
1515 await db
1516 .update(pullRequests)
1517 .set({
1518 state: "merged",
1519 mergedAt: new Date(),
1520 mergedBy: gate.userId,
1521 updatedAt: new Date(),
1522 })
1523 .where(eq(pullRequests.id, pr.id));
1524
1525 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1526 try {
1527 const { applyClosingKeywords } = await import("./close-keywords-apply");
1528 await applyClosingKeywords({
1529 repositoryId: gate.repoId,
1530 prNumber: pr.number,
1531 prTitle: pr.title,
1532 prBody: pr.body,
1533 actorUserId: gate.userId,
1534 });
1535 } catch {
1536 /* never block merge on close-keyword failures */
1537 }
1538
1539 await audit({
1540 userId: gate.userId,
1541 repositoryId: gate.repoId,
1542 action: "pr.merged",
1543 targetType: "pull_request",
1544 targetId: pr.id,
1545 metadata: { source: "mcp", number, sha: headSha },
1330 repoId: gate.repoId,
1331 defaultBranch: gate.defaultBranch,
1332 pr,
1333 actorUserId: gate.userId,
1334 confirmHighRisk,
1335 source: "mcp",
15461336 });
1547
1548 void logActivity({
1549 repositoryId: gate.repoId,
1550 userId: gate.userId,
1551 action: "pr_merge",
1552 targetType: "pull_request",
1553 targetId: String(number),
1554 metadata: { source: "mcp", sha: headSha },
1555 });
1556 void fireWebhooks(gate.repoId, "pr", { action: "merged", number });
1557
1558 // Resolved post-merge SHA (best effort).
1559 let mergedSha: string | null = null;
1560 try {
1561 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1562 } catch {
1563 mergedSha = null;
1564 }
1565
1566 // Block M3 — informational payload: when the risk score is high or
1567 // critical, include the score + summary in the response even on a
1568 // successful merge so the caller has the audit context. Low/medium
1569 // bands stay quiet to keep response noise low.
1570 const response: {
1571 merged: true;
1572 sha: string;
1573 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1574 } = {
1575 merged: true,
1576 sha: mergedSha ?? headSha,
1577 };
1578 if (risk && (risk.band === "high" || risk.band === "critical")) {
1579 response.riskScore = serialisePrRiskForResponse(risk);
1580 }
1581 return response;
15821337 },
15831338};
15841339
1585/**
1586 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1587 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1588 */
1589function serialisePrRiskForResponse(risk: PrRiskScore) {
1590 return {
1591 score: risk.score,
1592 band: risk.band,
1593 aiSummary: risk.aiSummary,
1594 commitSha: risk.commitSha,
1595 signals: risk.signals,
1596 generatedAt:
1597 risk.generatedAt instanceof Date
1598 ? risk.generatedAt.toISOString()
1599 : String(risk.generatedAt),
1600 };
1601}
1602
16031340// ---------------------------------------------------------------------------
16041341// gluecron_close_pr
16051342// ---------------------------------------------------------------------------
Addedsrc/lib/pr-merge-gated.ts+366−0View fileUnifiedSplit
1/**
2 * Shared gated PR merge — the complete pre-merge policy chain plus merge
3 * mechanics, extracted verbatim from the MCP `gluecron_merge_pr` handler so
4 * the REST endpoint `POST /api/v2/repos/:owner/:repo/pulls/:number/merge`
5 * enforces exactly the same rules (issue #194: the API docs advertised that
6 * endpoint but nothing implemented it — and implementing it as a second
7 * inline copy of this chain is exactly how surfaces drift apart).
8 *
9 * Chain, in order:
10 * 1. open + non-draft guard
11 * 2. Block M3 — pre-merge risk score; `critical` band soft-blocks unless
12 * `confirmHighRisk` is passed. Prefers the SHA-pinned cache entry, falls
13 * back to the latest cached row, finally computes on demand so callers
14 * always get an answer in one round trip.
15 * 3. head ref resolves
16 * 4. GateTest + AI-review hard gates via `runAllGateChecks`; the
17 * "Merge check" (conflict probe) is soft — a failure routes step 6
18 * through AI conflict resolution instead of blocking.
19 * 5. D5 branch protection + CODEOWNERS enforcement (CODEOWNERS fails open
20 * on internal errors — a bug there must never hard-block every merge
21 * platform-wide).
22 * 6. the ref update itself (`git update-ref`, or `mergeWithAutoResolve`
23 * when step 4 saw conflicts and AI review is enabled — posting the
24 * auto-resolved-conflicts PR comment on success).
25 * 7. DB state flip, J7 close-keyword scanning, audit + activity log +
26 * webhooks.
27 *
28 * Relationship to `pr-merge.ts` (`performMerge`): that module is the
29 * POST-decision mechanics used by the autopilot's auto-merge sweep, whose
30 * gating happens upstream in `evaluateAutoMerge` (K2). This module is the
31 * interactive-caller chain (MCP tool + REST API): it evaluates the gates
32 * itself. Keep both interactive callers on this function — do not inline a
33 * third copy of the chain.
34 */
35
36import { and, eq } from "drizzle-orm";
37import { db } from "../db";
38import { prComments, pullRequests, type PullRequest } from "../db/schema";
39import { getRepoPath, refRange, resolveRef } from "../git/repository";
40import { runAllGateChecks } from "./gate";
41import {
42 countHumanApprovals,
43 evaluateProtection,
44 listRequiredChecks,
45 matchProtection,
46 passingCheckNames,
47} from "./branch-protection";
48import { mergeWithAutoResolve } from "./merge-resolver";
49import { isAiReviewApproved, isAiReviewEnabled } from "./ai-review";
50import { requiredOwnersApproved } from "./codeowners";
51import {
52 computePrRiskForPullRequest,
53 getCachedPrRisk,
54 getLatestCachedPrRisk,
55 type PrRiskScore,
56} from "./pr-risk";
57import { audit, logActivity } from "./notify";
58import { fireWebhooks } from "../routes/webhooks";
59
60/** The PR-row fields the chain actually reads. */
61export type GatedMergePr = Pick<
62 PullRequest,
63 | "id"
64 | "number"
65 | "repositoryId"
66 | "authorId"
67 | "title"
68 | "body"
69 | "state"
70 | "isDraft"
71 | "baseBranch"
72 | "headBranch"
73>;
74
75export interface GatedMergeArgs {
76 /** Canonical owner username / repo name AS STORED — not caller casing. */
77 owner: string;
78 repo: string;
79 repoId: string;
80 defaultBranch: string;
81 pr: GatedMergePr;
82 /** Stamped on `merged_by`, close-keyword comments, and audit rows. */
83 actorUserId: string;
84 /** When true, bypass the M3 critical-band soft-block. */
85 confirmHighRisk?: boolean;
86 /** Recorded in audit/activity metadata so incidents can tell paths apart. */
87 source: "mcp" | "api";
88}
89
90/**
91 * Compact serialiser for embedding a PrRiskScore in a response payload.
92 * Keeps the surface stable + JSON-safe (Date → ISO string).
93 */
94export function serialisePrRiskForResponse(risk: PrRiskScore) {
95 return {
96 score: risk.score,
97 band: risk.band,
98 aiSummary: risk.aiSummary,
99 commitSha: risk.commitSha,
100 signals: risk.signals,
101 generatedAt:
102 risk.generatedAt instanceof Date
103 ? risk.generatedAt.toISOString()
104 : String(risk.generatedAt),
105 };
106}
107
108export type SerialisedPrRisk = ReturnType<typeof serialisePrRiskForResponse>;
109
110export type GatedMergeResult = {
111 merged: boolean;
112 sha?: string;
113 reason?: string;
114 riskScore?: SerialisedPrRisk;
115};
116
117/**
118 * Run the full gated merge. Caller is responsible for authentication and
119 * write-access on the repo; everything from PR-state validation onward is
120 * enforced here. Never throws for policy failures — those come back as
121 * `{merged:false, reason}` so each transport picks its own status code.
122 */
123export async function performGatedMerge(
124 args: GatedMergeArgs
125): Promise<GatedMergeResult> {
126 const { owner, repo, repoId, defaultBranch, pr, actorUserId, source } = args;
127 const confirmHighRisk = args.confirmHighRisk === true;
128
129 if (pr.state !== "open") {
130 return { merged: false, reason: `pr is ${pr.state}, not open` };
131 }
132 if (pr.isDraft) {
133 return {
134 merged: false,
135 reason: "This PR is a draft. Mark it as ready for review before merging.",
136 };
137 }
138
139 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
140 // fall back to most-recent cached row; finally compute on demand so the
141 // caller always gets a score in one round trip.
142 let risk: PrRiskScore | null = null;
143 try {
144 risk =
145 (await getCachedPrRisk(pr.id)) ||
146 (await getLatestCachedPrRisk(pr.id)) ||
147 (await computePrRiskForPullRequest(pr.id));
148 } catch {
149 risk = null;
150 }
151
152 if (risk && risk.band === "critical" && !confirmHighRisk) {
153 return {
154 merged: false,
155 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
156 riskScore: serialisePrRiskForResponse(risk),
157 };
158 }
159
160 const headSha = await resolveRef(owner, repo, pr.headBranch);
161 if (!headSha) {
162 return { merged: false, reason: "Head branch not found" };
163 }
164
165 const aiApproved = await isAiReviewApproved(pr.id);
166
167 const gateResult = await runAllGateChecks(
168 owner,
169 repo,
170 pr.baseBranch,
171 pr.headBranch,
172 headSha,
173 aiApproved
174 );
175
176 const hardFailures = gateResult.checks.filter(
177 (check) => !check.passed && check.name !== "Merge check"
178 );
179 if (hardFailures.length > 0) {
180 return {
181 merged: false,
182 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
183 };
184 }
185
186 // D5 — branch-protection enforcement
187 const protectionRule = await matchProtection(repoId, pr.baseBranch);
188 if (protectionRule) {
189 const humanApprovals = await countHumanApprovals(pr.id);
190 const required = await listRequiredChecks(protectionRule.id);
191 const passingNames =
192 required.length > 0 ? await passingCheckNames(repoId, headSha) : [];
193 const decision = evaluateProtection(
194 protectionRule,
195 {
196 aiApproved,
197 humanApprovalCount: humanApprovals,
198 gateResultGreen: hardFailures.length === 0,
199 hasFailedGates: hardFailures.length > 0,
200 passingCheckNames: passingNames,
201 },
202 required.map((r) => r.checkName)
203 );
204
205 // CODEOWNERS enforcement — additive to evaluateProtection(), only
206 // when the rule already requires human review at all (no new DB
207 // column for this pass). Fail-open on any internal error: a bug here
208 // must never hard-block every merge platform-wide.
209 // The range builder is part of the condition, not inside the try: a
210 // stored branch name beginning with a dash would otherwise reach git as
211 // an option. This block fails open by design, so an unsafe ref skips
212 // CODEOWNERS enforcement rather than erroring.
213 const codeownersRange = refRange(pr.baseBranch, pr.headBranch);
214 if (
215 (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) &&
216 codeownersRange
217 ) {
218 try {
219 const codeownersDiffProc = Bun.spawn(
220 ["git", "diff", "--name-only", codeownersRange],
221 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
222 );
223 const codeownersDiffRaw = await new Response(
224 codeownersDiffProc.stdout
225 ).text();
226 await codeownersDiffProc.exited;
227 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
228 if (changedPaths.length > 0) {
229 const { satisfied, missingOwners } = await requiredOwnersApproved(
230 owner,
231 repo,
232 defaultBranch,
233 pr.id,
234 changedPaths
235 );
236 if (!satisfied) {
237 decision.allowed = false;
238 decision.reasons.push(
239 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
240 );
241 }
242 }
243 } catch (err) {
244 console.warn(
245 "[codeowners] merge enforcement failed:",
246 err instanceof Error ? err.message : err
247 );
248 }
249 }
250
251 if (!decision.allowed) {
252 return { merged: false, reason: decision.reasons.join(" ") };
253 }
254 }
255
256 const repoDir = getRepoPath(owner, repo);
257 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
258 const hasConflicts = mergeCheck && !mergeCheck.passed;
259
260 if (hasConflicts && isAiReviewEnabled()) {
261 const mergeResult = await mergeWithAutoResolve(
262 owner,
263 repo,
264 pr.baseBranch,
265 pr.headBranch,
266 `Merge pull request #${pr.number}: ${pr.title}`
267 );
268 if (!mergeResult.success) {
269 return {
270 merged: false,
271 reason: mergeResult.error || "Auto-merge failed",
272 };
273 }
274 if (mergeResult.resolvedFiles.length > 0) {
275 await db.insert(prComments).values({
276 pullRequestId: pr.id,
277 authorId: actorUserId,
278 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
279 .map((f) => `- \`${f}\``)
280 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
281 isAiReview: true,
282 });
283 }
284 } else {
285 const ffProc = Bun.spawn(
286 [
287 "git",
288 "update-ref",
289 `refs/heads/${pr.baseBranch}`,
290 `refs/heads/${pr.headBranch}`,
291 ],
292 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
293 );
294 const ffExit = await ffProc.exited;
295 if (ffExit !== 0) {
296 return {
297 merged: false,
298 reason: "Merge failed — unable to update branch ref",
299 };
300 }
301 }
302
303 await db
304 .update(pullRequests)
305 .set({
306 state: "merged",
307 mergedAt: new Date(),
308 mergedBy: actorUserId,
309 updatedAt: new Date(),
310 })
311 .where(eq(pullRequests.id, pr.id));
312
313 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
314 try {
315 const { applyClosingKeywords } = await import("./close-keywords-apply");
316 await applyClosingKeywords({
317 repositoryId: repoId,
318 prNumber: pr.number,
319 prTitle: pr.title,
320 prBody: pr.body,
321 actorUserId,
322 });
323 } catch {
324 /* never block merge on close-keyword failures */
325 }
326
327 await audit({
328 userId: actorUserId,
329 repositoryId: repoId,
330 action: "pr.merged",
331 targetType: "pull_request",
332 targetId: pr.id,
333 metadata: { source, number: pr.number, sha: headSha },
334 });
335
336 void logActivity({
337 repositoryId: repoId,
338 userId: actorUserId,
339 action: "pr_merge",
340 targetType: "pull_request",
341 targetId: String(pr.number),
342 metadata: { source, sha: headSha },
343 });
344 void fireWebhooks(repoId, "pr", { action: "merged", number: pr.number });
345
346 // Resolved post-merge SHA (best effort).
347 let mergedSha: string | null = null;
348 try {
349 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
350 } catch {
351 mergedSha = null;
352 }
353
354 // Block M3 — informational payload: when the risk score is high or
355 // critical, include the score + summary in the response even on a
356 // successful merge so the caller has the audit context. Low/medium
357 // bands stay quiet to keep response noise low.
358 const response: GatedMergeResult = {
359 merged: true,
360 sha: mergedSha ?? headSha,
361 };
362 if (risk && (risk.band === "high" || risk.band === "critical")) {
363 response.riskScore = serialisePrRiskForResponse(risk);
364 }
365 return response;
366}
Modifiedsrc/routes/api-v2.ts+69−0View fileUnifiedSplit
7272import { postCommitStatusHandler } from "./commit-statuses";
7373import { apiTokens } from "../db/schema";
7474import { audit } from "../lib/notify";
75import { performGatedMerge } from "../lib/pr-merge-gated";
7576import { removeTempDir, removeTempFile } from "../lib/tmp-cleanup";
7677import { clientIpFrom } from "../lib/client-ip";
7778import {
13811382 });
13821383});
13831384
1385// POST /api/v2/repos/:owner/:repo/pulls/:number/merge
1386//
1387// The endpoint the API docs advertised long before it existed (issue #194).
1388// Same policy chain as the MCP `gluecron_merge_pr` tool and the web merge
1389// flow, via the shared `performGatedMerge`: draft/state guards, M3 risk
1390// score (critical band soft-blocks unless `confirmHighRisk: true` in the
1391// JSON body), GateTest + AI-review hard gates, branch protection +
1392// CODEOWNERS. 200 {merged:true, sha} on success; 422 {merged:false, reason}
1393// on any policy failure so callers can distinguish "refused" from "broken".
1394apiv2.post(
1395 "/repos/:owner/:repo/pulls/:number/merge",
1396 requireApiAuth,
1397 requireScope("repo"),
1398 async (c) => {
1399 const { owner, repo } = c.req.param();
1400 const num = (parseIdNumber(c.req.param("number")) ?? -1);
1401 const user = c.get("user")!;
1402
1403 // Body is optional — the documented call takes no payload.
1404 let confirmHighRisk = false;
1405 try {
1406 const body = await c.req.json<{ confirmHighRisk?: boolean }>();
1407 confirmHighRisk = body?.confirmHighRisk === true;
1408 } catch {
1409 /* no body */
1410 }
1411
1412 const resolved = await resolveRepo(owner, repo);
1413 if (!resolved) return c.json({ error: "Not found" }, 404);
1414 const repoRow = resolved.repo as any;
1415
1416 const access = await resolveRepoAccess({
1417 repoId: repoRow.id,
1418 userId: user.id,
1419 isPublic: !repoRow.isPrivate,
1420 });
1421 if (!satisfiesAccess(access, "write")) {
1422 return c.json({ error: "Write access required" }, 403);
1423 }
1424
1425 const [pr] = await db
1426 .select()
1427 .from(pullRequests)
1428 .where(
1429 and(
1430 eq(pullRequests.repositoryId, repoRow.id),
1431 eq(pullRequests.number, num)
1432 )
1433 )
1434 .limit(1);
1435 if (!pr) return c.json({ error: "PR not found" }, 404);
1436
1437 const result = await performGatedMerge({
1438 // Canonical stored casing, not the caller's path segments.
1439 owner: (resolved.owner as any).username,
1440 repo: repoRow.name,
1441 repoId: repoRow.id,
1442 defaultBranch: repoRow.defaultBranch || "main",
1443 pr,
1444 actorUserId: user.id,
1445 confirmHighRisk,
1446 source: "api",
1447 });
1448
1449 return c.json(result, result.merged ? 200 : 422);
1450 }
1451);
1452
13841453// ─── Branch previews (migration 0062) ──────────────────────────────────────
13851454//
13861455// GET /api/v2/repos/:owner/:repo/previews
Modifiedsrc/routes/docs.tsx+8−1View fileUnifiedSplit
12911291
12921292// If gate checks fail:
12931293Response 422:
1294{ "merged": false, "reason": "GateTest: leaked secret in src/config.ts" }`}</code></pre>
1294{ "merged": false, "reason": "GateTest: leaked secret in src/config.ts" }
1295
1296// If the pre-merge risk score is critical, the merge soft-blocks:
1297Response 422:
1298{ "merged": false, "reason": "risk score is critical (9/10) — ...", "riskScore": { ... } }
1299
1300// Confirm past the soft-block with an optional JSON body:
1301{ "confirmHighRisk": true }`}</code></pre>
12951302
12961303 <h2 id="webhooks">Webhooks</h2>
12971304
12981305
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts