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.
| 3cbe3d6 | 1 | /** |
| 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 | ||
| 20 | import { eq, and, sql } from "drizzle-orm"; | |
| 21 | import { db } from "../db"; | |
| 22 | import { | |
| 23 | depUpdateRuns, | |
| 24 | pullRequests, | |
| 25 | repositories, | |
| 26 | users, | |
| 27 | } from "../db/schema"; | |
| 28 | import { | |
| 29 | getBlob, | |
| 30 | getDefaultBranch, | |
| 31 | getRepoPath, | |
| 32 | resolveRef, | |
| 33 | } from "../git/repository"; | |
| 34 | ||
| 35 | export type Bump = { | |
| 36 | name: string; | |
| 37 | from: string; | |
| 38 | to: string; | |
| 39 | kind: "dep" | "dev"; | |
| 40 | major: boolean; | |
| 41 | }; | |
| 42 | ||
| 43 | export 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 | */ | |
| 52 | export 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 | */ | |
| 89 | export 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 | */ | |
| 111 | function 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 | ||
| 129 | function 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 | */ | |
| 142 | export 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 | */ | |
| 187 | export 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 | */ | |
| 261 | function 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 | */ | |
| 283 | async 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 | */ | |
| 320 | async 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 | */ | |
| 411 | export 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 | */ | |
| 572 | async 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 | ||
| 585 | export const __internal = { | |
| 586 | extractSemver, | |
| 587 | cmpSemver, | |
| 588 | renderBumpTable, | |
| 589 | writeFileToBranch, | |
| 590 | }; |