Skip to content

Commit 3469ce0

Browse files
AlbinoGeekclaude
andcommitted
feat(pr_preflight): pre-merge safety check
Composite GraphQL + REST call returns mergeable state, review approvals, CI status, behind-base count, pending reviewers, and a computed 'safe to merge' boolean with human-readable blocker reasons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 27affb2 commit 3469ce0

File tree

1 file changed

+268
-0
lines changed

1 file changed

+268
-0
lines changed

src/server/pr-preflight-tool.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
import { gateAuth } from "./github-auth.js";
4+
import { getOctokit, graphqlQuery } from "./github-client.js";
5+
import { jsonRespond } from "./json.js";
6+
import { FormatSchema, RepoRefSchema } from "./schemas.js";
7+
8+
interface ReviewNode {
9+
author: { login: string };
10+
state: string;
11+
}
12+
13+
interface CheckContext {
14+
name?: string;
15+
context?: string;
16+
conclusion?: string | null;
17+
status?: string;
18+
state?: string;
19+
}
20+
21+
interface PRPreflightData {
22+
repository: {
23+
pullRequest: {
24+
title: string;
25+
state: string;
26+
isDraft: boolean;
27+
mergeable: string;
28+
mergeStateStatus: string;
29+
baseRefName: string;
30+
headRefName: string;
31+
reviewDecision: string | null;
32+
labels: { nodes: { name: string }[] };
33+
reviews: { nodes: ReviewNode[] };
34+
reviewRequests: {
35+
nodes: { requestedReviewer: { login?: string; name?: string } }[];
36+
};
37+
commits: {
38+
nodes: {
39+
commit: {
40+
statusCheckRollup: {
41+
state: string;
42+
contexts: { nodes: CheckContext[] };
43+
} | null;
44+
};
45+
}[];
46+
};
47+
} | null;
48+
};
49+
}
50+
51+
const PR_PREFLIGHT_QUERY = `
52+
query PRPreflight($owner: String!, $repo: String!, $number: Int!) {
53+
repository(owner: $owner, name: $repo) {
54+
pullRequest(number: $number) {
55+
title state isDraft mergeable mergeStateStatus
56+
baseRefName headRefName reviewDecision
57+
labels(first: 20) { nodes { name } }
58+
reviews(last: 20) { nodes { author { login } state } }
59+
reviewRequests(first: 10) {
60+
nodes { requestedReviewer { ... on User { login } ... on Team { name } } }
61+
}
62+
commits(last: 1) {
63+
nodes {
64+
commit {
65+
statusCheckRollup {
66+
state
67+
contexts(first: 50) {
68+
nodes {
69+
... on CheckRun { name conclusion status }
70+
... on StatusContext { context state }
71+
}
72+
}
73+
}
74+
}
75+
}
76+
}
77+
}
78+
}
79+
}`;
80+
81+
export function registerPrPreflightTool(server: FastMCP): void {
82+
server.addTool({
83+
name: "pr_preflight",
84+
description:
85+
"Pre-merge safety check for a pull request. Returns mergeable state, review " +
86+
"approvals, CI status, commits behind base, pending reviewers, and a computed " +
87+
"'safe to merge' verdict with reasons.",
88+
annotations: { readOnlyHint: true },
89+
parameters: RepoRefSchema.extend({
90+
number: z.number().int().positive().describe("PR number."),
91+
format: FormatSchema,
92+
}),
93+
execute: async (args) => {
94+
const auth = gateAuth();
95+
if (!auth.ok) return jsonRespond(auth.body);
96+
97+
const { owner, repo, number: prNumber } = args;
98+
99+
try {
100+
const data = await graphqlQuery<PRPreflightData>(PR_PREFLIGHT_QUERY, {
101+
owner,
102+
repo,
103+
number: prNumber,
104+
});
105+
106+
const pr = data.repository.pullRequest;
107+
if (!pr) return jsonRespond({ error: "not_found", owner, repo, number: prNumber });
108+
109+
// Behind-base count via REST compare
110+
let behindBy = 0;
111+
try {
112+
const octokit = getOctokit();
113+
const cmp = await octokit.repos.compareCommits({
114+
owner,
115+
repo,
116+
base: pr.baseRefName,
117+
head: pr.headRefName,
118+
});
119+
behindBy = cmp.data.behind_by ?? 0;
120+
} catch {
121+
// comparison not available
122+
}
123+
124+
// De-duplicate reviews: latest per author
125+
const reviewMap = new Map<string, ReviewNode>();
126+
for (const r of pr.reviews.nodes) reviewMap.set(r.author.login, r);
127+
const reviews = [...reviewMap.values()];
128+
129+
// Pending reviewers
130+
const pendingReviewers = pr.reviewRequests.nodes
131+
.map((n) => n.requestedReviewer.login ?? n.requestedReviewer.name ?? "unknown")
132+
.filter(Boolean);
133+
134+
// CI
135+
const rollup = pr.commits.nodes[0]?.commit.statusCheckRollup;
136+
const ciStatus = rollup?.state ?? "UNKNOWN";
137+
const checks = (rollup?.contexts.nodes ?? []).map((c) => ({
138+
name: c.name ?? c.context ?? "unknown",
139+
conclusion: c.conclusion ?? c.state ?? null,
140+
status: c.status ?? c.state ?? "UNKNOWN",
141+
}));
142+
143+
const labels = pr.labels.nodes.map((l) => l.name);
144+
145+
// Compute verdict
146+
const reasons: string[] = [];
147+
let safe = true;
148+
149+
if (pr.state !== "OPEN") {
150+
safe = false;
151+
reasons.push(`PR is ${pr.state}`);
152+
}
153+
if (pr.isDraft) {
154+
safe = false;
155+
reasons.push("PR is a draft");
156+
}
157+
if (pr.mergeable === "CONFLICTING") {
158+
safe = false;
159+
reasons.push("Has merge conflicts");
160+
}
161+
if (pr.reviewDecision === "CHANGES_REQUESTED") {
162+
safe = false;
163+
const who = reviews
164+
.filter((r) => r.state === "CHANGES_REQUESTED")
165+
.map((r) => r.author.login);
166+
reasons.push(`Changes requested by ${who.join(", ")}`);
167+
} else if (pr.reviewDecision !== "APPROVED" && pendingReviewers.length > 0) {
168+
safe = false;
169+
reasons.push("Not yet approved");
170+
}
171+
172+
const failingChecks = checks.filter(
173+
(c) => c.conclusion === "FAILURE" || c.conclusion === "failure",
174+
);
175+
if (failingChecks.length > 0) {
176+
safe = false;
177+
reasons.push(`CI failing: ${failingChecks.map((c) => c.name).join(", ")}`);
178+
}
179+
const pendingChecks = checks.filter(
180+
(c) => c.conclusion === null && c.status !== "COMPLETED",
181+
);
182+
if (pendingChecks.length > 0) {
183+
safe = false;
184+
reasons.push("CI still running");
185+
}
186+
if (behindBy > 0) {
187+
reasons.push(`${behindBy} commits behind ${pr.baseRefName}`);
188+
}
189+
190+
const result = {
191+
number: prNumber,
192+
title: pr.title,
193+
safe,
194+
reasons,
195+
mergeable: pr.mergeable,
196+
reviewDecision: pr.reviewDecision,
197+
reviews: reviews.map((r) => ({ author: r.author.login, state: r.state })),
198+
pendingReviewers,
199+
ci: { status: ciStatus, checks },
200+
behindBase: behindBy,
201+
labels,
202+
conflicts: pr.mergeable === "CONFLICTING",
203+
};
204+
205+
if (args.format === "json") return jsonRespond(result);
206+
207+
// Markdown
208+
const icon = safe ? "✓" : "✗";
209+
const verdict = safe ? "Safe to merge" : "NOT safe to merge";
210+
const blockers = reasons.filter((r) => !r.includes("commits behind"));
211+
const warnings = reasons.filter((r) => r.includes("commits behind"));
212+
213+
let md = `# PR Preflight: ${owner}/${repo}#${prNumber}\n\n`;
214+
md += `## ${icon} ${verdict}\n\n`;
215+
216+
if (blockers.length > 0) {
217+
md += "**Blockers:**\n";
218+
for (const b of blockers) md += `- ✗ ${b}\n`;
219+
md += "\n";
220+
}
221+
if (warnings.length > 0) {
222+
md += "**Warnings:**\n";
223+
for (const w of warnings) md += `- ⚠ ${w}\n`;
224+
md += "\n";
225+
}
226+
227+
md += "| Check | Status |\n|-------|--------|\n";
228+
229+
// Reviews
230+
if (pr.reviewDecision === "APPROVED") {
231+
const who = reviews
232+
.filter((r) => r.state === "APPROVED")
233+
.map((r) => `${r.author.login} ✓`);
234+
md += `| Reviews | ✓ APPROVED (${who.join(", ")}) |\n`;
235+
} else if (pr.reviewDecision === "CHANGES_REQUESTED") {
236+
const who = reviews
237+
.filter((r) => r.state === "CHANGES_REQUESTED")
238+
.map((r) => r.author.login);
239+
md += `| Reviews | ✗ Changes requested by ${who.join(", ")} |\n`;
240+
} else {
241+
md += `| Reviews | ⚠ Pending (${pendingReviewers.join(", ") || "none"}) |\n`;
242+
}
243+
244+
// CI
245+
if (failingChecks.length > 0) {
246+
md += `| CI | ✗ ${failingChecks.length}/${checks.length} failing |\n`;
247+
} else if (pendingChecks.length > 0) {
248+
md += `| CI | ⚠ Running (${pendingChecks.length}/${checks.length}) |\n`;
249+
} else {
250+
md += "| CI | ✓ Passing |\n";
251+
}
252+
253+
md += `| Conflicts | ${pr.mergeable === "CONFLICTING" ? "✗ Has conflicts" : "✓ None"} |\n`;
254+
if (pendingReviewers.length > 0) {
255+
md += `| Pending reviewers | ${pendingReviewers.join(", ")} |\n`;
256+
}
257+
if (labels.length > 0) {
258+
md += `| Labels | ${labels.join(", ")} |\n`;
259+
}
260+
261+
return md;
262+
} catch (e) {
263+
const msg = e instanceof Error ? e.message : String(e);
264+
return jsonRespond({ error: "query_failed", message: msg, owner, repo, number: prNumber });
265+
}
266+
},
267+
});
268+
}

0 commit comments

Comments
 (0)