Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

pr-splitter.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pr-splitter.tsBlame214 lines · 1 contributor
1d6db4dClaude1/**
2 * PR Split Suggestions — AI-powered guidance for decomposing large PRs.
3 *
4 * When a PR has >400 lines changed, Claude Sonnet is asked to suggest how
5 * to split it into 2-4 smaller, independently-mergeable PRs grouped by
6 * logical concern (schema / API / UI / tests etc.).
7 *
8 * Results are cached in memory for 1 hour per PR — the AI call is expensive
9 * and the diff doesn't change between page loads.
10 *
11 * Returns `null` when:
12 * - PR has <=400 changed lines
13 * - AI is unavailable (no ANTHROPIC_API_KEY)
14 * - Claude returns fewer than 2 suggestions
15 * - Any error occurs (always degrades gracefully)
16 */
17
18import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
19import { join } from "path";
20import { config } from "./config";
21
22// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26export interface SplitPr {
27 title: string;
28 rationale: string;
29 files: string[];
30 estimatedLines: number;
31 suggestedBranch: string;
32}
33
34export interface SplitSuggestion {
35 originalPrTitle: string;
36 totalFiles: number;
37 totalLines: number;
38 suggestedPrs: SplitPr[];
39 mergeOrder: string[];
40}
41
42// ---------------------------------------------------------------------------
43// In-memory cache (1h TTL per prId)
44// ---------------------------------------------------------------------------
45
46interface CacheEntry {
47 suggestion: SplitSuggestion | null;
48 cachedAt: number;
49}
50
51const _cache = new Map<string, CacheEntry>();
52const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
53
54function getCached(prId: string): SplitSuggestion | null | undefined {
55 const entry = _cache.get(prId);
56 if (!entry) return undefined; // cache miss
57 if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
58 _cache.delete(prId);
59 return undefined;
60 }
61 return entry.suggestion;
62}
63
64function setCached(prId: string, suggestion: SplitSuggestion | null): void {
65 _cache.set(prId, { suggestion, cachedAt: Date.now() });
66}
67
68// ---------------------------------------------------------------------------
69// Diff stat parsing
70// ---------------------------------------------------------------------------
71
72interface FileStatLine {
73 path: string;
74 added: number;
75 deleted: number;
76 total: number;
77}
78
79function parseNumstat(raw: string): FileStatLine[] {
80 return raw
81 .trim()
82 .split("\n")
83 .filter(Boolean)
84 .map((line) => {
85 const parts = line.split("\t");
86 if (parts.length < 3) return null;
87 const added = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
88 const deleted = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
89 return { path: parts[2], added, deleted, total: added + deleted };
90 })
91 .filter((x): x is FileStatLine => x !== null);
92}
93
94function getRepoDir(owner: string, repo: string): string {
95 return join(config.gitReposPath, `${owner}/${repo}.git`);
96}
97
98async function getDiffStat(
99 owner: string,
100 repo: string,
101 baseBranch: string,
102 headBranch: string
103): Promise<FileStatLine[]> {
104 const repoDir = getRepoDir(owner, repo);
105 const proc = Bun.spawn(
106 ["git", "--git-dir", repoDir, "diff", "--numstat", `${baseBranch}...${headBranch}`],
107 { stdout: "pipe", stderr: "pipe" }
108 );
109 const raw = await new Response(proc.stdout).text();
110 await proc.exited;
111 return parseNumstat(raw);
112}
113
114// ---------------------------------------------------------------------------
115// Public API
116// ---------------------------------------------------------------------------
117
118/**
119 * Suggest how to split a large PR.
120 * Returns null when the PR is small, AI is unavailable, or any error occurs.
121 */
122export async function suggestPrSplit(
123 prId: string,
124 prTitle: string,
125 ownerName: string,
126 repoName: string,
127 baseBranch: string,
128 headBranch: string
129): Promise<SplitSuggestion | null> {
130 // Check cache first
131 const cached = getCached(prId);
132 if (cached !== undefined) return cached;
133
134 try {
135 const fileStats = await getDiffStat(ownerName, repoName, baseBranch, headBranch);
136 const totalLines = fileStats.reduce((s, f) => s + f.total, 0);
137 const totalFiles = fileStats.length;
138
139 if (totalLines < 400) {
140 setCached(prId, null);
141 return null;
142 }
143
144 if (!isAiAvailable()) {
145 setCached(prId, null);
146 return null;
147 }
148
149 const fileList = fileStats
150 .map((f) => `${f.path} +${f.added} -${f.deleted}`)
151 .join("\n");
152
153 const prompt = `This PR is too large to review effectively (${totalLines} lines across ${totalFiles} files).
154Suggest how to split it into 2-4 smaller PRs that can be reviewed and merged independently.
155
156PR title: ${prTitle}
157Files changed:
158${fileList}
159
160Return JSON with this exact shape (no extra keys, no prose outside the JSON block):
161{
162 "suggestedPrs": [
163 {
164 "title": "...",
165 "rationale": "...",
166 "files": ["..."],
167 "estimatedLines": N,
168 "suggestedBranch": "..."
169 }
170 ],
171 "mergeOrder": ["PR title 1", "PR title 2"]
172}
173
174Rules:
175- Group by logical concern (schema changes together, API layer together, UI together)
176- Each suggested PR should be independently mergeable
177- Suggest merge order to minimise conflicts
178- suggestedBranch should be kebab-case derived from the PR title (e.g. feat/auth-schema-only)
179- Return between 2 and 4 suggested PRs`;
180
181 const anthropic = getAnthropic();
182 const message = await anthropic.messages.create({
183 model: MODEL_SONNET,
184 max_tokens: 1024,
185 messages: [{ role: "user", content: prompt }],
186 });
187
188 const text = extractText(message);
189 const parsed = parseJsonResponse<{
190 suggestedPrs: SplitPr[];
191 mergeOrder: string[];
192 }>(text);
193
194 if (!parsed || !Array.isArray(parsed.suggestedPrs) || parsed.suggestedPrs.length < 2) {
195 setCached(prId, null);
196 return null;
197 }
198
199 const suggestion: SplitSuggestion = {
200 originalPrTitle: prTitle,
201 totalFiles,
202 totalLines,
203 suggestedPrs: parsed.suggestedPrs,
204 mergeOrder: Array.isArray(parsed.mergeOrder) ? parsed.mergeOrder : [],
205 };
206
207 setCached(prId, suggestion);
208 return suggestion;
209 } catch {
210 // Always degrade gracefully
211 setCached(prId, null);
212 return null;
213 }
214}