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

dep-updater-sweep.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.

dep-updater-sweep.tsBlame395 lines · 1 contributor
f5ad215Claude1/**
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";
7c947f5Claude45import { getAnthropic, MODEL_SONNET, extractText, isAiAvailable } from "./ai-client";
f5ad215Claude46
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({
7c947f5Claude130 model: MODEL_SONNET,
f5ad215Claude131 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}