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.
| f5ad215 | 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 | ||
| 27 | import { eq, and } from "drizzle-orm"; | |
| 28 | import { db } from "../db"; | |
| 29 | import { | |
| 30 | depUpdateRuns, | |
| 31 | pullRequests, | |
| 32 | prComments, | |
| 33 | repositories, | |
| 34 | users, | |
| 35 | } from "../db/schema"; | |
| 36 | import { | |
| 37 | parseManifest, | |
| 38 | planUpdates, | |
| 39 | runDepUpdateRun, | |
| 40 | queryNpmLatest, | |
| 41 | type Bump, | |
| 42 | } from "./dep-updater"; | |
| 43 | import { getBlob, getDefaultBranch } from "../git/repository"; | |
| 44 | import { config } from "./config"; | |
| 7c947f5 | 45 | import { getAnthropic, MODEL_SONNET, extractText, isAiAvailable } from "./ai-client"; |
| f5ad215 | 46 | |
| 47 | // --------------------------------------------------------------------------- | |
| 48 | // Public types | |
| 49 | // --------------------------------------------------------------------------- | |
| 50 | ||
| 51 | export 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 | */ | |
| 68 | async 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 | */ | |
| 105 | async 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({ | |
| 7c947f5 | 130 | model: MODEL_SONNET, |
| f5ad215 | 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 | */ | |
| 178 | async 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 | */ | |
| 207 | async 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 | */ | |
| 232 | export 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 | } |