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

multi-repo-refactor.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.

multi-repo-refactor.tsBlame1183 lines · 1 contributor
23d0abfClaude1/**
2 * Multi-repo refactor agent.
3 *
4 * User issues one English request — "rename `getUserById` to `findUser`" —
5 * and the agent fans the change out across every repository the user owns,
6 * opening one coordinated AI-authored PR per affected repo. All PRs share
7 * a marker label `multi-repo:refactor:<refactorId>` so the UI can group
8 * them as a single logical change.
9 *
10 * Lifecycle (mirrored in the DB):
11 *
12 * planRefactor — Claude reads the description, walks the user's
13 * repos, returns a per-repo plan. Status flips
14 * `planning` → `building` once executeRefactor
15 * starts. Skipped repos never make it into the
16 * `multi_repo_refactor_prs` table at all.
17 *
18 * executeRefactor — for every planned repo we ask Claude for a
19 * concrete edit (same prompt shape as
20 * `ai-patch-generator` / `spec-to-pr`), write the
21 * files onto a fresh branch via the existing git
22 * plumbing helpers, and insert a draft `pull_requests`
23 * row. Per-repo failures flip just that child to
24 * `failed`; the parent stays in `building` until
25 * every child has terminated.
26 *
27 * coordinated merge — when every child is `opened` (and the caller has
28 * confirmed each PR is green + approved), invoke
29 * `mergeRefactor`. It walks children in insertion
30 * order and updates PR rows to `merged`. Any
31 * failure stops the cascade — the rest stay open.
32 *
33 * Design notes:
34 *
35 * - We never throw. Every step is funnelled through `{ ok, ... } | { ok:
36 * false, error }`. This keeps callers (the API route, the UI form
37 * handler, the autopilot loop) free of try/catch boilerplate.
38 *
39 * - The Anthropic client is injectable so the test-suite can pin
40 * deterministic plans without an API key. Production callers leave
41 * `client` undefined and we lazy-resolve via `ai-client.getAnthropic`.
42 *
43 * - Sequencing matters for the merge step but NOT for the build step.
44 * We open all PRs in parallel; the user's intent is one logical
45 * atomic change, but git itself can't atomic-merge across repos.
46 */
47
48import { and, desc, eq } from "drizzle-orm";
49import type Anthropic from "@anthropic-ai/sdk";
50import { db } from "../db";
51import {
52 labels,
53 multiRepoRefactorPrs,
54 multiRepoRefactors,
55 prComments,
56 pullRequests,
57 repositories,
58 users,
59 type MultiRepoRefactor,
60 type MultiRepoRefactorPr,
61} from "../db/schema";
62import {
63 createOrUpdateFileOnBranch,
64 getBlob,
65 refExists,
66 resolveRef,
67 updateRef,
68} from "../git/repository";
69import { config } from "./config";
70import { audit } from "./notify";
71import {
72 extractText,
73 getAnthropic,
74 MODEL_SONNET,
75 parseJsonResponse,
76} from "./ai-client";
77
78// ---------------------------------------------------------------------------
79// Public types
80// ---------------------------------------------------------------------------
81
82/** Marker prefix every multi-repo PR is tagged with. */
83export const MULTI_REPO_REFACTOR_LABEL_PREFIX = "multi-repo:refactor:";
84
85/** Marker baked into PR bodies so other tooling can recognise them. */
86export const MULTI_REPO_REFACTOR_MARKER =
87 "<!-- gluecron:multi-repo-refactor:v1 -->";
88
89export type RefactorStatus =
90 | "planning"
91 | "building"
92 | "ready_for_review"
93 | "merged"
94 | "failed";
95
96export type RefactorPrStatus =
97 | "pending"
98 | "building"
99 | "opened"
100 | "failed";
101
102/**
103 * Optional Anthropic client override — primarily for tests. We accept the
104 * narrow `Pick<Anthropic, "messages">` shape used elsewhere in the codebase
105 * (`ai-patch-generator`) so test fakes can implement `.messages.create`
106 * without committing to the whole SDK surface.
107 */
108export type ClaudeClient = Pick<Anthropic, "messages">;
109
110export interface RefactorRepoPlan {
111 repositoryId: string;
112 /** Owner namespace + repo name. Pre-resolved so the UI doesn't re-query. */
113 owner: string;
114 name: string;
115 /** Claude's short prediction of what it will change in this repo. */
116 predicted_changes_summary: string;
117}
118
119export interface PlanRefactorArgs {
120 userId: string;
121 description: string;
122 /** Optional explicit repo list. When omitted we walk every repo the user owns. */
123 repositoryIds?: string[];
124 /** Test-only override for the Anthropic client. */
125 client?: ClaudeClient;
126 /** Test-only override for the title — production derives one from the description. */
127 titleOverride?: string;
128}
129
130export type PlanRefactorResult =
131 | {
132 ok: true;
133 refactor: MultiRepoRefactor;
134 plan: RefactorRepoPlan[];
135 }
136 | { ok: false; error: string };
137
138export interface ExecuteRefactorArgs {
139 refactorId: string;
140 /** Test-only override for the Anthropic client. */
141 client?: ClaudeClient;
142}
143
144export interface ExecuteRefactorChildResult {
145 repositoryId: string;
146 status: RefactorPrStatus;
147 pullRequestId?: string;
148 prNumber?: number;
149 branch?: string;
150 error?: string;
151}
152
153export type ExecuteRefactorResult =
154 | {
155 ok: true;
156 refactor: MultiRepoRefactor;
157 children: ExecuteRefactorChildResult[];
158 }
159 | { ok: false; error: string };
160
161// ---------------------------------------------------------------------------
162// Pure helpers
163// ---------------------------------------------------------------------------
164
165/** Label name used to mark every PR in the refactor group. */
166export function refactorLabelName(refactorId: string): string {
167 return `${MULTI_REPO_REFACTOR_LABEL_PREFIX}${refactorId}`;
168}
169
170/** Derive a sentence-case title from the user's free-text description. */
171export function deriveTitle(description: string): string {
172 const trimmed = (description || "").trim();
173 if (!trimmed) return "Multi-repo refactor";
174 // Take the first line, cap at ~80 chars.
175 const firstLine = trimmed.split(/\r?\n/, 1)[0] || trimmed;
176 const capped = firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
177 return capped;
178}
179
180/**
181 * Derive a git-ref-safe branch name from a refactor id. Kept short so PR
182 * pages stay tidy in the branch column.
183 */
184export function refactorBranchName(refactorId: string): string {
185 const short = refactorId.replace(/-/g, "").slice(0, 8);
186 return `multi-repo-refactor/${short}`;
187}
188
189/**
190 * Build the planning prompt. Pure so tests can pin the shape without an
191 * API key.
192 */
193export function buildPlanPrompt(args: {
194 description: string;
195 repos: Array<{ id: string; owner: string; name: string; description: string | null }>;
196}): string {
197 const repoLines = args.repos
198 .map(
199 (r) =>
200 `- id=${r.id} | ${r.owner}/${r.name}${r.description ? ` — ${r.description}` : ""}`
201 )
202 .join("\n");
203 return [
204 "You are a refactoring planner for a multi-repository code change.",
205 "The user has issued ONE English request. Decide which of the listed",
206 "repositories are actually affected and, for each, write a one-line",
207 "prediction of the concrete change the editor agent should make.",
208 "",
209 "User request:",
210 args.description,
211 "",
212 "Repositories the user owns:",
213 repoLines || "(none)",
214 "",
215 "Respond ONLY with JSON of this exact shape:",
216 "{",
217 ' "title": "short title for the whole refactor (max 80 chars)",',
218 ' "affected": [',
219 ' { "repository_id": "<uuid>", "predicted_changes_summary": "1-sentence plan" }',
220 " ]",
221 "}",
222 "",
223 "Rules:",
224 "- Only include repos that are actually affected. Omit obvious no-ops.",
225 "- predicted_changes_summary must be concrete (e.g. `rename getUserById to findUser in src/lib/user.ts`).",
226 "- If no repo is affected, return an empty `affected` array.",
227 ].join("\n");
228}
229
230/**
231 * Build the per-repo edit prompt. Asks Claude for an end-state set of
232 * files, exactly like `ai-patch-generator` does.
233 */
234export function buildEditPrompt(args: {
235 description: string;
236 predictedChanges: string;
237 repoFiles: Array<{ path: string; content: string }>;
238}): string {
239 const fileBlocks = args.repoFiles
240 .map(
241 (f) =>
242 `--- FILE: ${f.path} ---\n\`\`\`\n${f.content}\n\`\`\`\n--- END FILE ---`
243 )
244 .join("\n\n");
245 return [
246 "You are implementing one slice of a multi-repository refactor.",
247 "",
248 "Overall refactor request:",
249 args.description,
250 "",
251 "Predicted change for THIS repository:",
252 args.predictedChanges,
253 "",
254 "Current contents of the files you may modify:",
255 fileBlocks || "(no files were pre-loaded)",
256 "",
257 "Respond ONLY with JSON of this exact shape:",
258 "{",
259 ' "explanation": "1-3 sentence summary of what you changed",',
260 ' "patches": [',
261 ' { "path": "same/path/as/above", "new_content": "FULL replacement file contents" }',
262 " ]",
263 "}",
264 "",
265 "Rules:",
266 "- Return [] (empty patches) if there is genuinely nothing to do.",
267 "- new_content MUST be the entire file, not a diff.",
268 "- Do not invent new files — only touch files you've been shown.",
269 "- Preserve existing formatting / indentation / trailing newlines.",
270 ].join("\n");
271}
272
273interface ClaudePlanResponse {
274 title?: string;
275 affected?: Array<{
276 repository_id?: string;
277 predicted_changes_summary?: string;
278 }>;
279}
280
281interface ClaudePatch {
282 path: string;
283 new_content: string;
284}
285
286interface ClaudeEditResponse {
287 explanation?: string;
288 patches?: ClaudePatch[];
289}
290
291// ---------------------------------------------------------------------------
292// Internals — DB + git glue
293// ---------------------------------------------------------------------------
294
295/**
296 * Resolve a `ClaudeClient` for the call. Returns null when no API key is
297 * configured and the caller didn't inject one — signals to bail before any
298 * DB writes.
299 */
300function resolveClient(override?: ClaudeClient): ClaudeClient | null {
301 if (override) return override;
302 if (!config.anthropicApiKey) return null;
303 try {
304 return getAnthropic();
305 } catch {
306 return null;
307 }
308}
309
310/**
311 * Load every repo the user owns, with the joined owner username. Used by
312 * `planRefactor` when the caller didn't pass an explicit list.
313 */
314async function listUserRepos(
315 userId: string,
316 filterIds?: string[]
317): Promise<Array<{ id: string; owner: string; name: string; description: string | null; defaultBranch: string }>> {
318 try {
319 const rows = await db
320 .select({
321 id: repositories.id,
322 owner: users.username,
323 name: repositories.name,
324 description: repositories.description,
325 defaultBranch: repositories.defaultBranch,
326 })
327 .from(repositories)
328 .innerJoin(users, eq(users.id, repositories.ownerId))
329 .where(eq(repositories.ownerId, userId));
330 if (!filterIds || filterIds.length === 0) return rows;
331 const filterSet = new Set(filterIds);
332 return rows.filter((r) => filterSet.has(r.id));
333 } catch (err) {
334 console.warn(
335 "[multi-repo-refactor] listUserRepos failed:",
336 err instanceof Error ? err.message : err
337 );
338 return [];
339 }
340}
341
342/**
343 * Ensure the group-marker label exists on a repo. Best-effort — the label
344 * is a UX nicety surfaced in PR bodies, not load-bearing.
345 */
346async function ensureGroupLabel(
347 repositoryId: string,
348 refactorId: string
349): Promise<void> {
350 try {
351 await db
352 .insert(labels)
353 .values({
354 repositoryId,
355 name: refactorLabelName(refactorId),
6fd5915Claude356 color: "#5b6ee8",
23d0abfClaude357 description: `Member of multi-repo refactor ${refactorId}`,
358 })
359 .onConflictDoNothing?.();
360 } catch (err) {
361 console.warn(
362 "[multi-repo-refactor] ensureGroupLabel failed:",
363 err instanceof Error ? err.message : err
364 );
365 }
366}
367
368/**
369 * Ask Claude to plan the refactor. Returns null on any failure (network,
370 * parse, missing key).
371 */
372async function askClaudeForPlan(
373 client: ClaudeClient,
374 description: string,
375 repos: Array<{ id: string; owner: string; name: string; description: string | null }>
376): Promise<ClaudePlanResponse | null> {
377 try {
378 const message = await client.messages.create({
379 model: MODEL_SONNET,
380 max_tokens: 4096,
381 messages: [
382 {
383 role: "user",
384 content: buildPlanPrompt({ description, repos }),
385 },
386 ],
387 });
1d4ff60Claude388 try {
389 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
390 const usage = extractUsage(message);
391 await recordAiCost({
392 model: MODEL_SONNET,
393 inputTokens: usage.input,
394 outputTokens: usage.output,
395 category: "refactor",
396 sourceKind: "multi_repo_plan",
397 });
398 } catch {
399 /* swallow — best-effort */
400 }
23d0abfClaude401 const text = extractText(message);
402 return parseJsonResponse<ClaudePlanResponse>(text);
403 } catch (err) {
404 console.warn(
405 "[multi-repo-refactor] plan call failed:",
406 err instanceof Error ? err.message : err
407 );
408 return null;
409 }
410}
411
412/**
413 * Ask Claude for the per-repo edit. Same shape as `ai-patch-generator`.
414 */
415async function askClaudeForEdit(
416 client: ClaudeClient,
417 description: string,
418 predictedChanges: string,
419 repoFiles: Array<{ path: string; content: string }>
420): Promise<ClaudeEditResponse | null> {
421 try {
422 const message = await client.messages.create({
423 model: MODEL_SONNET,
424 max_tokens: 4096,
425 messages: [
426 {
427 role: "user",
428 content: buildEditPrompt({
429 description,
430 predictedChanges,
431 repoFiles,
432 }),
433 },
434 ],
435 });
1d4ff60Claude436 try {
437 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
438 const usage = extractUsage(message);
439 await recordAiCost({
440 model: MODEL_SONNET,
441 inputTokens: usage.input,
442 outputTokens: usage.output,
443 category: "refactor",
444 sourceKind: "multi_repo_edit",
445 });
446 } catch {
447 /* swallow — best-effort */
448 }
23d0abfClaude449 const text = extractText(message);
450 return parseJsonResponse<ClaudeEditResponse>(text);
451 } catch (err) {
452 console.warn(
453 "[multi-repo-refactor] edit call failed:",
454 err instanceof Error ? err.message : err
455 );
456 return null;
457 }
458}
459
460/**
461 * Resolve repository row + owner username for use by the per-repo executor.
462 */
463async function loadRepoForExecute(
464 repositoryId: string
465): Promise<
466 | {
467 id: string;
468 owner: string;
469 ownerId: string;
470 name: string;
471 defaultBranch: string;
472 }
473 | null
474> {
475 try {
476 const [row] = await db
477 .select({
478 id: repositories.id,
479 owner: users.username,
480 ownerId: repositories.ownerId,
481 name: repositories.name,
482 defaultBranch: repositories.defaultBranch,
483 })
484 .from(repositories)
485 .innerJoin(users, eq(users.id, repositories.ownerId))
486 .where(eq(repositories.id, repositoryId))
487 .limit(1);
488 return row || null;
489 } catch {
490 return null;
491 }
492}
493
494/**
495 * Render the PR body for a child PR. Pure helper exported for tests.
496 */
497export function renderRefactorPrBody(args: {
498 refactorId: string;
499 refactorTitle: string;
500 description: string;
501 predictedChanges: string;
502 explanation: string;
503 patchPaths: string[];
504}): string {
505 const label = refactorLabelName(args.refactorId);
506 const quoted = args.description
507 .split("\n")
508 .map((l) => `> ${l}`)
509 .join("\n");
510 const files = args.patchPaths.map((p) => `- \`${p}\``).join("\n");
511 return [
512 MULTI_REPO_REFACTOR_MARKER,
513 `## ${args.refactorTitle}`,
514 "",
515 "_Part of a multi-repo refactor. Every PR in this group shares the",
516 `label \`${label}\` so they can be reviewed and merged together._`,
517 "",
518 "### Original request",
519 quoted,
520 "",
521 "### Predicted change for this repo",
522 args.predictedChanges,
523 "",
524 "### What changed",
525 args.explanation || "_(no explanation provided)_",
526 "",
527 "### Files",
528 files || "_(none)_",
529 "",
530 "---",
531 "",
532 `Refactor id: \`${args.refactorId}\``,
533 `Group label: \`${label}\``,
534 "",
535 "_Auto-generated by GlueCron multi-repo refactor agent. Review every line before merging._",
536 ].join("\n");
537}
538
539/**
540 * Seed a fresh branch at the repo's default-branch HEAD. Mirrors the
541 * `ai-patch-generator` helper but takes the branch HEAD instead of an
542 * explicit base sha because the planner doesn't track per-repo commit
543 * shas.
544 */
545async function seedBranchFromHead(
546 owner: string,
547 name: string,
548 branch: string,
549 defaultBranch: string
550): Promise<{ ok: true; baseSha: string } | { ok: false; error: string }> {
551 let baseSha: string | null;
552 try {
553 baseSha = await resolveRef(owner, name, defaultBranch);
554 } catch (err) {
555 return {
556 ok: false,
557 error: `resolveRef failed: ${err instanceof Error ? err.message : err}`,
558 };
559 }
560 if (!baseSha) {
561 return { ok: false, error: "repo has no commits on default branch" };
562 }
563 const fullRef = `refs/heads/${branch}`;
564 if (await refExists(owner, name, fullRef)) {
565 return { ok: true, baseSha };
566 }
567 const ok = await updateRef(owner, name, fullRef, baseSha);
568 if (!ok) return { ok: false, error: "updateRef failed" };
569 return { ok: true, baseSha };
570}
571
572/**
573 * Update the parent refactor's status. Best-effort; failures are logged
574 * but never propagate — the orchestrator already has a result to return.
575 */
576async function setRefactorStatus(
577 refactorId: string,
578 status: RefactorStatus
579): Promise<void> {
580 try {
581 await db
582 .update(multiRepoRefactors)
583 .set({ status, updatedAt: new Date() })
584 .where(eq(multiRepoRefactors.id, refactorId));
585 } catch (err) {
586 console.warn(
587 "[multi-repo-refactor] setRefactorStatus failed:",
588 err instanceof Error ? err.message : err
589 );
590 }
591}
592
593/**
594 * Roll the per-child statuses up into a parent status:
595 * - any child still building/pending → parent stays `building`
596 * - every child opened → `ready_for_review`
597 * - every child failed → `failed`
598 * - any child failed + at least one opened → `ready_for_review`
599 * (the user can still merge what worked; failed children stay
600 * surfaced in the UI so they can be retried)
601 */
602export function rollupStatus(
603 children: ReadonlyArray<{ status: RefactorPrStatus }>
604): RefactorStatus {
605 if (children.length === 0) return "failed";
606 const anyInFlight = children.some(
607 (c) => c.status === "pending" || c.status === "building"
608 );
609 if (anyInFlight) return "building";
610 const opened = children.filter((c) => c.status === "opened").length;
611 if (opened === 0) return "failed";
612 return "ready_for_review";
613}
614
615// ---------------------------------------------------------------------------
616// Public API — planRefactor
617// ---------------------------------------------------------------------------
618
619/**
620 * Stage 1 of the refactor pipeline. Persists a `planning` parent row +
621 * one `pending` child row per affected repo, then returns the plan so the
622 * caller (UI form handler or autopilot) can confirm it before kicking off
623 * `executeRefactor`.
624 *
625 * Never throws — failures are returned as `{ ok: false, error }`.
626 */
627export async function planRefactor(
628 args: PlanRefactorArgs
629): Promise<PlanRefactorResult> {
630 const description = (args.description || "").trim();
631 if (!description) return { ok: false, error: "description is empty" };
632
633 const client = resolveClient(args.client);
634 if (!client) {
635 return { ok: false, error: "ANTHROPIC_API_KEY required for refactor planning" };
636 }
637
638 // Load candidate repos.
639 const repos = await listUserRepos(args.userId, args.repositoryIds);
640 if (repos.length === 0) {
641 return { ok: false, error: "user owns no repositories to refactor" };
642 }
643
644 // Ask Claude to plan.
645 const planRes = await askClaudeForPlan(client, description, repos);
646 if (!planRes || !Array.isArray(planRes.affected)) {
647 return { ok: false, error: "planner returned invalid response" };
648 }
649
650 // Filter the plan down to repos we actually saw (defence-in-depth — a
651 // hallucinated id shouldn't be persisted).
652 const repoById = new Map(repos.map((r) => [r.id, r]));
653 const plan: RefactorRepoPlan[] = [];
654 for (const entry of planRes.affected) {
655 if (!entry || typeof entry.repository_id !== "string") continue;
656 const repo = repoById.get(entry.repository_id);
657 if (!repo) continue;
658 const summary =
659 (entry.predicted_changes_summary || "").trim() || "no summary provided";
660 plan.push({
661 repositoryId: repo.id,
662 owner: repo.owner,
663 name: repo.name,
664 predicted_changes_summary: summary,
665 });
666 }
667
668 if (plan.length === 0) {
669 return { ok: false, error: "planner identified no affected repos" };
670 }
671
672 // Persist the parent + children. Wrap each write so a single DB failure
673 // bubbles a clean error instead of a throw.
674 const title =
675 (args.titleOverride && args.titleOverride.trim()) ||
676 (planRes.title && planRes.title.trim()) ||
677 deriveTitle(description);
678
679 let parent: MultiRepoRefactor | null = null;
680 try {
681 const [row] = await db
682 .insert(multiRepoRefactors)
683 .values({
684 ownerUserId: args.userId,
685 title: title.slice(0, 200),
686 description,
687 status: "planning",
688 })
689 .returning();
690 parent = row || null;
691 } catch (err) {
692 return {
693 ok: false,
694 error: `parent insert failed: ${err instanceof Error ? err.message : err}`,
695 };
696 }
697 if (!parent) return { ok: false, error: "parent insert returned no row" };
698
699 try {
700 await db.insert(multiRepoRefactorPrs).values(
701 plan.map((p) => ({
702 refactorId: parent!.id,
703 repositoryId: p.repositoryId,
704 status: "pending" as const,
705 }))
706 );
707 } catch (err) {
708 return {
709 ok: false,
710 error: `children insert failed: ${err instanceof Error ? err.message : err}`,
711 };
712 }
713
714 await audit({
715 userId: args.userId,
716 action: "multi_repo_refactor.planned",
717 repositoryId: null,
718 metadata: {
719 refactorId: parent.id,
720 title,
721 affectedCount: plan.length,
722 },
723 });
724
725 return { ok: true, refactor: parent, plan };
726}
727
728// ---------------------------------------------------------------------------
729// Public API — executeRefactor
730// ---------------------------------------------------------------------------
731
732/**
733 * Stage 2 of the refactor pipeline. For every child row in `pending` we:
734 * 1. flip its status to `building`,
735 * 2. resolve the repo + owner,
736 * 3. seed a fresh branch off the default branch's HEAD,
737 * 4. ask Claude for end-state file contents,
738 * 5. write each file via `createOrUpdateFileOnBranch`,
739 * 6. insert the PR row + a marker comment naming the group label,
740 * 7. flip the child to `opened` (or `failed` on any non-recoverable error).
741 *
742 * Once every child has terminated we roll the statuses up into a parent
743 * status (`ready_for_review` / `failed`) and return the children list.
744 */
745export async function executeRefactor(
746 args: ExecuteRefactorArgs
747): Promise<ExecuteRefactorResult> {
748 if (!args.refactorId) return { ok: false, error: "refactorId required" };
749
750 const client = resolveClient(args.client);
751 if (!client) {
752 return { ok: false, error: "ANTHROPIC_API_KEY required for refactor execution" };
753 }
754
755 // Load parent.
756 let parent: MultiRepoRefactor | null = null;
757 try {
758 const [row] = await db
759 .select()
760 .from(multiRepoRefactors)
761 .where(eq(multiRepoRefactors.id, args.refactorId))
762 .limit(1);
763 parent = row || null;
764 } catch {
765 return { ok: false, error: "refactor lookup failed" };
766 }
767 if (!parent) return { ok: false, error: "refactor not found" };
768
769 if (parent.status === "merged") {
770 return { ok: false, error: "refactor already merged" };
771 }
772
773 // Load children that need work. We re-pick `pending` and `building` so a
774 // crash mid-execute is recoverable.
775 let children: MultiRepoRefactorPr[] = [];
776 try {
777 children = await db
778 .select()
779 .from(multiRepoRefactorPrs)
780 .where(eq(multiRepoRefactorPrs.refactorId, parent.id));
781 } catch {
782 return { ok: false, error: "children lookup failed" };
783 }
784
785 if (children.length === 0) {
786 return { ok: false, error: "refactor has no child repos" };
787 }
788
789 await setRefactorStatus(parent.id, "building");
790
791 const results: ExecuteRefactorChildResult[] = [];
792 // Sequential by default — keeps the volume of concurrent Claude calls
793 // sane and makes the test output deterministic. Production callers
794 // hammering this can swap to Promise.all when they're ready.
795 for (const child of children) {
796 if (child.status === "opened") {
797 results.push({
798 repositoryId: child.repositoryId,
799 status: "opened",
800 pullRequestId: child.pullRequestId ?? undefined,
801 });
802 continue;
803 }
804
805 const out = await executeChild({
806 client,
807 refactor: parent,
808 child,
809 });
810 results.push(out);
811 }
812
813 // Roll the children up into a parent status.
814 const rolled = rollupStatus(results);
815 await setRefactorStatus(parent.id, rolled);
816
817 // Refresh the parent row so the caller sees the new status.
818 let refreshed: MultiRepoRefactor = { ...parent, status: rolled };
819 try {
820 const [r] = await db
821 .select()
822 .from(multiRepoRefactors)
823 .where(eq(multiRepoRefactors.id, parent.id))
824 .limit(1);
825 if (r) refreshed = r;
826 } catch {
827 /* keep the in-memory rolled status */
828 }
829
830 return { ok: true, refactor: refreshed, children: results };
831}
832
833interface ExecuteChildArgs {
834 client: ClaudeClient;
835 refactor: MultiRepoRefactor;
836 child: MultiRepoRefactorPr;
837}
838
839/**
840 * Run the AI patch pipeline for one child row. Always returns a result —
841 * failures flip the child to `failed` with an error message stored on
842 * the row so the UI can surface it inline.
843 */
844async function executeChild(
845 args: ExecuteChildArgs
846): Promise<ExecuteRefactorChildResult> {
847 const { client, refactor, child } = args;
848
849 // Mark as building.
850 try {
851 await db
852 .update(multiRepoRefactorPrs)
853 .set({ status: "building", updatedAt: new Date() })
854 .where(eq(multiRepoRefactorPrs.id, child.id));
855 } catch {
856 /* non-fatal */
857 }
858
859 const repo = await loadRepoForExecute(child.repositoryId);
860 if (!repo) {
861 return finaliseChildFailure(child.id, child.repositoryId, "repo not found");
862 }
863
864 // Reuse the predicted summary from the plan if the row carries it.
865 // Today we don't persist the per-repo summary on the child row to keep
866 // the schema lean — we recompute by re-asking Claude inside the prompt
867 // (the description itself anchors the change).
868 const predictedChanges =
869 `Apply the multi-repo refactor described above to the \`${repo.owner}/${repo.name}\` repository.`;
870
871 const branch = refactorBranchName(refactor.id);
872 const seeded = await seedBranchFromHead(
873 repo.owner,
874 repo.name,
875 branch,
876 repo.defaultBranch || "main"
877 );
878 if (!seeded.ok) {
879 return finaliseChildFailure(
880 child.id,
881 child.repositoryId,
882 `seed branch failed: ${seeded.error}`
883 );
884 }
885
886 // Build a tiny pre-loaded context — for the first iteration we don't
887 // ship the whole repo to Claude, we let it work from the description +
888 // the predicted summary. Future enhancement: feed in the semantic-index
889 // top-k files for this repo. We do read README.md when present so
890 // Claude has *some* concrete anchor.
891 const repoFiles: Array<{ path: string; content: string }> = [];
892 try {
893 const readme = await getBlob(repo.owner, repo.name, seeded.baseSha, "README.md");
894 if (readme && !readme.isBinary) {
895 repoFiles.push({ path: "README.md", content: readme.content });
896 }
897 } catch {
898 /* README is a nice-to-have */
899 }
900
901 const editRes = await askClaudeForEdit(
902 client,
903 refactor.description,
904 predictedChanges,
905 repoFiles
906 );
907 if (
908 !editRes ||
909 !Array.isArray(editRes.patches) ||
910 editRes.patches.length === 0
911 ) {
912 return finaliseChildFailure(
913 child.id,
914 child.repositoryId,
915 "AI returned no patches"
916 );
917 }
918
919 // Apply patches.
920 const writtenPaths: string[] = [];
921 for (const patch of editRes.patches) {
922 if (
923 !patch ||
924 typeof patch.path !== "string" ||
925 typeof patch.new_content !== "string"
926 ) {
927 continue;
928 }
929 const res = await createOrUpdateFileOnBranch({
930 owner: repo.owner,
931 name: repo.name,
932 branch,
933 filePath: patch.path,
934 bytes: new TextEncoder().encode(patch.new_content),
935 message: `refactor(multi-repo): ${patch.path}`,
936 authorName: "GlueCron Multi-Repo Refactor",
937 authorEmail: "refactor@gluecron.com",
938 });
939 if ("error" in res) {
940 return finaliseChildFailure(
941 child.id,
942 child.repositoryId,
943 `write failed: ${res.error}`
944 );
945 }
946 writtenPaths.push(patch.path);
947 }
948
949 if (writtenPaths.length === 0) {
950 return finaliseChildFailure(
951 child.id,
952 child.repositoryId,
953 "no patches written"
954 );
955 }
956
957 // Ensure the group label row exists on this repo, then open the PR.
958 await ensureGroupLabel(repo.id, refactor.id);
959
960 const body = renderRefactorPrBody({
961 refactorId: refactor.id,
962 refactorTitle: refactor.title,
963 description: refactor.description,
964 predictedChanges,
965 explanation: editRes.explanation || "",
966 patchPaths: writtenPaths,
967 });
968 const title = `[refactor] ${refactor.title}`.slice(0, 200);
969 const baseBranch = repo.defaultBranch || "main";
970
971 let prId: string | null = null;
972 let prNumber: number | null = null;
973 try {
974 const [pr] = await db
975 .insert(pullRequests)
976 .values({
977 repositoryId: repo.id,
978 authorId: repo.ownerId,
979 title,
980 body,
981 baseBranch,
982 headBranch: branch,
983 isDraft: false,
984 })
985 .returning({ id: pullRequests.id, number: pullRequests.number });
986 if (pr) {
987 prId = pr.id;
988 prNumber = pr.number;
989 }
990 } catch (err) {
991 return finaliseChildFailure(
992 child.id,
993 child.repositoryId,
994 `PR insert failed: ${err instanceof Error ? err.message : err}`
995 );
996 }
997
998 if (!prId || prNumber == null) {
999 return finaliseChildFailure(
1000 child.id,
1001 child.repositoryId,
1002 "PR insert returned no row"
1003 );
1004 }
1005
1006 // Drop a marker comment so the label is discoverable without a join table.
1007 try {
1008 await db.insert(prComments).values({
1009 pullRequestId: prId,
1010 authorId: repo.ownerId,
1011 isAiReview: true,
1012 body: `${MULTI_REPO_REFACTOR_MARKER}\nApplied label: \`${refactorLabelName(refactor.id)}\``,
1013 });
1014 } catch {
1015 /* best-effort */
1016 }
1017
1018 // Mark the child as opened.
1019 try {
1020 await db
1021 .update(multiRepoRefactorPrs)
1022 .set({
1023 status: "opened",
1024 pullRequestId: prId,
1025 errorMessage: null,
1026 updatedAt: new Date(),
1027 })
1028 .where(eq(multiRepoRefactorPrs.id, child.id));
1029 } catch {
1030 /* the in-memory result still reflects success */
1031 }
1032
1033 await audit({
1034 userId: refactor.ownerUserId,
1035 action: "multi_repo_refactor.pr_opened",
1036 repositoryId: repo.id,
1037 metadata: {
1038 refactorId: refactor.id,
1039 prNumber,
1040 branch,
1041 },
1042 });
1043
1044 return {
1045 repositoryId: repo.id,
1046 status: "opened",
1047 pullRequestId: prId,
1048 prNumber,
1049 branch,
1050 };
1051}
1052
1053async function finaliseChildFailure(
1054 childId: string,
1055 repositoryId: string,
1056 error: string
1057): Promise<ExecuteRefactorChildResult> {
1058 try {
1059 await db
1060 .update(multiRepoRefactorPrs)
1061 .set({
1062 status: "failed",
1063 errorMessage: error.slice(0, 1000),
1064 updatedAt: new Date(),
1065 })
1066 .where(eq(multiRepoRefactorPrs.id, childId));
1067 } catch {
1068 /* non-fatal — we still return the error to the caller */
1069 }
1070 return { repositoryId, status: "failed", error };
1071}
1072
1073// ---------------------------------------------------------------------------
1074// Public API — read helpers
1075// ---------------------------------------------------------------------------
1076
1077export interface GetRefactorResult {
1078 refactor: MultiRepoRefactor;
1079 children: Array<
1080 MultiRepoRefactorPr & {
1081 repoOwner: string | null;
1082 repoName: string | null;
1083 prNumber: number | null;
1084 }
1085 >;
1086}
1087
1088/**
1089 * Load a refactor + its children, joined with the repo names and PR
1090 * numbers needed by the UI table.
1091 */
1092export async function getRefactor(
1093 refactorId: string,
1094 opts: { userId?: string } = {}
1095): Promise<GetRefactorResult | null> {
1096 let parent: MultiRepoRefactor | null = null;
1097 try {
1098 const conditions = opts.userId
1099 ? and(
1100 eq(multiRepoRefactors.id, refactorId),
1101 eq(multiRepoRefactors.ownerUserId, opts.userId)
1102 )
1103 : eq(multiRepoRefactors.id, refactorId);
1104 const [row] = await db
1105 .select()
1106 .from(multiRepoRefactors)
1107 .where(conditions)
1108 .limit(1);
1109 parent = row || null;
1110 } catch {
1111 return null;
1112 }
1113 if (!parent) return null;
1114
1115 let children: GetRefactorResult["children"] = [];
1116 try {
1117 const rows = await db
1118 .select({
1119 id: multiRepoRefactorPrs.id,
1120 refactorId: multiRepoRefactorPrs.refactorId,
1121 repositoryId: multiRepoRefactorPrs.repositoryId,
1122 pullRequestId: multiRepoRefactorPrs.pullRequestId,
1123 status: multiRepoRefactorPrs.status,
1124 errorMessage: multiRepoRefactorPrs.errorMessage,
1125 createdAt: multiRepoRefactorPrs.createdAt,
1126 updatedAt: multiRepoRefactorPrs.updatedAt,
1127 repoOwner: users.username,
1128 repoName: repositories.name,
1129 prNumber: pullRequests.number,
1130 })
1131 .from(multiRepoRefactorPrs)
1132 .leftJoin(
1133 repositories,
1134 eq(multiRepoRefactorPrs.repositoryId, repositories.id)
1135 )
1136 .leftJoin(users, eq(repositories.ownerId, users.id))
1137 .leftJoin(
1138 pullRequests,
1139 eq(multiRepoRefactorPrs.pullRequestId, pullRequests.id)
1140 )
1141 .where(eq(multiRepoRefactorPrs.refactorId, refactorId));
1142 children = rows as GetRefactorResult["children"];
1143 } catch {
1144 children = [];
1145 }
1146
1147 return { refactor: parent, children };
1148}
1149
1150/** List refactors a user owns, newest first. UI-facing. */
1151export async function listRefactorsForUser(
1152 userId: string,
1153 limit = 50
1154): Promise<MultiRepoRefactor[]> {
1155 try {
1156 return await db
1157 .select()
1158 .from(multiRepoRefactors)
1159 .where(eq(multiRepoRefactors.ownerUserId, userId))
1160 .orderBy(desc(multiRepoRefactors.createdAt))
1161 .limit(limit);
1162 } catch {
1163 return [];
1164 }
1165}
1166
1167// ---------------------------------------------------------------------------
1168// Test-only re-exports
1169// ---------------------------------------------------------------------------
1170
1171export const __test = {
1172 buildPlanPrompt,
1173 buildEditPrompt,
1174 rollupStatus,
1175 refactorLabelName,
1176 refactorBranchName,
1177 deriveTitle,
1178 renderRefactorPrBody,
1179 listUserRepos,
1180 ensureGroupLabel,
1181 seedBranchFromHead,
1182 setRefactorStatus,
1183};