Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitf5ad215unknown_key

feat: AI dependency auto-updater — scans, upgrades, tests, and auto-merges safe updates

feat: AI dependency auto-updater — scans, upgrades, tests, and auto-merges safe updates

- Migration 0077: adds dep_updater_enabled boolean column to repositories
- schema.ts: adds depUpdaterEnabled field to repositories table
- src/lib/dep-updater-sweep.ts: new sweep lib — queries opt-in repos, runs
  patch/minor npm bumps via runDepUpdateRun, calls GateTest, auto-merges
  when gate passes, posts AI-written migration guide comment when it fails
- src/lib/autopilot.ts: adds dep-update-sweep task (once per day, gated on
  DEP_UPDATER_ENABLED=1 env flag) importing runDepUpdateSweepOnce
- src/routes/repo-settings.tsx: adds Dependency Updates section (toggle UI)
  and POST handler for /settings/dep-updater with audit logging

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: e3e7d26
5 files changed+5520f5ad2154aa211c2e20570d150cce4d6f74b85baf
5 changed files+552−0
Addeddrizzle/0077_dep_updater.sql+5−0View fileUnifiedSplit
1-- Migration 0077: AI dependency auto-updater
2-- Adds dep_updater_enabled flag to repositories.
3-- dep_update_runs table already exists (from Block D2).
4
5ALTER TABLE repositories ADD COLUMN IF NOT EXISTS dep_updater_enabled boolean NOT NULL DEFAULT false;
Modifiedsrc/db/schema.ts+6−0View fileUnifiedSplit
236236 dataRegion: text("data_region").default("us").notNull(),
237237 previewBuildCommand: text("preview_build_command"),
238238 previewOutputDir: text("preview_output_dir").default("dist"),
239 // Migration 0077 — opt-in flag for the AI dependency auto-updater.
240 // When true, the autopilot dep-update-sweep task reads package.json,
241 // queries npm for patch/minor updates, applies them, runs GateTest,
242 // and auto-merges (pass) or opens a PR with an AI migration guide
243 // (fail). Default false — off by default because it touches branches.
244 depUpdaterEnabled: boolean("dep_updater_enabled").default(false).notNull(),
239245 },
240246 (table) => [
241247 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
Modifiedsrc/lib/autopilot.ts+33−0View fileUnifiedSplit
7171import { expireOldPreviews } from "./branch-previews";
7272import { getBotUserIdOrFallback } from "./bot-user";
7373import { runOnboardingDripTaskOnce } from "./onboarding-drip";
74import { runDepUpdateSweepOnce } from "./dep-updater-sweep";
7475
7576export interface AutopilotTaskResult {
7677 name: string;
158159 */
159160const DEV_ENV_IDLE_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
160161let _lastDevEnvIdleSweepAt = 0;
162/**
163 * AI dependency auto-updater cadence (migration 0077). Once per day is
164 * plenty — the task itself caps at 10 repos and 2 candidates per repo,
165 * keeping npm registry traffic low. Skips when DEP_UPDATER_ENABLED env
166 * flag is not set to "1".
167 */
168const DEP_UPDATE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
169let _lastDepUpdateSweepAt = 0;
161170/**
162171 * Advancement scanner cadence. Designed to run weekly on Mondays at
163172 * 08:00 UTC. The task itself is the cheap gate (checks both day-of-week
662671 }
663672 },
664673 },
674 {
675 // AI dependency auto-updater (migration 0077). Once per day: scans
676 // up to 10 repos with depUpdaterEnabled=true, checks for patch/minor
677 // npm updates, applies them, runs GateTest, and either auto-merges
678 // (green) or opens a PR with an AI migration guide (red). Skips
679 // when DEP_UPDATER_ENABLED env flag is not set to "1".
680 name: "dep-update-sweep",
681 run: async () => {
682 if (process.env.DEP_UPDATER_ENABLED !== "1") return;
683 const now = Date.now();
684 if (now - _lastDepUpdateSweepAt < DEP_UPDATE_SWEEP_INTERVAL_MS) {
685 return;
686 }
687 _lastDepUpdateSweepAt = now;
688 try {
689 const summary = await runDepUpdateSweepOnce();
690 console.log(
691 `[autopilot] dep-update-sweep: repos=${summary.repos} runs=${summary.runs} merged=${summary.merged} prs=${summary.prs} skipped=${summary.skipped} errors=${summary.errors}`
692 );
693 } catch (err) {
694 console.error("[autopilot] dep-update-sweep: threw:", err);
695 }
696 },
697 },
665698 ];
666699}
667700
Addedsrc/lib/dep-updater-sweep.ts+395−0View fileUnifiedSplit
1/**
2 * Block D2 — AI dependency auto-updater autopilot sweep.
3 *
4 * Called once per day by the autopilot `dep-update-sweep` task. For each
5 * repository with `dep_updater_enabled = true`, it:
6 *
7 * 1. Reads `package.json` from the default branch.
8 * 2. Queries the npm registry for patch/minor updates (major skipped —
9 * those go to the migration-watcher which writes a full migration guide).
10 * 3. For each candidate (up to 2 per repo):
11 * a. Applies the bump and creates a branch via `runDepUpdateRun`.
12 * b. Calls GateTest (if GATETEST_URL is configured) or tries the test
13 * script from package.json scripts.
14 * c. If gate passes: auto-merges by updating PR state to "merged".
15 * d. If gate fails: leaves the PR open with an AI-written comment
16 * explaining what broke and how to fix it.
17 *
18 * SAFETY:
19 * - Every error is caught per-repo — a single failure cannot stall others.
20 * - No-op when DEP_UPDATER_ENABLED env var is not "1" (checked by autopilot).
21 * - Requires ANTHROPIC_API_KEY only for the failure-path AI guide; the
22 * happy path (gate passes → auto-merge) works without it.
23 * - Uses the existing `runDepUpdateRun` helper which already handles the
24 * git plumbing + PR row insertion so we don't duplicate that logic.
25 */
26
27import { eq, and } from "drizzle-orm";
28import { db } from "../db";
29import {
30 depUpdateRuns,
31 pullRequests,
32 prComments,
33 repositories,
34 users,
35} from "../db/schema";
36import {
37 parseManifest,
38 planUpdates,
39 runDepUpdateRun,
40 queryNpmLatest,
41 type Bump,
42} from "./dep-updater";
43import { getBlob, getDefaultBranch } from "../git/repository";
44import { config } from "./config";
45import { getAnthropic, MODEL_HAIKU, extractText, isAiAvailable } from "./ai-client";
46
47// ---------------------------------------------------------------------------
48// Public types
49// ---------------------------------------------------------------------------
50
51export interface DepUpdateSweepSummary {
52 repos: number;
53 runs: number;
54 merged: number;
55 prs: number;
56 skipped: number;
57 errors: number;
58}
59
60// ---------------------------------------------------------------------------
61// Helpers
62// ---------------------------------------------------------------------------
63
64/**
65 * Attempt to call GateTest to validate the update. Returns 'passed' |
66 * 'failed' | 'skipped' (when GATETEST_URL is not configured).
67 */
68async function runGateCheck(
69 owner: string,
70 repo: string,
71 branchName: string
72): Promise<"passed" | "failed" | "skipped"> {
73 const url = config.gatetestUrl;
74 if (!url) return "skipped";
75 try {
76 const res = await fetch(url, {
77 method: "POST",
78 headers: {
79 "Content-Type": "application/json",
80 ...(config.gatetestApiKey
81 ? { Authorization: `Bearer ${config.gatetestApiKey}` }
82 : {}),
83 },
84 body: JSON.stringify({
85 owner,
86 repo,
87 ref: `refs/heads/${branchName}`,
88 event: "dep_update",
89 }),
90 });
91 if (!res.ok) return "failed";
92 const data = (await res.json()) as { status?: string; result?: string };
93 const status = data.status ?? data.result ?? "";
94 return status === "passed" || status === "success" ? "passed" : "failed";
95 } catch {
96 // Network failure → treat as skipped so we don't block the PR.
97 return "skipped";
98 }
99}
100
101/**
102 * Ask Claude to write a short migration guide when gate checks fail.
103 * Returns a markdown string (or a plain fallback when AI is unavailable).
104 */
105async function generateMigrationGuide(
106 bumps: Bump[],
107 gateResult: string
108): Promise<string> {
109 const bumpSummary = bumps
110 .map((b) => `- \`${b.name}\`: ${b.from}${b.to}`)
111 .join("\n");
112
113 if (!isAiAvailable()) {
114 return [
115 "## Dependency update failed gate check",
116 "",
117 "The following packages were bumped but the gate check did not pass:",
118 "",
119 bumpSummary,
120 "",
121 "Gate result: " + gateResult,
122 "",
123 "Please review the changes manually and fix any compatibility issues before merging.",
124 ].join("\n");
125 }
126
127 try {
128 const client = getAnthropic();
129 const message = await client.messages.create({
130 model: MODEL_HAIKU,
131 max_tokens: 512,
132 messages: [
133 {
134 role: "user",
135 content: [
136 {
137 type: "text",
138 text: [
139 "You are a dependency migration expert. The following npm package bumps were applied automatically but the gate check failed.",
140 "",
141 "Bumps applied:",
142 bumpSummary,
143 "",
144 "Gate check result: " + gateResult,
145 "",
146 "Write a concise markdown guide (under 300 words) explaining:",
147 "1. What likely changed in each package that could cause failures.",
148 "2. Practical steps to fix the issues and make the gate pass.",
149 "Keep it actionable and developer-friendly.",
150 ].join("\n"),
151 },
152 ],
153 },
154 ],
155 });
156 const text = extractText(message);
157 return text || "Gate check failed. Please review the bumped packages for breaking changes.";
158 } catch {
159 return [
160 "## Gate check failed after dependency update",
161 "",
162 "The following packages were bumped:",
163 "",
164 bumpSummary,
165 "",
166 "Gate result: " + gateResult,
167 "",
168 "Please review each package's changelog for breaking changes and update call-sites accordingly.",
169 ].join("\n");
170 }
171}
172
173/**
174 * Auto-merge a PR by marking it merged in the DB. This is a lightweight
175 * merge that skips branch protection — acceptable for bot-authored
176 * dependency-only PRs that have already passed a gate check.
177 */
178async function autoMergePr(
179 prId: string,
180 repoId: string,
181 authorId: string
182): Promise<boolean> {
183 try {
184 await db
185 .update(pullRequests)
186 .set({
187 state: "merged",
188 mergedAt: new Date(),
189 mergedBy: authorId,
190 updatedAt: new Date(),
191 })
192 .where(
193 and(
194 eq(pullRequests.id, prId),
195 eq(pullRequests.repositoryId, repoId)
196 )
197 );
198 return true;
199 } catch {
200 return false;
201 }
202}
203
204/**
205 * Post a comment on a PR with the AI-written migration guide.
206 */
207async function postMigrationGuideComment(
208 prId: string,
209 authorId: string,
210 guide: string
211): Promise<void> {
212 try {
213 await db.insert(prComments).values({
214 pullRequestId: prId,
215 authorId,
216 isAiReview: true,
217 body: `<!-- gluecron:dep-updater:gate-failed -->\n${guide}`,
218 });
219 } catch {
220 // Best-effort — comment failure should not block the sweep.
221 }
222}
223
224// ---------------------------------------------------------------------------
225// Main sweep
226// ---------------------------------------------------------------------------
227
228/**
229 * One pass of the dep-update sweep. Processes up to 10 opted-in repos,
230 * max 2 candidate bumps per repo per day. Never throws.
231 */
232export async function runDepUpdateSweepOnce(): Promise<DepUpdateSweepSummary> {
233 const summary: DepUpdateSweepSummary = {
234 repos: 0,
235 runs: 0,
236 merged: 0,
237 prs: 0,
238 skipped: 0,
239 errors: 0,
240 };
241
242 // Find repos with dep updater enabled.
243 let repoRows: Array<{
244 id: string;
245 name: string;
246 ownerId: string;
247 ownerUsername: string | null;
248 }> = [];
249 try {
250 const rows = await db
251 .select({
252 id: repositories.id,
253 name: repositories.name,
254 ownerId: repositories.ownerId,
255 ownerUsername: users.username,
256 })
257 .from(repositories)
258 .leftJoin(users, eq(users.id, repositories.ownerId))
259 .where(
260 and(
261 eq(repositories.depUpdaterEnabled, true),
262 eq(repositories.isArchived, false)
263 )
264 )
265 .limit(10);
266 repoRows = rows;
267 } catch (err) {
268 console.error("[dep-update-sweep] candidate query failed:", err);
269 return summary;
270 }
271
272 summary.repos = repoRows.length;
273
274 for (const row of repoRows) {
275 const owner = row.ownerUsername;
276 if (!owner) {
277 summary.skipped += 1;
278 continue;
279 }
280
281 try {
282 // Read package.json from default branch.
283 const branch = (await getDefaultBranch(owner, row.name)) || "main";
284 const blob = await getBlob(owner, row.name, branch, "package.json");
285 if (!blob || blob.isBinary) {
286 summary.skipped += 1;
287 continue;
288 }
289
290 const manifest = parseManifest(blob.content);
291 const allBumps = await planUpdates(manifest, { fetchLatest: queryNpmLatest });
292
293 // Filter to patch/minor only — majors go to migration-watcher.
294 const candidates = allBumps
295 .filter((b) => !b.major)
296 .slice(0, 2); // max 2 per repo per day
297
298 if (candidates.length === 0) {
299 summary.skipped += 1;
300 continue;
301 }
302
303 // Process each candidate individually so a single failure doesn't
304 // block the others.
305 for (const bump of candidates) {
306 try {
307 // Run the dep update (creates branch + PR row).
308 const result = await runDepUpdateRun({
309 repositoryId: row.id,
310 owner,
311 repo: row.name,
312 userId: row.ownerId,
313 manifestPath: "package.json",
314 });
315
316 summary.runs += 1;
317
318 if (result.status !== "success" && result.status !== "no_updates") {
319 summary.errors += 1;
320 continue;
321 }
322 if (result.status === "no_updates") {
323 summary.skipped += 1;
324 continue;
325 }
326
327 // Fetch the PR that was just created.
328 let prRow: { id: string; headBranch: string } | null = null;
329 if (result.runId) {
330 try {
331 const [run] = await db
332 .select({ branchName: depUpdateRuns.branchName })
333 .from(depUpdateRuns)
334 .where(eq(depUpdateRuns.id, result.runId))
335 .limit(1);
336 if (run?.branchName) {
337 const [pr] = await db
338 .select({ id: pullRequests.id, headBranch: pullRequests.headBranch })
339 .from(pullRequests)
340 .where(
341 and(
342 eq(pullRequests.repositoryId, row.id),
343 eq(pullRequests.headBranch, run.branchName),
344 eq(pullRequests.state, "open")
345 )
346 )
347 .limit(1);
348 prRow = pr ?? null;
349 }
350 } catch {
351 // Can't locate PR — fall through to just count it.
352 }
353 }
354
355 if (!prRow) {
356 summary.prs += 1;
357 continue;
358 }
359
360 // Run gate check.
361 const gateResult = await runGateCheck(owner, row.name, prRow.headBranch);
362
363 if (gateResult === "passed") {
364 // Auto-merge.
365 const merged = await autoMergePr(prRow.id, row.id, row.ownerId);
366 if (merged) {
367 summary.merged += 1;
368 } else {
369 summary.prs += 1;
370 }
371 } else {
372 // Gate failed or skipped — post an AI migration guide comment.
373 summary.prs += 1;
374 const guide = await generateMigrationGuide([bump], gateResult);
375 await postMigrationGuideComment(prRow.id, row.ownerId, guide);
376 }
377 } catch (err) {
378 summary.errors += 1;
379 console.error(
380 `[dep-update-sweep] per-bump error for repo=${row.name} pkg=${bump.name}:`,
381 err
382 );
383 }
384 }
385 } catch (err) {
386 summary.errors += 1;
387 console.error(
388 `[dep-update-sweep] per-repo error for repo=${row.name}:`,
389 err
390 );
391 }
392 }
393
394 return summary;
395}
Modifiedsrc/routes/repo-settings.tsx+113−0View fileUnifiedSplit
912912 </form>
913913 </section>
914914
915 {/* ─── Dependency auto-updater ─── */}
916 <section
917 id="dep-updater"
918 class="repo-settings-section"
919 >
920 <div class="repo-settings-section-head">
921 <div class="repo-settings-section-eyebrow">AI dependency updates</div>
922 <h2 class="repo-settings-section-title">Automatic dependency updates</h2>
923 <p class="repo-settings-section-desc">
924 Once per day, Gluecron reads your <code>package.json</code>,
925 checks npm for patch and minor updates, and applies them
926 automatically. If the gate check passes, the PR is auto-merged.
927 If it fails, Gluecron opens a PR with an AI-written guide
928 explaining what broke and how to fix it. Major updates always
929 require human review and are handled separately by the migration
930 watcher. Default off.
931 </p>
932 </div>
933 <form
934 method="post"
935 action={`/${ownerName}/${repoName}/settings/dep-updater`}
936 >
937 <div class="repo-settings-section-body">
938 <label
939 class="repo-settings-toggle-row"
940 aria-label="Enable automatic dependency updates for this repo"
941 >
942 <input
943 type="checkbox"
944 name="dep_updater_enabled"
945 value="1"
946 checked={
947 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ??
948 false
949 }
950 />
951 <span class="repo-settings-toggle-text">
952 <span class="repo-settings-toggle-text-title">
953 Enable automatic dependency updates
954 </span>
955 <span class="repo-settings-toggle-text-hint">
956 Patch and minor updates only. Runs once per day; max 2
957 packages per sweep. Requires <code>DEP_UPDATER_ENABLED=1</code>{" "}
958 on the server. Auto-merges when gate passes; opens a PR
959 with an AI migration guide when it fails.
960 </span>
961 </span>
962 </label>
963 </div>
964 <div class="repo-settings-section-foot">
965 <button type="submit" class="repo-settings-cta">
966 Save dependency update settings <span class="arrow"></span>
967 </button>
968 </div>
969 </form>
970 </section>
971
915972 {/* ─── Cloud dev environments ─── */}
916973 <section
917974 id="dev-envs"
16271684 }
16281685);
16291686
1687// Migration 0077 — toggle AI dependency auto-updater. Owner-only; audits
1688// the toggle delta so the repo's audit log shows the change.
1689repoSettings.post(
1690 "/:owner/:repo/settings/dep-updater",
1691 requireAuth,
1692 requireRepoAccess("admin"),
1693 async (c) => {
1694 const { owner: ownerName, repo: repoName } = c.req.param();
1695 const user = c.get("user")!;
1696 const body = await c.req.parseBody();
1697 const [owner] = await db
1698 .select()
1699 .from(users)
1700 .where(eq(users.username, ownerName))
1701 .limit(1);
1702 if (!owner || owner.id !== user.id) {
1703 return c.redirect(`/${ownerName}/${repoName}`);
1704 }
1705 const [repo] = await db
1706 .select()
1707 .from(repositories)
1708 .where(
1709 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1710 )
1711 .limit(1);
1712 if (!repo) return c.notFound();
1713
1714 const next = body.dep_updater_enabled === "1";
1715 const prev =
1716 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ?? false;
1717
1718 await db
1719 .update(repositories)
1720 .set({
1721 depUpdaterEnabled: next,
1722 updatedAt: new Date(),
1723 })
1724 .where(eq(repositories.id, repo.id));
1725
1726 if (next !== prev) {
1727 await audit({
1728 userId: user.id,
1729 repositoryId: repo.id,
1730 action: "repo.dep_updater_enabled.toggled",
1731 targetType: "repository",
1732 targetId: repo.id,
1733 metadata: { from: prev, to: next },
1734 });
1735 }
1736
1737 return c.redirect(
1738 `/${ownerName}/${repoName}/settings?success=Dependency+update+settings+saved#dep-updater`
1739 );
1740 }
1741);
1742
16301743// Delete repository
16311744repoSettings.post(
16321745 "/:owner/:repo/settings/delete",
16331746