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

migration-assistant.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.

migration-assistant.tsBlame1046 lines · 1 contributor
45f3b73Claude1/**
2 * Major-version migration assistant.
3 *
4 * When a dependency is several majors behind, Dependabot-style patch bumps
5 * aren't enough — the upgrade typically requires touching call-sites + test
6 * fixtures. This module wraps Claude to:
7 *
8 * 1. Locate every file that imports / uses the dependency (via grep
9 * over the recursive tree).
10 * 2. Pull each affected blob at `baseSha`.
11 * 3. Ask Claude for `{ explanation, patches, test_updates }`.
12 * 4. Apply the patches + test updates onto a fresh branch via
13 * `createOrUpdateFileOnBranch` — the same path `ai-patch-generator.ts`
14 * uses.
15 * 5. Open a PR titled `[migration] {dep} {from} -> {to}` carrying the
16 * `ai:major-migration` label tag in its body.
17 *
18 * The Claude client is injectable so unit tests pin the response without
19 * a network call. Production callers leave `client` undefined and we
20 * lazily wire `ai-client.getAnthropic()`.
21 *
22 * SAFETY:
23 * - Short-circuits to `null` if ANTHROPIC_API_KEY is unset AND no client
24 * was injected. Safe to fire from background tasks.
25 * - Wrapped in try/catch end-to-end — never throws.
26 * - Empty patches array means we do NOT open an empty PR.
27 * - Every successful proposal is logged in `audit_log` under
28 * `ai.migration.proposed` with `{dep, fromVersion, toVersion}`. The
29 * watcher uses this same row to enforce the 7-day per-repo throttle.
30 */
31
32import { createHash } from "crypto";
33import { and, desc, eq, gte, sql } from "drizzle-orm";
34import type Anthropic from "@anthropic-ai/sdk";
35import { db } from "../db";
36import {
37 auditLog,
38 labels,
39 prComments,
40 pullRequests,
41 repositories,
42 repoSettings,
43 users,
44} from "../db/schema";
45import {
46 createOrUpdateFileOnBranch,
47 getBlob,
48 getDefaultBranch,
49 getTreeRecursive,
50 refExists,
51 updateRef,
52 resolveRef,
53} from "../git/repository";
54import { config } from "./config";
55import { audit } from "./notify";
56import {
57 getAnthropic,
58 MODEL_SONNET,
59 extractText,
60 parseJsonResponse,
61} from "./ai-client";
62import { parseManifest } from "./dep-updater";
63
64/** Body marker so other tooling can spot AI-authored migration PRs. */
65export const MIGRATION_MARKER = "<!-- gluecron-ai-migration:proposed -->";
66
67/** Label surfaced (and ensured on the repo) for these PRs. */
68export const MIGRATION_LABEL = "ai:major-migration";
69
70/** Audit action used by both the propose call AND the dedupe check. */
71export const MIGRATION_AUDIT_ACTION = "ai.migration.proposed";
72
73/** Default throttle window: 1 PR per repo per 7 days. */
74export const MIGRATION_THROTTLE_HOURS = 24 * 7;
75
76// ---------------------------------------------------------------------------
77// Public types
78// ---------------------------------------------------------------------------
79
80export interface ProposeMigrationOptions {
81 repositoryId: string;
82 /** Package / crate / module name as it appears in the manifest. */
83 dependency: string;
84 /** e.g. `^3.2.1` or `3.2.1`. Free-form — Claude only needs the text. */
85 fromVersion: string;
86 /** e.g. `4.0.0`. */
87 toVersion: string;
88 /** Commit sha the migration is anchored against. Branch is forked from it. */
89 baseSha: string;
90 /**
91 * Optional changelog snippet to pass into the prompt. When the watcher
92 * fetches one from npm we forward it here; manual UI invocations leave
93 * it null.
94 */
95 changelog?: string | null;
96 /**
97 * Optional Anthropic client override — primarily for tests.
98 */
99 client?: Pick<Anthropic, "messages">;
100 /**
101 * Deterministic branch name for tests. Production code derives it from
102 * `{dependency}-{toVersion}-{timestamp}`.
103 */
104 branchOverride?: string;
105 /**
106 * Skip the dedupe check. The watcher always leaves this false; manual
107 * `/migrations/propose` form invocations can flip it on so a user can
108 * override the throttle.
109 */
110 skipThrottle?: boolean;
111}
112
113export interface ProposeMigrationResult {
114 branch: string;
115 prNumber: number;
116}
117
118/** One file change Claude returned. */
119interface ClaudePatch {
120 path: string;
121 new_content: string;
122}
123
124interface ClaudeMigrationResponse {
125 explanation?: string;
126 patches?: ClaudePatch[];
127 test_updates?: ClaudePatch[];
128}
129
130// ---------------------------------------------------------------------------
131// Manifest detection
132// ---------------------------------------------------------------------------
133
134/**
135 * The supported package manifests we look for, in priority order. The
136 * caller doesn't need to know which one a repo uses — we probe each at
137 * the default tree root.
138 */
139export const SUPPORTED_MANIFESTS = [
140 "package.json", // npm / bun / yarn
141 "pyproject.toml",
142 "Cargo.toml",
143 "go.mod",
144] as const;
145
146export type ManifestPath = (typeof SUPPORTED_MANIFESTS)[number];
147
148/**
149 * Find the first manifest that exists in `ref`'s tree root. Returns
150 * `{ path, content }` or null. Pure-ish (one git call) — exposed for
151 * tests of the watcher.
152 */
153export async function findManifest(
154 owner: string,
155 name: string,
156 ref: string
157): Promise<{ path: ManifestPath; content: string } | null> {
158 for (const path of SUPPORTED_MANIFESTS) {
159 try {
160 const blob = await getBlob(owner, name, ref, path);
161 if (blob && !blob.isBinary) {
162 return { path, content: blob.content };
163 }
164 } catch {
165 // Treat any error as "not present" and keep probing.
166 }
167 }
168 return null;
169}
170
171/**
172 * Substrings that strongly suggest a file references a given dependency.
173 * We accept any of these matching. Exposed for tests.
174 */
175export function dependencyHints(dependency: string): string[] {
176 const safe = dependency.trim();
177 if (!safe) return [];
178 const hints = new Set<string>();
179 hints.add(`"${safe}"`); // string-literal import / require
180 hints.add(`'${safe}'`);
181 hints.add(` from "${safe}"`);
182 hints.add(` from '${safe}'`);
183 hints.add(`require("${safe}")`);
184 hints.add(`require('${safe}')`);
185 // Python `import X` / `from X import ...`. The name in pyproject often
186 // differs from the importable module, but for a best-effort scan we
187 // accept the same string.
188 hints.add(`import ${safe}`);
189 hints.add(`from ${safe} `);
190 // Rust `use crate_name::` / `crate_name::`. Hyphens become underscores.
191 hints.add(`${safe.replace(/-/g, "_")}::`);
192 // Go imports — quoted path.
193 hints.add(`"${safe}"`);
194 return Array.from(hints);
195}
196
197/**
198 * Walk the repo's tree at `baseSha` and return file paths whose contents
199 * mention the dependency. Caps at `opts.maxFiles` so a runaway dep that
200 * touches the whole repo can't blow the prompt budget.
201 *
202 * NB: we currently read each blob to scan it. That's O(repo size) but
203 * fine for the small repos this lives behind. If we ever need it on
204 * 100k-file repos we can add a `git grep -l` shortcut.
205 */
206export async function findUsages(
207 owner: string,
208 name: string,
209 ref: string,
210 dependency: string,
211 opts: { maxFiles?: number; maxBytesPerFile?: number } = {}
212): Promise<string[]> {
213 const maxFiles = opts.maxFiles ?? 12;
214 const maxBytes = opts.maxBytesPerFile ?? 200_000;
215 const tree = await getTreeRecursive(owner, name, ref);
216 if (!tree) return [];
217 const hints = dependencyHints(dependency);
218 if (hints.length === 0) return [];
219
220 const matched: string[] = [];
221 for (const entry of tree.tree) {
222 if (entry.type !== "blob") continue;
223 if (entry.size != null && entry.size > maxBytes) continue;
224 // Manifest + lockfile mentions don't tell us anything we don't already
225 // know — skip them to focus on call-sites + tests.
226 if ((SUPPORTED_MANIFESTS as readonly string[]).includes(entry.path)) {
227 continue;
228 }
229 if (/(^|\/)(package-lock\.json|bun\.lockb|yarn\.lock|Cargo\.lock|go\.sum|poetry\.lock)$/.test(
230 entry.path
231 )) {
232 continue;
233 }
234 let blob;
235 try {
236 blob = await getBlob(owner, name, ref, entry.path);
237 } catch {
238 continue;
239 }
240 if (!blob || blob.isBinary) continue;
241 if (hints.some((h) => blob!.content.includes(h))) {
242 matched.push(entry.path);
243 if (matched.length >= maxFiles) break;
244 }
245 }
246 return matched;
247}
248
249// ---------------------------------------------------------------------------
250// Prompt + helpers
251// ---------------------------------------------------------------------------
252
253/**
254 * Build the Claude prompt. Pure so unit tests can pin the shape.
255 */
256export function buildMigrationPrompt(args: {
257 dependency: string;
258 fromVersion: string;
259 toVersion: string;
260 manifestPath: string | null;
261 changelog?: string | null;
262 files: Array<{ path: string; content: string }>;
263}): string {
264 const { dependency, fromVersion, toVersion, manifestPath, changelog, files } =
265 args;
266 const fileBlocks = files
267 .map(
268 (f) =>
269 `### \`${f.path}\`\n\`\`\`\n${f.content}\n\`\`\``
270 )
271 .join("\n\n");
272 const changelogBlock = changelog?.trim()
273 ? `\n\n**Changelog:**\n\`\`\`\n${changelog.trim()}\n\`\`\``
274 : "\n\n**Changelog:** _(none provided)_";
275 return [
276 `Upgrade \`${dependency}\` from \`${fromVersion}\` to \`${toVersion}\`.`,
277 `Manifest: \`${manifestPath ?? "(unknown)"}\``,
278 "",
279 "Below are the files in the repo that reference this dependency. Update",
280 "each one for the new major version. If a test file needs its fixtures",
281 "or assertions adjusted to match the new API, include it under",
282 `"test_updates" instead of "patches" so reviewers can see the split.`,
283 changelogBlock,
284 "",
285 "Files:",
286 "",
287 fileBlocks || "_(none — repo had no usages)_",
288 "",
289 "Respond ONLY with JSON of this exact shape:",
290 "{",
291 ' "explanation": "Short paragraph describing the breaking changes and how you addressed them.",',
292 ' "patches": [{ "path": "src/foo.ts", "new_content": "FULL replacement file" }],',
293 ' "test_updates":[{ "path": "test/foo.test.ts", "new_content": "FULL replacement file" }]',
294 "}",
295 "",
296 "Rules:",
297 `- Bump the dependency's version in the manifest itself if shown above.`,
298 "- new_content MUST be the entire file (not a diff).",
299 "- Only touch files you've been shown.",
300 "- If you cannot perform the migration safely, return empty patches/test_updates.",
301 ].join("\n");
302}
303
304/**
305 * Branch name. Test override wins; otherwise we use a slug + timestamp.
306 */
307export function migrationBranchName(
308 dependency: string,
309 toVersion: string,
310 override?: string
311): string {
312 if (override && override.trim()) return override.trim();
313 const slug =
314 `${dependency}-${toVersion}`
315 .toLowerCase()
316 .replace(/[^a-z0-9.+]/g, "-")
317 .replace(/-+/g, "-")
318 .replace(/^-|-$/g, "") || "migration";
319 return `ai-migration/${slug}-${Date.now()}`;
320}
321
322/**
323 * Render the PR body. Pure helper exported for tests.
324 */
325export function renderMigrationPrBody(args: {
326 dependency: string;
327 fromVersion: string;
328 toVersion: string;
329 explanation: string;
330 patchPaths: string[];
331 testPaths: string[];
332 changelog?: string | null;
333}): string {
334 const {
335 dependency,
336 fromVersion,
337 toVersion,
338 explanation,
339 patchPaths,
340 testPaths,
341 changelog,
342 } = args;
343 const fileList = (xs: string[]) =>
344 xs.length ? xs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
345 return [
346 MIGRATION_MARKER,
347 `## Major-version migration: \`${dependency}\` ${fromVersion} → ${toVersion}`,
348 "",
349 "### Summary",
350 explanation || "_(no explanation provided)_",
351 "",
352 "### Source changes",
353 fileList(patchPaths),
354 "",
355 "### Test changes",
356 fileList(testPaths),
357 "",
358 changelog?.trim()
359 ? `### Changelog excerpt\n\n\`\`\`\n${changelog.trim()}\n\`\`\``
360 : "_(no changelog provided)_",
361 "",
362 "---",
363 "",
364 `Labels: \`${MIGRATION_LABEL}\``,
365 "",
366 "_Auto-generated by GlueCron AI. Review carefully — major upgrades often have edge cases the model can't see._",
367 ].join("\n");
368}
369
370// ---------------------------------------------------------------------------
371// Internal helpers
372// ---------------------------------------------------------------------------
373
374async function resolveOwnerName(
375 repositoryId: string
376): Promise<{ owner: string; name: string; ownerId: string; defaultBranch: string | null } | null> {
377 try {
378 const [row] = await db
379 .select({
380 ownerId: repositories.ownerId,
381 ownerUsername: users.username,
382 name: repositories.name,
383 defaultBranch: repositories.defaultBranch,
384 })
385 .from(repositories)
386 .innerJoin(users, eq(repositories.ownerId, users.id))
387 .where(eq(repositories.id, repositoryId))
388 .limit(1);
389 if (!row) return null;
390 return {
391 owner: row.ownerUsername,
392 name: row.name,
393 ownerId: row.ownerId,
394 defaultBranch: row.defaultBranch ?? null,
395 };
396 } catch (err) {
397 console.error(
398 "[ai-migration] resolveOwnerName failed:",
399 err instanceof Error ? err.message : err
400 );
401 return null;
402 }
403}
404
405async function ensureMigrationLabel(repositoryId: string): Promise<void> {
406 try {
407 await db
408 .insert(labels)
409 .values({
410 repositoryId,
411 name: MIGRATION_LABEL,
6fd5915Claude412 color: "#5b6ee8",
45f3b73Claude413 description:
414 "Major-version dependency migration proposed by GlueCron AI",
415 })
416 .onConflictDoNothing?.();
417 } catch (err) {
418 console.warn(
419 "[ai-migration] ensureMigrationLabel failed:",
420 err instanceof Error ? err.message : err
421 );
422 }
423}
424
425async function seedBranchFromBase(
426 owner: string,
427 name: string,
428 branch: string,
429 baseSha: string
430): Promise<boolean> {
431 const fullRef = `refs/heads/${branch}`;
432 if (await refExists(owner, name, fullRef)) return true;
433 return updateRef(owner, name, fullRef, baseSha);
434}
435
436/**
437 * Has this exact `{dep, toVersion}` migration been proposed for this repo
438 * in the throttle window? Used by the watcher (skipThrottle:false). The
439 * audit row's metadata JSON is the source of truth.
440 */
441export async function recentlyProposed(
442 repositoryId: string,
443 dependency: string,
444 toVersion: string,
445 windowHours: number = MIGRATION_THROTTLE_HOURS
446): Promise<boolean> {
447 try {
448 const cutoff = new Date(Date.now() - windowHours * 60 * 60 * 1000);
449 const rows = await db
450 .select({ metadata: auditLog.metadata, createdAt: auditLog.createdAt })
451 .from(auditLog)
452 .where(
453 and(
454 eq(auditLog.repositoryId, repositoryId),
455 eq(auditLog.action, MIGRATION_AUDIT_ACTION),
456 gte(auditLog.createdAt, cutoff)
457 )
458 )
459 .orderBy(desc(auditLog.createdAt))
460 .limit(50);
461 for (const r of rows) {
462 if (!r.metadata) continue;
463 try {
464 const md = JSON.parse(r.metadata) as {
465 dependency?: string;
466 toVersion?: string;
467 };
468 if (md.dependency === dependency && md.toVersion === toVersion) {
469 return true;
470 }
471 } catch {
472 // Skip rows with malformed JSON.
473 }
474 }
475 return false;
476 } catch (err) {
477 // If we can't query, fall through and let the caller try.
478 console.warn(
479 "[ai-migration] recentlyProposed query failed:",
480 err instanceof Error ? err.message : err
481 );
482 return false;
483 }
484}
485
486/**
487 * Ask Claude for the migration. Returns parsed JSON or null on any
488 * failure (network, parse error).
489 */
490async function askClaudeForMigration(
491 client: Pick<Anthropic, "messages">,
492 prompt: string
493): Promise<ClaudeMigrationResponse | null> {
494 try {
495 const message = await client.messages.create({
496 model: MODEL_SONNET,
497 max_tokens: 8192,
498 messages: [{ role: "user", content: prompt }],
499 });
1d4ff60Claude500 try {
501 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
502 const usage = extractUsage(message);
503 await recordAiCost({
504 model: MODEL_SONNET,
505 inputTokens: usage.input,
506 outputTokens: usage.output,
507 category: "refactor",
508 sourceKind: "migration_assistant",
509 });
510 } catch {
511 /* swallow — best-effort */
512 }
45f3b73Claude513 const text = extractText(message);
514 return parseJsonResponse<ClaudeMigrationResponse>(text);
515 } catch (err) {
516 console.warn(
517 "[ai-migration] Claude call failed:",
518 err instanceof Error ? err.message : err
519 );
520 return null;
521 }
522}
523
524// ---------------------------------------------------------------------------
525// Main entry point
526// ---------------------------------------------------------------------------
527
528/**
529 * Propose a major-version migration. Returns the new branch + PR number,
530 * or `null` if anything went wrong / nothing actionable came back.
531 *
532 * Failure modes that yield null (and are logged but never thrown):
533 * - ANTHROPIC_API_KEY missing AND no `client` injected
534 * - repository row can't be resolved
535 * - already proposed within the throttle window
536 * - no manifest detected
537 * - Claude returned zero patches AND zero test_updates
538 * - git or DB write failed
539 */
540export async function proposeMajorMigration(
541 opts: ProposeMigrationOptions
542): Promise<ProposeMigrationResult | null> {
543 if (!opts.dependency?.trim() || !opts.toVersion?.trim()) return null;
544
545 // Lazy-resolve client so tests can inject without an API key.
546 let client: Pick<Anthropic, "messages">;
547 if (opts.client) {
548 client = opts.client;
549 } else {
550 if (!config.anthropicApiKey) return null;
551 try {
552 client = getAnthropic();
553 } catch {
554 return null;
555 }
556 }
557
558 const repo = await resolveOwnerName(opts.repositoryId);
559 if (!repo) return null;
560
561 if (!opts.skipThrottle) {
562 const dup = await recentlyProposed(
563 opts.repositoryId,
564 opts.dependency,
565 opts.toVersion
566 );
567 if (dup) return null;
568 }
569
570 await ensureMigrationLabel(opts.repositoryId);
571
572 // Locate manifest + usages.
573 const manifest = await findManifest(repo.owner, repo.name, opts.baseSha);
574 const usagePaths = await findUsages(
575 repo.owner,
576 repo.name,
577 opts.baseSha,
578 opts.dependency
579 );
580
581 // Build the context the model needs. Manifest is always included (so
582 // the model can bump the version pin); usages are appended.
583 const files: Array<{ path: string; content: string }> = [];
584 const seen = new Set<string>();
585 if (manifest) {
586 files.push({ path: manifest.path, content: manifest.content });
587 seen.add(manifest.path);
588 }
589 for (const p of usagePaths) {
590 if (seen.has(p)) continue;
591 try {
592 const blob = await getBlob(repo.owner, repo.name, opts.baseSha, p);
593 if (!blob || blob.isBinary) continue;
594 files.push({ path: p, content: blob.content });
595 seen.add(p);
596 } catch {
597 // skip
598 }
599 }
600
601 if (files.length === 0) {
602 // Nothing for the model to operate on.
603 return null;
604 }
605
606 const prompt = buildMigrationPrompt({
607 dependency: opts.dependency,
608 fromVersion: opts.fromVersion,
609 toVersion: opts.toVersion,
610 manifestPath: manifest?.path ?? null,
611 changelog: opts.changelog ?? null,
612 files,
613 });
614
615 const claudeRes = await askClaudeForMigration(client, prompt);
616 if (!claudeRes) return null;
617
618 const patches = Array.isArray(claudeRes.patches) ? claudeRes.patches : [];
619 const testUpdates = Array.isArray(claudeRes.test_updates)
620 ? claudeRes.test_updates
621 : [];
622 if (patches.length === 0 && testUpdates.length === 0) return null;
623
624 // Seed a fresh branch from baseSha.
625 const branch = migrationBranchName(
626 opts.dependency,
627 opts.toVersion,
628 opts.branchOverride
629 );
630 const seeded = await seedBranchFromBase(
631 repo.owner,
632 repo.name,
633 branch,
634 opts.baseSha
635 );
636 if (!seeded) {
637 console.warn(
638 `[ai-migration] could not seed branch ${branch} from ${opts.baseSha} for ${repo.owner}/${repo.name}`
639 );
640 return null;
641 }
642
643 const allChanges = [
644 ...patches.map((p) => ({ ...p, kind: "patch" as const })),
645 ...testUpdates.map((p) => ({ ...p, kind: "test" as const })),
646 ];
647
648 const writtenPatchPaths: string[] = [];
649 const writtenTestPaths: string[] = [];
650 let writeError: string | null = null;
651 for (const change of allChanges) {
652 if (
653 !change ||
654 typeof change.path !== "string" ||
655 typeof change.new_content !== "string"
656 ) {
657 continue;
658 }
659 const res = await createOrUpdateFileOnBranch({
660 owner: repo.owner,
661 name: repo.name,
662 branch,
663 filePath: change.path,
664 bytes: new TextEncoder().encode(change.new_content),
665 message: `chore(migration): ${opts.dependency} ${opts.fromVersion} -> ${opts.toVersion} (${change.path})`,
666 authorName: "GlueCron AI",
667 authorEmail: "ai@gluecron.com",
668 });
669 if ("error" in res) {
670 writeError = res.error;
671 break;
672 }
673 if (change.kind === "patch") writtenPatchPaths.push(change.path);
674 else writtenTestPaths.push(change.path);
675 }
676
677 if (writeError || writtenPatchPaths.length + writtenTestPaths.length === 0) {
678 console.warn(
679 `[ai-migration] write failed (${writeError ?? "no patches written"}) on ${repo.owner}/${repo.name}@${branch}`
680 );
681 return null;
682 }
683
684 // Default branch lookup (base for the PR).
685 let baseBranch = repo.defaultBranch || "main";
686 try {
687 if (!repo.defaultBranch) {
688 const probed = await getDefaultBranch(repo.owner, repo.name);
689 if (probed) baseBranch = probed;
690 }
691 } catch {
692 // keep default
693 }
694
695 const title = `[migration] ${opts.dependency} ${opts.fromVersion} → ${opts.toVersion}`;
696 const body = renderMigrationPrBody({
697 dependency: opts.dependency,
698 fromVersion: opts.fromVersion,
699 toVersion: opts.toVersion,
700 explanation: claudeRes.explanation || "",
701 patchPaths: writtenPatchPaths,
702 testPaths: writtenTestPaths,
703 changelog: opts.changelog ?? null,
704 });
705
706 let prNumber: number | null = null;
707 try {
708 const [pr] = await db
709 .insert(pullRequests)
710 .values({
711 repositoryId: opts.repositoryId,
712 authorId: repo.ownerId,
713 title,
714 body,
715 baseBranch,
716 headBranch: branch,
717 isDraft: false,
718 })
719 .returning({ number: pullRequests.number, id: pullRequests.id });
720 if (pr) {
721 prNumber = pr.number;
722 // Drop a marker comment so the label can be associated without
723 // a PR<->label join table — same pattern ai-patch-generator uses.
724 try {
725 await db.insert(prComments).values({
726 pullRequestId: pr.id,
727 authorId: repo.ownerId,
728 isAiReview: true,
729 body: `${MIGRATION_MARKER}\nApplied label: \`${MIGRATION_LABEL}\``,
730 });
731 } catch (err) {
732 console.warn(
733 "[ai-migration] failed to insert label-marker comment:",
734 err instanceof Error ? err.message : err
735 );
736 }
737 }
738 } catch (err) {
739 console.error(
740 "[ai-migration] failed to insert pullRequests row:",
741 err instanceof Error ? err.message : err
742 );
743 return null;
744 }
745
746 if (prNumber == null) return null;
747
748 await audit({
749 userId: null,
750 action: MIGRATION_AUDIT_ACTION,
751 repositoryId: opts.repositoryId,
752 metadata: {
753 dependency: opts.dependency,
754 fromVersion: opts.fromVersion,
755 toVersion: opts.toVersion,
756 branch,
757 prNumber,
758 baseSha: opts.baseSha,
759 patchCount: writtenPatchPaths.length,
760 testCount: writtenTestPaths.length,
761 },
762 });
763
764 return { branch, prNumber };
765}
766
767// ---------------------------------------------------------------------------
768// Watcher — autopilot task entry point
769// ---------------------------------------------------------------------------
770
771export interface MigrationWatcherSummary {
772 considered: number;
773 proposed: number;
774 skippedThrottle: number;
775 skippedNotEnabled: number;
776 errors: number;
777}
778
779export interface MigrationWatcherDeps {
780 /** Override the npm-latest lookup (DI for tests). */
781 fetchLatest?: (name: string) => Promise<string | null>;
782 /** Override the propose call (DI for tests). */
783 propose?: (
784 opts: ProposeMigrationOptions
785 ) => Promise<ProposeMigrationResult | null>;
786 /**
787 * Override the per-repo "is migration_watch on?" check. Default reads
788 * the env flag (`MIGRATION_WATCHER_ENABLED`) + the repo's
789 * `aiPrSummaryEnabled` flag as a proxy for "AI features are on for
790 * this repo". When we add a dedicated column this default flips to
791 * read it.
792 */
793 isEnabled?: (repositoryId: string) => Promise<boolean>;
794 /** Hard cap on repos visited per tick. */
795 maxReposPerTick?: number;
796}
797
798/**
799 * Detect "is this version several majors behind?". Returns the parsed
800 * `from`/`to` strings ready to feed `proposeMajorMigration`, or null when
801 * either side can't be parsed or it isn't a major bump.
802 *
803 * Exported for direct unit testing.
804 */
805export function detectMajorBump(
806 currentRange: string,
807 latestVersion: string
808): { from: string; to: string } | null {
809 const cur = matchSemver(currentRange);
810 const lat = matchSemver(latestVersion);
811 if (!cur || !lat) return null;
812 if (lat.major <= cur.major) return null;
813 return { from: currentRange.trim(), to: latestVersion.trim() };
814}
815
816function matchSemver(s: string): { major: number; minor: number; patch: number } | null {
817 if (!s || typeof s !== "string") return null;
818 const m = s.match(/(\d+)\.(\d+)\.(\d+)/);
819 if (!m) return null;
820 return {
821 major: parseInt(m[1], 10),
822 minor: parseInt(m[2], 10),
823 patch: parseInt(m[3], 10),
824 };
825}
826
827/** Default registry-latest lookup with a 5s timeout. Never throws. */
828async function defaultFetchLatest(pkg: string): Promise<string | null> {
829 try {
830 const ctrl = new AbortController();
831 const t = setTimeout(() => ctrl.abort(), 5000);
832 try {
833 const safe = encodeURIComponent(pkg).replace(/%40/g, "@");
834 const res = await fetch(`https://registry.npmjs.org/${safe}/latest`, {
835 headers: { accept: "application/json" },
836 signal: ctrl.signal,
837 });
838 if (!res.ok) return null;
839 const data = (await res.json()) as { version?: unknown };
840 return typeof data.version === "string" ? data.version : null;
841 } finally {
842 clearTimeout(t);
843 }
844 } catch {
845 return null;
846 }
847}
848
849/**
850 * Default "is migration_watch enabled?" check. Reads
851 * `MIGRATION_WATCHER_ENABLED` env var (must be `1`/`true`) and falls
852 * back to the repo's `aiPrSummaryEnabled` flag as a proxy for AI opt-in.
853 * Returns true only when both signals agree.
854 */
855async function defaultIsEnabled(repositoryId: string): Promise<boolean> {
856 const envFlag =
857 process.env.MIGRATION_WATCHER_ENABLED === "1" ||
858 process.env.MIGRATION_WATCHER_ENABLED === "true";
859 if (!envFlag) return false;
860 try {
861 const [row] = await db
862 .select({ on: repoSettings.aiPrSummaryEnabled })
863 .from(repoSettings)
864 .where(eq(repoSettings.repositoryId, repositoryId))
865 .limit(1);
866 if (!row) return true; // default-on when settings row is absent
867 return Boolean(row.on);
868 } catch {
869 return false;
870 }
871}
872
873/**
874 * One iteration of the migration-watcher autopilot task.
875 *
876 * Per repo:
877 * 1. Skip if disabled.
878 * 2. Skip if a migration was already proposed in the last 7 days
879 * (single audit lookup, no per-dep cost yet).
880 * 3. Pull `package.json` from the default branch (other manifests are
881 * a future expansion).
882 * 4. For each declared dep, fetch the latest from npm (5s timeout,
883 * fail-soft).
884 * 5. If at least one major behind AND not already proposed for this
885 * exact `{dep, latest}`, kick off `proposeMajorMigration` and stop
886 * after the first one (cap 1 per repo per tick).
887 */
888export async function runMigrationWatcherTaskOnce(
889 deps: MigrationWatcherDeps = {}
890): Promise<MigrationWatcherSummary> {
891 const summary: MigrationWatcherSummary = {
892 considered: 0,
893 proposed: 0,
894 skippedThrottle: 0,
895 skippedNotEnabled: 0,
896 errors: 0,
897 };
898
899 if (!config.anthropicApiKey && !deps.propose) {
900 // No AI configured + no test injection -> nothing useful to do.
901 return summary;
902 }
903
904 const fetchLatest = deps.fetchLatest ?? defaultFetchLatest;
905 const propose = deps.propose ?? proposeMajorMigration;
906 const isEnabled = deps.isEnabled ?? defaultIsEnabled;
907 const maxRepos = deps.maxReposPerTick ?? 25;
908
909 let repos: Array<{
910 id: string;
911 owner: string;
912 name: string;
913 defaultBranch: string | null;
914 }> = [];
915 try {
916 repos = await db
917 .select({
918 id: repositories.id,
919 owner: users.username,
920 name: repositories.name,
921 defaultBranch: repositories.defaultBranch,
922 })
923 .from(repositories)
924 .innerJoin(users, eq(repositories.ownerId, users.id))
925 .limit(maxRepos);
926 } catch (err) {
927 console.warn(
928 "[ai-migration] watcher repo lookup failed:",
929 err instanceof Error ? err.message : err
930 );
931 return summary;
932 }
933
934 for (const repo of repos) {
935 summary.considered++;
936 try {
937 const enabled = await isEnabled(repo.id);
938 if (!enabled) {
939 summary.skippedNotEnabled++;
940 continue;
941 }
942
943 // Coarse repo-level throttle: only one migration PR per repo per
944 // window. Cheap-ish — one audit query per repo.
945 const recentAny = await anyMigrationInWindow(repo.id);
946 if (recentAny) {
947 summary.skippedThrottle++;
948 continue;
949 }
950
951 const branch = repo.defaultBranch || "main";
952 const baseSha = await resolveRef(repo.owner, repo.name, branch);
953 if (!baseSha) continue;
954 const blob = await getBlob(repo.owner, repo.name, branch, "package.json");
955 if (!blob || blob.isBinary) continue;
956
957 const manifest = parseManifest(blob.content);
958 const allDeps: Array<{ name: string; range: string }> = [
959 ...Object.entries(manifest.dependencies || {}).map(([name, range]) => ({
960 name,
961 range,
962 })),
963 ...Object.entries(manifest.devDependencies || {}).map(
964 ([name, range]) => ({ name, range })
965 ),
966 ];
967
968 for (const dep of allDeps) {
969 const latest = await fetchLatest(dep.name);
970 if (!latest) continue;
971 const bump = detectMajorBump(dep.range, latest);
972 if (!bump) continue;
973 // Per-{dep, version} dedup — guards against the watcher proposing
974 // the same migration twice in a single 7d window (e.g. across
975 // restarts).
976 const dup = await recentlyProposed(repo.id, dep.name, bump.to);
977 if (dup) {
978 summary.skippedThrottle++;
979 continue;
980 }
981 const result = await propose({
982 repositoryId: repo.id,
983 dependency: dep.name,
984 fromVersion: bump.from,
985 toVersion: bump.to,
986 baseSha,
987 });
988 if (result) {
989 summary.proposed++;
990 }
991 // One per repo per tick regardless of result.
992 break;
993 }
994 } catch (err) {
995 summary.errors++;
996 console.warn(
997 `[ai-migration] watcher repo ${repo.owner}/${repo.name} failed:`,
998 err instanceof Error ? err.message : err
999 );
1000 }
1001 }
1002
1003 return summary;
1004}
1005
1006/**
1007 * Cheap "has anything been proposed for this repo in the throttle window?"
1008 * check used by the watcher's per-repo cap. Separate from
1009 * `recentlyProposed` because that one matches a specific {dep, version}.
1010 */
1011async function anyMigrationInWindow(
1012 repositoryId: string,
1013 windowHours: number = MIGRATION_THROTTLE_HOURS
1014): Promise<boolean> {
1015 try {
1016 const cutoff = new Date(Date.now() - windowHours * 60 * 60 * 1000);
1017 const [row] = await db
1018 .select({ c: sql<number>`count(*)::int` })
1019 .from(auditLog)
1020 .where(
1021 and(
1022 eq(auditLog.repositoryId, repositoryId),
1023 eq(auditLog.action, MIGRATION_AUDIT_ACTION),
1024 gte(auditLog.createdAt, cutoff)
1025 )
1026 );
1027 return (row?.c ?? 0) > 0;
1028 } catch {
1029 return false;
1030 }
1031}
1032
1033// ---------------------------------------------------------------------------
1034// Test-only re-exports
1035// ---------------------------------------------------------------------------
1036
1037export const __test = {
1038 resolveOwnerName,
1039 ensureMigrationLabel,
1040 seedBranchFromBase,
1041 askClaudeForMigration,
1042 anyMigrationInWindow,
1043 defaultIsEnabled,
1044 defaultFetchLatest,
1045 matchSemver,
1046};