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.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.tsBlame590 lines · 1 contributor
3cbe3d6Claude1/**
2 * Block D2 — AI-native dependency updater (Dependabot equivalent).
3 *
4 * Reads a repo's `package.json`, queries the npm registry for updates,
5 * and (best-effort) opens a pull request with the bumped versions.
6 *
7 * Implementation notes:
8 * - Pure-function helpers (parseManifest / planUpdates / applyBumps) are
9 * fully covered by unit tests and make no network or disk I/O.
10 * - The git plumbing (creating a branch + commit + PR row) is wired in
11 * `runDepUpdateRun`. It uses `Bun.spawn` to drive `git hash-object`,
12 * `git mktree`, `git commit-tree`, `git update-ref` — the same pattern
13 * used by `src/routes/editor.tsx`. If any step fails, the run is
14 * recorded with `status:"failed"` and the function never throws.
15 * - If spawning git fails for any reason (missing binary, missing repo,
16 * etc.), the run still records the planned bumps in `attemptedBumps`
17 * so the UI can show what *would* have been done.
18 */
19
20import { eq, and, sql } from "drizzle-orm";
21import { db } from "../db";
22import {
23 depUpdateRuns,
24 pullRequests,
25 repositories,
26 users,
27} from "../db/schema";
28import {
29 getBlob,
30 getDefaultBranch,
31 getRepoPath,
32 resolveRef,
33} from "../git/repository";
34
35export type Bump = {
36 name: string;
37 from: string;
38 to: string;
39 kind: "dep" | "dev";
40 major: boolean;
41};
42
43export type ParsedManifest = {
44 dependencies: Record<string, string>;
45 devDependencies: Record<string, string>;
46 name?: string;
47};
48
49/**
50 * Tolerant JSON parser. Returns empty structures on any parse error.
51 */
52export function parseManifest(text: string): ParsedManifest {
53 const empty: ParsedManifest = { dependencies: {}, devDependencies: {} };
54 if (!text || typeof text !== "string") return empty;
55 try {
56 const raw = JSON.parse(text);
57 if (!raw || typeof raw !== "object") return empty;
58 const deps =
59 raw.dependencies && typeof raw.dependencies === "object"
60 ? (raw.dependencies as Record<string, string>)
61 : {};
62 const dev =
63 raw.devDependencies && typeof raw.devDependencies === "object"
64 ? (raw.devDependencies as Record<string, string>)
65 : {};
66 // Filter to string values only.
67 const dependencies: Record<string, string> = {};
68 for (const [k, v] of Object.entries(deps)) {
69 if (typeof v === "string") dependencies[k] = v;
70 }
71 const devDependencies: Record<string, string> = {};
72 for (const [k, v] of Object.entries(dev)) {
73 if (typeof v === "string") devDependencies[k] = v;
74 }
75 return {
76 dependencies,
77 devDependencies,
78 name: typeof raw.name === "string" ? raw.name : undefined,
79 };
80 } catch {
81 return empty;
82 }
83}
84
85/**
86 * Query the npm registry for the latest version of a package.
87 * Returns null on any network / parse error.
88 */
89export async function queryNpmLatest(
90 pkgName: string
91): Promise<string | null> {
92 try {
93 const safe = encodeURIComponent(pkgName).replace(/%40/g, "@");
94 const res = await fetch(`https://registry.npmjs.org/${safe}/latest`, {
95 headers: { accept: "application/json" },
96 });
97 if (!res.ok) return null;
98 const data = (await res.json()) as { version?: unknown };
99 if (typeof data.version !== "string") return null;
100 return data.version;
101 } catch {
102 return null;
103 }
104}
105
106/**
107 * Extract a pure semver x.y.z from a range string like `^1.2.3`, `~1.2.3`,
108 * `1.2.3`, `>=1.2.3 <2`, etc. Returns null for non-semver strings such as
109 * `workspace:*`, `github:foo/bar`, `file:./x`, `latest`, `*`, or `https://…`.
110 */
111function extractSemver(range: string): { major: number; minor: number; patch: number } | null {
112 if (typeof range !== "string") return null;
113 const trimmed = range.trim();
114 if (!trimmed) return null;
115 // Reject obvious non-registry sources.
116 if (/^(workspace:|github:|git\+|git:|file:|link:|http:|https:|npm:)/i.test(trimmed)) {
117 return null;
118 }
119 if (trimmed === "*" || /^latest$/i.test(trimmed)) return null;
120 const m = trimmed.match(/(\d+)\.(\d+)\.(\d+)/);
121 if (!m) return null;
122 return {
123 major: parseInt(m[1], 10),
124 minor: parseInt(m[2], 10),
125 patch: parseInt(m[3], 10),
126 };
127}
128
129function cmpSemver(
130 a: { major: number; minor: number; patch: number },
131 b: { major: number; minor: number; patch: number }
132): number {
133 if (a.major !== b.major) return a.major - b.major;
134 if (a.minor !== b.minor) return a.minor - b.minor;
135 return a.patch - b.patch;
136}
137
138/**
139 * Walk a manifest and produce a list of bumps. Skips packages with
140 * non-semver range strings, no-op bumps, and downgrades.
141 */
142export async function planUpdates(
143 manifest: {
144 dependencies: Record<string, string>;
145 devDependencies: Record<string, string>;
146 },
147 opts?: { fetchLatest?: (name: string) => Promise<string | null> }
148): Promise<Bump[]> {
149 const fetchLatest = opts?.fetchLatest ?? queryNpmLatest;
150 const bumps: Bump[] = [];
151
152 const walk = async (
153 bucket: Record<string, string>,
154 kind: "dep" | "dev"
155 ) => {
156 for (const [name, current] of Object.entries(bucket)) {
157 const currentParsed = extractSemver(current);
158 if (!currentParsed) continue;
159 const latest = await fetchLatest(name);
160 if (!latest) continue;
161 const latestParsed = extractSemver(latest);
162 if (!latestParsed) continue;
163 // Skip no-ops and downgrades.
164 if (cmpSemver(latestParsed, currentParsed) <= 0) continue;
165 bumps.push({
166 name,
167 from: current,
168 to: latest,
169 kind,
170 major: latestParsed.major > currentParsed.major,
171 });
172 }
173 };
174
175 await walk(manifest.dependencies || {}, "dep");
176 await walk(manifest.devDependencies || {}, "dev");
177
178 return bumps;
179}
180
181/**
182 * Rewrite `package.json` text in-place, preserving formatting as much as
183 * possible. For each bump, locates the matching `"name": "..."` line inside
184 * the correct stanza (`dependencies` vs `devDependencies`) and rewrites
185 * only the version string. Preserves the trailing newline.
186 */
187export function applyBumps(
188 manifestText: string,
189 bumps: Array<{ name: string; to: string; kind: "dep" | "dev" }>
190): string {
191 if (!manifestText) return manifestText;
192
193 const hadTrailingNewline = manifestText.endsWith("\n");
194 let text = manifestText;
195
196 // Preserve the user's original version prefix (^ / ~ / >= / exact).
197 const prefixOf = (val: string): string => {
198 const m = val.match(/^\s*([\^~><=]+)/);
199 return m ? m[1] : "";
200 };
201
202 const stanzaRange = (
203 body: string,
204 key: "dependencies" | "devDependencies"
205 ): { start: number; end: number } | null => {
206 // Find the "dependencies": { ... } block. Matches from the key through
207 // its matching closing brace, accounting for nested braces (shouldn't
208 // occur in package.json, but defensive).
209 const keyRe = new RegExp(`"${key}"\\s*:\\s*\\{`);
210 const keyMatch = keyRe.exec(body);
211 if (!keyMatch) return null;
212 const openIdx = body.indexOf("{", keyMatch.index);
213 if (openIdx === -1) return null;
214 let depth = 1;
215 let i = openIdx + 1;
216 for (; i < body.length && depth > 0; i++) {
217 const ch = body[i];
218 if (ch === "{") depth++;
219 else if (ch === "}") depth--;
220 }
221 if (depth !== 0) return null;
222 return { start: openIdx + 1, end: i - 1 }; // content between braces
223 };
224
225 for (const bump of bumps) {
226 const key = bump.kind === "dep" ? "dependencies" : "devDependencies";
227 const range = stanzaRange(text, key);
228 if (!range) continue;
229 const before = text.slice(0, range.start);
230 const inside = text.slice(range.start, range.end);
231 const after = text.slice(range.end);
232
233 // Match `"<name>": "<version>"` inside the stanza. Escape special chars
234 // in name (npm scopes contain `@` and `/`).
235 const escapedName = bump.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
236 const lineRe = new RegExp(
237 `("${escapedName}"\\s*:\\s*")([^"]+)(")`
238 );
239 const m = lineRe.exec(inside);
240 if (!m) continue;
241 const prefix = prefixOf(m[2]);
242 const replacement = `${m[1]}${prefix}${bump.to}${m[3]}`;
243 const newInside =
244 inside.slice(0, m.index) +
245 replacement +
246 inside.slice(m.index + m[0].length);
247 text = before + newInside + after;
248 }
249
250 if (hadTrailingNewline && !text.endsWith("\n")) text += "\n";
251 if (!hadTrailingNewline && text.endsWith("\n") && !manifestText.endsWith("\n")) {
252 // Shouldn't happen, but keep invariant strict.
253 text = text.replace(/\n+$/, "");
254 }
255 return text;
256}
257
258/**
259 * Format a markdown table describing the applied bumps — used as the PR body.
260 */
261function renderBumpTable(bumps: Bump[]): string {
262 const lines: string[] = [];
263 lines.push("Automated dependency update by GlueCron.");
264 lines.push("");
265 lines.push("| Package | From | To | Kind | Major? |");
266 lines.push("| --- | --- | --- | --- | --- |");
267 for (const b of bumps) {
268 lines.push(
269 `| \`${b.name}\` | ${b.from} | ${b.to} | ${b.kind} | ${b.major ? "yes" : "no"} |`
270 );
271 }
272 lines.push("");
273 lines.push(
274 "_This PR was opened by the GlueCron AI dependency updater. Review carefully before merging — major bumps may contain breaking changes._"
275 );
276 return lines.join("\n");
277}
278
279/**
280 * Spawn helper used for git plumbing. Returns trimmed stdout and exit code.
281 * Never throws — callers check the exit code.
282 */
283async function spawn(
284 cmd: string[],
285 cwd: string,
286 stdin?: string,
287 env?: Record<string, string>
288): Promise<{ stdout: string; stderr: string; exitCode: number }> {
289 try {
290 const proc = Bun.spawn(cmd, {
291 cwd,
292 stdout: "pipe",
293 stderr: "pipe",
294 stdin: stdin !== undefined ? "pipe" : undefined,
295 env: { ...process.env, ...(env || {}) },
296 });
297 if (stdin !== undefined && proc.stdin) {
298 (proc.stdin as WritableStreamDefaultWriter<Uint8Array> | any).write(
299 new TextEncoder().encode(stdin)
300 );
301 (proc.stdin as any).end();
302 }
303 const [stdout, stderr] = await Promise.all([
304 new Response(proc.stdout).text(),
305 new Response(proc.stderr).text(),
306 ]);
307 const exitCode = await proc.exited;
308 return { stdout: stdout.trim(), stderr, exitCode };
309 } catch (err: any) {
310 return { stdout: "", stderr: String(err?.message || err), exitCode: -1 };
311 }
312}
313
314/**
315 * Write updated file content onto a new branch by building a fresh tree
316 * from the current tree entries with one blob replaced, committing it, and
317 * pointing the new branch ref at the new commit. Returns `{ ok: true,
318 * commitSha }` or `{ ok: false, error }`.
319 */
320async function writeFileToBranch(
321 repoDir: string,
322 baseRef: string,
323 newBranch: string,
324 filePath: string,
325 content: string,
326 authorName: string,
327 authorEmail: string,
328 message: string
329): Promise<{ ok: true; commitSha: string } | { ok: false; error: string }> {
330 // 1. Hash the new blob.
331 const hashed = await spawn(
332 ["git", "hash-object", "-w", "--stdin"],
333 repoDir,
334 content
335 );
336 if (hashed.exitCode !== 0 || !hashed.stdout) {
337 return { ok: false, error: `hash-object failed: ${hashed.stderr}` };
338 }
339 const blobSha = hashed.stdout;
340
341 // 2. Read the existing tree at baseRef.
342 const lsTree = await spawn(["git", "ls-tree", "-r", baseRef], repoDir);
343 if (lsTree.exitCode !== 0) {
344 return { ok: false, error: `ls-tree failed: ${lsTree.stderr}` };
345 }
346 const entries = lsTree.stdout.split("\n").filter(Boolean);
347 let replaced = false;
348 const rewritten = entries
349 .map((line) => {
350 const match = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
351 if (!match) return line;
352 if (match[4] === filePath) {
353 replaced = true;
354 return `${match[1]} blob ${blobSha}\t${match[4]}`;
355 }
356 return line;
357 })
358 .join("\n");
359 const treeInput = replaced
360 ? rewritten + "\n"
361 : rewritten + (entries.length ? "\n" : "") + `100644 blob ${blobSha}\t${filePath}\n`;
362
363 // 3. Build the new tree.
364 const mktree = await spawn(["git", "mktree"], repoDir, treeInput);
365 if (mktree.exitCode !== 0 || !mktree.stdout) {
366 return { ok: false, error: `mktree failed: ${mktree.stderr}` };
367 }
368 const newTreeSha = mktree.stdout;
369
370 // 4. Look up the parent commit.
371 const parent = await spawn(["git", "rev-parse", baseRef], repoDir);
372 if (parent.exitCode !== 0 || !parent.stdout) {
373 return { ok: false, error: `rev-parse failed: ${parent.stderr}` };
374 }
375 const parentSha = parent.stdout;
376
377 // 5. Create the commit.
378 const commit = await spawn(
379 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
380 repoDir,
381 undefined,
382 {
383 GIT_AUTHOR_NAME: authorName,
384 GIT_AUTHOR_EMAIL: authorEmail,
385 GIT_COMMITTER_NAME: authorName,
386 GIT_COMMITTER_EMAIL: authorEmail,
387 }
388 );
389 if (commit.exitCode !== 0 || !commit.stdout) {
390 return { ok: false, error: `commit-tree failed: ${commit.stderr}` };
391 }
392 const commitSha = commit.stdout;
393
394 // 6. Point the new branch at the new commit.
395 const update = await spawn(
396 ["git", "update-ref", `refs/heads/${newBranch}`, commitSha],
397 repoDir
398 );
399 if (update.exitCode !== 0) {
400 return { ok: false, error: `update-ref failed: ${update.stderr}` };
401 }
402
403 return { ok: true, commitSha };
404}
405
406/**
407 * Main orchestrator — plan + apply + commit + PR. Never throws.
408 *
409 * Any failure is recorded on the run row with `status:"failed"`.
410 */
411export async function runDepUpdateRun(params: {
412 repositoryId: string;
413 owner: string;
414 repo: string;
415 userId: string | null;
416 manifestPath?: string;
417}): Promise<{ runId: string | null; status: string }> {
418 const {
419 repositoryId,
420 owner,
421 repo,
422 userId,
423 manifestPath = "package.json",
424 } = params;
425
426 // 1. Insert the run row in "running" state so the UI has something to show.
427 let runId: string | null = null;
428 try {
429 const [inserted] = await db
430 .insert(depUpdateRuns)
431 .values({
432 repositoryId,
433 status: "running",
434 ecosystem: "npm",
435 manifestPath,
436 triggeredBy: userId,
437 })
438 .returning();
439 runId = inserted?.id ?? null;
440 } catch (err) {
441 // If the DB isn't reachable, we've already lost — bail with failure.
442 return { runId: null, status: "failed" };
443 }
444
445 const finish = async (
446 patch: Partial<typeof depUpdateRuns.$inferInsert> & { status: string }
447 ) => {
448 if (!runId) return;
449 try {
450 await db
451 .update(depUpdateRuns)
452 .set({ ...patch, completedAt: new Date() })
453 .where(eq(depUpdateRuns.id, runId));
454 } catch {
455 // Swallow — we already did our best.
456 }
457 };
458
459 try {
460 // 2. Load the manifest from the default branch.
461 const branch = (await getDefaultBranch(owner, repo)) || "main";
462 const blob = await getBlob(owner, repo, branch, manifestPath);
463 if (!blob || blob.isBinary) {
464 await finish({
465 status: "failed",
466 errorMessage: `Could not read ${manifestPath} on ${branch}`,
467 });
468 return { runId, status: "failed" };
469 }
470
471 const manifest = parseManifest(blob.content);
472 const bumps = await planUpdates(manifest);
473
474 const attempted = JSON.stringify(bumps);
475
476 if (bumps.length === 0) {
477 await finish({
478 status: "no_updates",
479 attemptedBumps: attempted,
480 appliedBumps: "[]",
481 });
482 return { runId, status: "no_updates" };
483 }
484
485 // 3. Apply the bumps to the manifest text.
486 const rewritten = applyBumps(blob.content, bumps);
487
488 // 4. Create a new branch with the rewritten manifest.
489 const repoDir = getRepoPath(owner, repo);
490 const stamp = new Date().toISOString().replace(/[:.]/g, "-");
491 const branchName = `gluecron/dep-update-${stamp}`;
492
493 const authorName = "GlueCron Bot";
494 const authorEmail = "bot@gluecron.com";
495 const commitMessage = `chore(deps): bump ${bumps.length} package${bumps.length === 1 ? "" : "s"}`;
496
497 const writeResult = await writeFileToBranch(
498 repoDir,
499 branch,
500 branchName,
501 manifestPath,
502 rewritten,
503 authorName,
504 authorEmail,
505 commitMessage
506 );
507
508 if (!writeResult.ok) {
509 // Record the plan but note the failure — useful for the UI.
510 await finish({
511 status: "failed",
512 attemptedBumps: attempted,
513 appliedBumps: "[]",
514 errorMessage: writeResult.error,
515 });
516 return { runId, status: "failed" };
517 }
518
519 // 5. Insert the PR row. `number` is a serial column in the schema so
520 // the DB assigns it; we just read it back from the RETURNING row.
521 let prNumber: number | null = null;
522 try {
523 const authorId = userId ?? (await resolveBotAuthorId(owner));
524 if (!authorId) throw new Error("no author");
525 const prBody = renderBumpTable(bumps);
526 const prTitle = `chore(deps): bump ${bumps.length} package${bumps.length === 1 ? "" : "s"}`;
527 const [pr] = await db
528 .insert(pullRequests)
529 .values({
530 repositoryId,
531 authorId,
532 title: prTitle,
533 body: prBody,
534 baseBranch: branch,
535 headBranch: branchName,
536 isDraft: false,
537 })
538 .returning();
539 prNumber = pr?.number ?? null;
540 } catch (err: any) {
541 await finish({
542 status: "failed",
543 attemptedBumps: attempted,
544 appliedBumps: attempted,
545 branchName,
546 errorMessage: `PR insert failed: ${String(err?.message || err)}`,
547 });
548 return { runId, status: "failed" };
549 }
550
551 await finish({
552 status: "success",
553 attemptedBumps: attempted,
554 appliedBumps: attempted,
555 branchName,
556 prNumber: prNumber ?? undefined,
557 });
558 return { runId, status: "success" };
559 } catch (err: any) {
560 await finish({
561 status: "failed",
562 errorMessage: String(err?.message || err),
563 });
564 return { runId, status: "failed" };
565 }
566}
567
568/**
569 * Fallback author for bot-authored PRs — the repo owner. We don't have a
570 * dedicated bot user row, and the `authorId` column is NOT NULL.
571 */
572async function resolveBotAuthorId(ownerName: string): Promise<string | null> {
573 try {
574 const [row] = await db
575 .select({ id: users.id })
576 .from(users)
577 .where(eq(users.username, ownerName))
578 .limit(1);
579 return row?.id ?? null;
580 } catch {
581 return null;
582 }
583}
584
585export const __internal = {
586 extractSemver,
587 cmpSemver,
588 renderBumpTable,
589 writeFileToBranch,
590};