Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame1157 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),
356 color: "#8c6dff",
357 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 });
388 const text = extractText(message);
389 return parseJsonResponse<ClaudePlanResponse>(text);
390 } catch (err) {
391 console.warn(
392 "[multi-repo-refactor] plan call failed:",
393 err instanceof Error ? err.message : err
394 );
395 return null;
396 }
397}
398
399/**
400 * Ask Claude for the per-repo edit. Same shape as `ai-patch-generator`.
401 */
402async function askClaudeForEdit(
403 client: ClaudeClient,
404 description: string,
405 predictedChanges: string,
406 repoFiles: Array<{ path: string; content: string }>
407): Promise<ClaudeEditResponse | null> {
408 try {
409 const message = await client.messages.create({
410 model: MODEL_SONNET,
411 max_tokens: 4096,
412 messages: [
413 {
414 role: "user",
415 content: buildEditPrompt({
416 description,
417 predictedChanges,
418 repoFiles,
419 }),
420 },
421 ],
422 });
423 const text = extractText(message);
424 return parseJsonResponse<ClaudeEditResponse>(text);
425 } catch (err) {
426 console.warn(
427 "[multi-repo-refactor] edit call failed:",
428 err instanceof Error ? err.message : err
429 );
430 return null;
431 }
432}
433
434/**
435 * Resolve repository row + owner username for use by the per-repo executor.
436 */
437async function loadRepoForExecute(
438 repositoryId: string
439): Promise<
440 | {
441 id: string;
442 owner: string;
443 ownerId: string;
444 name: string;
445 defaultBranch: string;
446 }
447 | null
448> {
449 try {
450 const [row] = await db
451 .select({
452 id: repositories.id,
453 owner: users.username,
454 ownerId: repositories.ownerId,
455 name: repositories.name,
456 defaultBranch: repositories.defaultBranch,
457 })
458 .from(repositories)
459 .innerJoin(users, eq(users.id, repositories.ownerId))
460 .where(eq(repositories.id, repositoryId))
461 .limit(1);
462 return row || null;
463 } catch {
464 return null;
465 }
466}
467
468/**
469 * Render the PR body for a child PR. Pure helper exported for tests.
470 */
471export function renderRefactorPrBody(args: {
472 refactorId: string;
473 refactorTitle: string;
474 description: string;
475 predictedChanges: string;
476 explanation: string;
477 patchPaths: string[];
478}): string {
479 const label = refactorLabelName(args.refactorId);
480 const quoted = args.description
481 .split("\n")
482 .map((l) => `> ${l}`)
483 .join("\n");
484 const files = args.patchPaths.map((p) => `- \`${p}\``).join("\n");
485 return [
486 MULTI_REPO_REFACTOR_MARKER,
487 `## ${args.refactorTitle}`,
488 "",
489 "_Part of a multi-repo refactor. Every PR in this group shares the",
490 `label \`${label}\` so they can be reviewed and merged together._`,
491 "",
492 "### Original request",
493 quoted,
494 "",
495 "### Predicted change for this repo",
496 args.predictedChanges,
497 "",
498 "### What changed",
499 args.explanation || "_(no explanation provided)_",
500 "",
501 "### Files",
502 files || "_(none)_",
503 "",
504 "---",
505 "",
506 `Refactor id: \`${args.refactorId}\``,
507 `Group label: \`${label}\``,
508 "",
509 "_Auto-generated by GlueCron multi-repo refactor agent. Review every line before merging._",
510 ].join("\n");
511}
512
513/**
514 * Seed a fresh branch at the repo's default-branch HEAD. Mirrors the
515 * `ai-patch-generator` helper but takes the branch HEAD instead of an
516 * explicit base sha because the planner doesn't track per-repo commit
517 * shas.
518 */
519async function seedBranchFromHead(
520 owner: string,
521 name: string,
522 branch: string,
523 defaultBranch: string
524): Promise<{ ok: true; baseSha: string } | { ok: false; error: string }> {
525 let baseSha: string | null;
526 try {
527 baseSha = await resolveRef(owner, name, defaultBranch);
528 } catch (err) {
529 return {
530 ok: false,
531 error: `resolveRef failed: ${err instanceof Error ? err.message : err}`,
532 };
533 }
534 if (!baseSha) {
535 return { ok: false, error: "repo has no commits on default branch" };
536 }
537 const fullRef = `refs/heads/${branch}`;
538 if (await refExists(owner, name, fullRef)) {
539 return { ok: true, baseSha };
540 }
541 const ok = await updateRef(owner, name, fullRef, baseSha);
542 if (!ok) return { ok: false, error: "updateRef failed" };
543 return { ok: true, baseSha };
544}
545
546/**
547 * Update the parent refactor's status. Best-effort; failures are logged
548 * but never propagate — the orchestrator already has a result to return.
549 */
550async function setRefactorStatus(
551 refactorId: string,
552 status: RefactorStatus
553): Promise<void> {
554 try {
555 await db
556 .update(multiRepoRefactors)
557 .set({ status, updatedAt: new Date() })
558 .where(eq(multiRepoRefactors.id, refactorId));
559 } catch (err) {
560 console.warn(
561 "[multi-repo-refactor] setRefactorStatus failed:",
562 err instanceof Error ? err.message : err
563 );
564 }
565}
566
567/**
568 * Roll the per-child statuses up into a parent status:
569 * - any child still building/pending → parent stays `building`
570 * - every child opened → `ready_for_review`
571 * - every child failed → `failed`
572 * - any child failed + at least one opened → `ready_for_review`
573 * (the user can still merge what worked; failed children stay
574 * surfaced in the UI so they can be retried)
575 */
576export function rollupStatus(
577 children: ReadonlyArray<{ status: RefactorPrStatus }>
578): RefactorStatus {
579 if (children.length === 0) return "failed";
580 const anyInFlight = children.some(
581 (c) => c.status === "pending" || c.status === "building"
582 );
583 if (anyInFlight) return "building";
584 const opened = children.filter((c) => c.status === "opened").length;
585 if (opened === 0) return "failed";
586 return "ready_for_review";
587}
588
589// ---------------------------------------------------------------------------
590// Public API — planRefactor
591// ---------------------------------------------------------------------------
592
593/**
594 * Stage 1 of the refactor pipeline. Persists a `planning` parent row +
595 * one `pending` child row per affected repo, then returns the plan so the
596 * caller (UI form handler or autopilot) can confirm it before kicking off
597 * `executeRefactor`.
598 *
599 * Never throws — failures are returned as `{ ok: false, error }`.
600 */
601export async function planRefactor(
602 args: PlanRefactorArgs
603): Promise<PlanRefactorResult> {
604 const description = (args.description || "").trim();
605 if (!description) return { ok: false, error: "description is empty" };
606
607 const client = resolveClient(args.client);
608 if (!client) {
609 return { ok: false, error: "ANTHROPIC_API_KEY required for refactor planning" };
610 }
611
612 // Load candidate repos.
613 const repos = await listUserRepos(args.userId, args.repositoryIds);
614 if (repos.length === 0) {
615 return { ok: false, error: "user owns no repositories to refactor" };
616 }
617
618 // Ask Claude to plan.
619 const planRes = await askClaudeForPlan(client, description, repos);
620 if (!planRes || !Array.isArray(planRes.affected)) {
621 return { ok: false, error: "planner returned invalid response" };
622 }
623
624 // Filter the plan down to repos we actually saw (defence-in-depth — a
625 // hallucinated id shouldn't be persisted).
626 const repoById = new Map(repos.map((r) => [r.id, r]));
627 const plan: RefactorRepoPlan[] = [];
628 for (const entry of planRes.affected) {
629 if (!entry || typeof entry.repository_id !== "string") continue;
630 const repo = repoById.get(entry.repository_id);
631 if (!repo) continue;
632 const summary =
633 (entry.predicted_changes_summary || "").trim() || "no summary provided";
634 plan.push({
635 repositoryId: repo.id,
636 owner: repo.owner,
637 name: repo.name,
638 predicted_changes_summary: summary,
639 });
640 }
641
642 if (plan.length === 0) {
643 return { ok: false, error: "planner identified no affected repos" };
644 }
645
646 // Persist the parent + children. Wrap each write so a single DB failure
647 // bubbles a clean error instead of a throw.
648 const title =
649 (args.titleOverride && args.titleOverride.trim()) ||
650 (planRes.title && planRes.title.trim()) ||
651 deriveTitle(description);
652
653 let parent: MultiRepoRefactor | null = null;
654 try {
655 const [row] = await db
656 .insert(multiRepoRefactors)
657 .values({
658 ownerUserId: args.userId,
659 title: title.slice(0, 200),
660 description,
661 status: "planning",
662 })
663 .returning();
664 parent = row || null;
665 } catch (err) {
666 return {
667 ok: false,
668 error: `parent insert failed: ${err instanceof Error ? err.message : err}`,
669 };
670 }
671 if (!parent) return { ok: false, error: "parent insert returned no row" };
672
673 try {
674 await db.insert(multiRepoRefactorPrs).values(
675 plan.map((p) => ({
676 refactorId: parent!.id,
677 repositoryId: p.repositoryId,
678 status: "pending" as const,
679 }))
680 );
681 } catch (err) {
682 return {
683 ok: false,
684 error: `children insert failed: ${err instanceof Error ? err.message : err}`,
685 };
686 }
687
688 await audit({
689 userId: args.userId,
690 action: "multi_repo_refactor.planned",
691 repositoryId: null,
692 metadata: {
693 refactorId: parent.id,
694 title,
695 affectedCount: plan.length,
696 },
697 });
698
699 return { ok: true, refactor: parent, plan };
700}
701
702// ---------------------------------------------------------------------------
703// Public API — executeRefactor
704// ---------------------------------------------------------------------------
705
706/**
707 * Stage 2 of the refactor pipeline. For every child row in `pending` we:
708 * 1. flip its status to `building`,
709 * 2. resolve the repo + owner,
710 * 3. seed a fresh branch off the default branch's HEAD,
711 * 4. ask Claude for end-state file contents,
712 * 5. write each file via `createOrUpdateFileOnBranch`,
713 * 6. insert the PR row + a marker comment naming the group label,
714 * 7. flip the child to `opened` (or `failed` on any non-recoverable error).
715 *
716 * Once every child has terminated we roll the statuses up into a parent
717 * status (`ready_for_review` / `failed`) and return the children list.
718 */
719export async function executeRefactor(
720 args: ExecuteRefactorArgs
721): Promise<ExecuteRefactorResult> {
722 if (!args.refactorId) return { ok: false, error: "refactorId required" };
723
724 const client = resolveClient(args.client);
725 if (!client) {
726 return { ok: false, error: "ANTHROPIC_API_KEY required for refactor execution" };
727 }
728
729 // Load parent.
730 let parent: MultiRepoRefactor | null = null;
731 try {
732 const [row] = await db
733 .select()
734 .from(multiRepoRefactors)
735 .where(eq(multiRepoRefactors.id, args.refactorId))
736 .limit(1);
737 parent = row || null;
738 } catch {
739 return { ok: false, error: "refactor lookup failed" };
740 }
741 if (!parent) return { ok: false, error: "refactor not found" };
742
743 if (parent.status === "merged") {
744 return { ok: false, error: "refactor already merged" };
745 }
746
747 // Load children that need work. We re-pick `pending` and `building` so a
748 // crash mid-execute is recoverable.
749 let children: MultiRepoRefactorPr[] = [];
750 try {
751 children = await db
752 .select()
753 .from(multiRepoRefactorPrs)
754 .where(eq(multiRepoRefactorPrs.refactorId, parent.id));
755 } catch {
756 return { ok: false, error: "children lookup failed" };
757 }
758
759 if (children.length === 0) {
760 return { ok: false, error: "refactor has no child repos" };
761 }
762
763 await setRefactorStatus(parent.id, "building");
764
765 const results: ExecuteRefactorChildResult[] = [];
766 // Sequential by default — keeps the volume of concurrent Claude calls
767 // sane and makes the test output deterministic. Production callers
768 // hammering this can swap to Promise.all when they're ready.
769 for (const child of children) {
770 if (child.status === "opened") {
771 results.push({
772 repositoryId: child.repositoryId,
773 status: "opened",
774 pullRequestId: child.pullRequestId ?? undefined,
775 });
776 continue;
777 }
778
779 const out = await executeChild({
780 client,
781 refactor: parent,
782 child,
783 });
784 results.push(out);
785 }
786
787 // Roll the children up into a parent status.
788 const rolled = rollupStatus(results);
789 await setRefactorStatus(parent.id, rolled);
790
791 // Refresh the parent row so the caller sees the new status.
792 let refreshed: MultiRepoRefactor = { ...parent, status: rolled };
793 try {
794 const [r] = await db
795 .select()
796 .from(multiRepoRefactors)
797 .where(eq(multiRepoRefactors.id, parent.id))
798 .limit(1);
799 if (r) refreshed = r;
800 } catch {
801 /* keep the in-memory rolled status */
802 }
803
804 return { ok: true, refactor: refreshed, children: results };
805}
806
807interface ExecuteChildArgs {
808 client: ClaudeClient;
809 refactor: MultiRepoRefactor;
810 child: MultiRepoRefactorPr;
811}
812
813/**
814 * Run the AI patch pipeline for one child row. Always returns a result —
815 * failures flip the child to `failed` with an error message stored on
816 * the row so the UI can surface it inline.
817 */
818async function executeChild(
819 args: ExecuteChildArgs
820): Promise<ExecuteRefactorChildResult> {
821 const { client, refactor, child } = args;
822
823 // Mark as building.
824 try {
825 await db
826 .update(multiRepoRefactorPrs)
827 .set({ status: "building", updatedAt: new Date() })
828 .where(eq(multiRepoRefactorPrs.id, child.id));
829 } catch {
830 /* non-fatal */
831 }
832
833 const repo = await loadRepoForExecute(child.repositoryId);
834 if (!repo) {
835 return finaliseChildFailure(child.id, child.repositoryId, "repo not found");
836 }
837
838 // Reuse the predicted summary from the plan if the row carries it.
839 // Today we don't persist the per-repo summary on the child row to keep
840 // the schema lean — we recompute by re-asking Claude inside the prompt
841 // (the description itself anchors the change).
842 const predictedChanges =
843 `Apply the multi-repo refactor described above to the \`${repo.owner}/${repo.name}\` repository.`;
844
845 const branch = refactorBranchName(refactor.id);
846 const seeded = await seedBranchFromHead(
847 repo.owner,
848 repo.name,
849 branch,
850 repo.defaultBranch || "main"
851 );
852 if (!seeded.ok) {
853 return finaliseChildFailure(
854 child.id,
855 child.repositoryId,
856 `seed branch failed: ${seeded.error}`
857 );
858 }
859
860 // Build a tiny pre-loaded context — for the first iteration we don't
861 // ship the whole repo to Claude, we let it work from the description +
862 // the predicted summary. Future enhancement: feed in the semantic-index
863 // top-k files for this repo. We do read README.md when present so
864 // Claude has *some* concrete anchor.
865 const repoFiles: Array<{ path: string; content: string }> = [];
866 try {
867 const readme = await getBlob(repo.owner, repo.name, seeded.baseSha, "README.md");
868 if (readme && !readme.isBinary) {
869 repoFiles.push({ path: "README.md", content: readme.content });
870 }
871 } catch {
872 /* README is a nice-to-have */
873 }
874
875 const editRes = await askClaudeForEdit(
876 client,
877 refactor.description,
878 predictedChanges,
879 repoFiles
880 );
881 if (
882 !editRes ||
883 !Array.isArray(editRes.patches) ||
884 editRes.patches.length === 0
885 ) {
886 return finaliseChildFailure(
887 child.id,
888 child.repositoryId,
889 "AI returned no patches"
890 );
891 }
892
893 // Apply patches.
894 const writtenPaths: string[] = [];
895 for (const patch of editRes.patches) {
896 if (
897 !patch ||
898 typeof patch.path !== "string" ||
899 typeof patch.new_content !== "string"
900 ) {
901 continue;
902 }
903 const res = await createOrUpdateFileOnBranch({
904 owner: repo.owner,
905 name: repo.name,
906 branch,
907 filePath: patch.path,
908 bytes: new TextEncoder().encode(patch.new_content),
909 message: `refactor(multi-repo): ${patch.path}`,
910 authorName: "GlueCron Multi-Repo Refactor",
911 authorEmail: "refactor@gluecron.com",
912 });
913 if ("error" in res) {
914 return finaliseChildFailure(
915 child.id,
916 child.repositoryId,
917 `write failed: ${res.error}`
918 );
919 }
920 writtenPaths.push(patch.path);
921 }
922
923 if (writtenPaths.length === 0) {
924 return finaliseChildFailure(
925 child.id,
926 child.repositoryId,
927 "no patches written"
928 );
929 }
930
931 // Ensure the group label row exists on this repo, then open the PR.
932 await ensureGroupLabel(repo.id, refactor.id);
933
934 const body = renderRefactorPrBody({
935 refactorId: refactor.id,
936 refactorTitle: refactor.title,
937 description: refactor.description,
938 predictedChanges,
939 explanation: editRes.explanation || "",
940 patchPaths: writtenPaths,
941 });
942 const title = `[refactor] ${refactor.title}`.slice(0, 200);
943 const baseBranch = repo.defaultBranch || "main";
944
945 let prId: string | null = null;
946 let prNumber: number | null = null;
947 try {
948 const [pr] = await db
949 .insert(pullRequests)
950 .values({
951 repositoryId: repo.id,
952 authorId: repo.ownerId,
953 title,
954 body,
955 baseBranch,
956 headBranch: branch,
957 isDraft: false,
958 })
959 .returning({ id: pullRequests.id, number: pullRequests.number });
960 if (pr) {
961 prId = pr.id;
962 prNumber = pr.number;
963 }
964 } catch (err) {
965 return finaliseChildFailure(
966 child.id,
967 child.repositoryId,
968 `PR insert failed: ${err instanceof Error ? err.message : err}`
969 );
970 }
971
972 if (!prId || prNumber == null) {
973 return finaliseChildFailure(
974 child.id,
975 child.repositoryId,
976 "PR insert returned no row"
977 );
978 }
979
980 // Drop a marker comment so the label is discoverable without a join table.
981 try {
982 await db.insert(prComments).values({
983 pullRequestId: prId,
984 authorId: repo.ownerId,
985 isAiReview: true,
986 body: `${MULTI_REPO_REFACTOR_MARKER}\nApplied label: \`${refactorLabelName(refactor.id)}\``,
987 });
988 } catch {
989 /* best-effort */
990 }
991
992 // Mark the child as opened.
993 try {
994 await db
995 .update(multiRepoRefactorPrs)
996 .set({
997 status: "opened",
998 pullRequestId: prId,
999 errorMessage: null,
1000 updatedAt: new Date(),
1001 })
1002 .where(eq(multiRepoRefactorPrs.id, child.id));
1003 } catch {
1004 /* the in-memory result still reflects success */
1005 }
1006
1007 await audit({
1008 userId: refactor.ownerUserId,
1009 action: "multi_repo_refactor.pr_opened",
1010 repositoryId: repo.id,
1011 metadata: {
1012 refactorId: refactor.id,
1013 prNumber,
1014 branch,
1015 },
1016 });
1017
1018 return {
1019 repositoryId: repo.id,
1020 status: "opened",
1021 pullRequestId: prId,
1022 prNumber,
1023 branch,
1024 };
1025}
1026
1027async function finaliseChildFailure(
1028 childId: string,
1029 repositoryId: string,
1030 error: string
1031): Promise<ExecuteRefactorChildResult> {
1032 try {
1033 await db
1034 .update(multiRepoRefactorPrs)
1035 .set({
1036 status: "failed",
1037 errorMessage: error.slice(0, 1000),
1038 updatedAt: new Date(),
1039 })
1040 .where(eq(multiRepoRefactorPrs.id, childId));
1041 } catch {
1042 /* non-fatal — we still return the error to the caller */
1043 }
1044 return { repositoryId, status: "failed", error };
1045}
1046
1047// ---------------------------------------------------------------------------
1048// Public API — read helpers
1049// ---------------------------------------------------------------------------
1050
1051export interface GetRefactorResult {
1052 refactor: MultiRepoRefactor;
1053 children: Array<
1054 MultiRepoRefactorPr & {
1055 repoOwner: string | null;
1056 repoName: string | null;
1057 prNumber: number | null;
1058 }
1059 >;
1060}
1061
1062/**
1063 * Load a refactor + its children, joined with the repo names and PR
1064 * numbers needed by the UI table.
1065 */
1066export async function getRefactor(
1067 refactorId: string,
1068 opts: { userId?: string } = {}
1069): Promise<GetRefactorResult | null> {
1070 let parent: MultiRepoRefactor | null = null;
1071 try {
1072 const conditions = opts.userId
1073 ? and(
1074 eq(multiRepoRefactors.id, refactorId),
1075 eq(multiRepoRefactors.ownerUserId, opts.userId)
1076 )
1077 : eq(multiRepoRefactors.id, refactorId);
1078 const [row] = await db
1079 .select()
1080 .from(multiRepoRefactors)
1081 .where(conditions)
1082 .limit(1);
1083 parent = row || null;
1084 } catch {
1085 return null;
1086 }
1087 if (!parent) return null;
1088
1089 let children: GetRefactorResult["children"] = [];
1090 try {
1091 const rows = await db
1092 .select({
1093 id: multiRepoRefactorPrs.id,
1094 refactorId: multiRepoRefactorPrs.refactorId,
1095 repositoryId: multiRepoRefactorPrs.repositoryId,
1096 pullRequestId: multiRepoRefactorPrs.pullRequestId,
1097 status: multiRepoRefactorPrs.status,
1098 errorMessage: multiRepoRefactorPrs.errorMessage,
1099 createdAt: multiRepoRefactorPrs.createdAt,
1100 updatedAt: multiRepoRefactorPrs.updatedAt,
1101 repoOwner: users.username,
1102 repoName: repositories.name,
1103 prNumber: pullRequests.number,
1104 })
1105 .from(multiRepoRefactorPrs)
1106 .leftJoin(
1107 repositories,
1108 eq(multiRepoRefactorPrs.repositoryId, repositories.id)
1109 )
1110 .leftJoin(users, eq(repositories.ownerId, users.id))
1111 .leftJoin(
1112 pullRequests,
1113 eq(multiRepoRefactorPrs.pullRequestId, pullRequests.id)
1114 )
1115 .where(eq(multiRepoRefactorPrs.refactorId, refactorId));
1116 children = rows as GetRefactorResult["children"];
1117 } catch {
1118 children = [];
1119 }
1120
1121 return { refactor: parent, children };
1122}
1123
1124/** List refactors a user owns, newest first. UI-facing. */
1125export async function listRefactorsForUser(
1126 userId: string,
1127 limit = 50
1128): Promise<MultiRepoRefactor[]> {
1129 try {
1130 return await db
1131 .select()
1132 .from(multiRepoRefactors)
1133 .where(eq(multiRepoRefactors.ownerUserId, userId))
1134 .orderBy(desc(multiRepoRefactors.createdAt))
1135 .limit(limit);
1136 } catch {
1137 return [];
1138 }
1139}
1140
1141// ---------------------------------------------------------------------------
1142// Test-only re-exports
1143// ---------------------------------------------------------------------------
1144
1145export const __test = {
1146 buildPlanPrompt,
1147 buildEditPrompt,
1148 rollupStatus,
1149 refactorLabelName,
1150 refactorBranchName,
1151 deriveTitle,
1152 renderRefactorPrBody,
1153 listUserRepos,
1154 ensureGroupLabel,
1155 seedBranchFromHead,
1156 setRefactorStatus,
1157};