CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-doc-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.
| d199847 | 1 | /** |
| 2 | * AI-tracked documentation sections — when a piece of prose in a markdown | |
| 3 | * file claims to describe a source file, this module keeps the two in | |
| 4 | * sync. The flow per push: | |
| 5 | * | |
| 6 | * 1. Scan tracked markdown files for `<!-- gluecron:doc-track src=... -->` | |
| 7 | * regions (`findTrackedDocs`). | |
| 8 | * 2. For each region, hash the referenced source. If the hash differs | |
| 9 | * from the `claimed_hash` stored in `doc_tracking`, the section is | |
| 10 | * stale. | |
| 11 | * 3. `proposeDocUpdate` asks Claude to rewrite the prose to match the | |
| 12 | * current source, then opens a PR labelled `ai:doc-update` on a | |
| 13 | * fresh branch (`ai-doc-update/<basename>-<timestamp>`). | |
| 14 | * | |
| 15 | * Reuses the same git plumbing as ai-patch-generator (createOrUpdateFileOnBranch | |
| 16 | * + updateRef) — the patch shape Claude returns is identical, so we keep the | |
| 17 | * "full-file replacement" contract here too. | |
| 18 | * | |
| 19 | * SAFETY: | |
| 20 | * - Caller MUST ensure ANTHROPIC_API_KEY is set OR pass a `client`. | |
| 21 | * proposeDocUpdate short-circuits to `null` when neither is available | |
| 22 | * so it's safe to fire from a post-receive handler. | |
| 23 | * - Every step is wrapped in try/catch — neither exported function ever | |
| 24 | * throws. | |
| 25 | * - On hash match (no drift) we update `last_checked_at` and bail | |
| 26 | * without touching git or Claude. | |
| 27 | * - The `last_pr_id` column gates repeat-proposals: if a PR is already | |
| 28 | * open for the same drift, we skip until it's merged or closed. | |
| 29 | */ | |
| 30 | ||
| 31 | import { createHash } from "crypto"; | |
| 32 | import { and, eq, inArray } from "drizzle-orm"; | |
| 33 | import type Anthropic from "@anthropic-ai/sdk"; | |
| 34 | import { db } from "../db"; | |
| 35 | import { | |
| 36 | docTracking, | |
| 37 | labels, | |
| 38 | prComments, | |
| 39 | pullRequests, | |
| 40 | repositories, | |
| 41 | users, | |
| 42 | } from "../db/schema"; | |
| 43 | import { | |
| 44 | createOrUpdateFileOnBranch, | |
| 45 | getBlob, | |
| 46 | getDefaultBranch, | |
| 47 | getTreeRecursive, | |
| 48 | refExists, | |
| 49 | resolveRef, | |
| 50 | updateRef, | |
| 51 | } from "../git/repository"; | |
| 52 | import { config } from "./config"; | |
| 53 | import { audit } from "./notify"; | |
| 54 | import { | |
| 55 | getAnthropic, | |
| 56 | MODEL_SONNET, | |
| 57 | extractText, | |
| 58 | parseJsonResponse, | |
| 59 | } from "./ai-client"; | |
| 60 | ||
| 61 | /** Marker we embed in the auto-opened PR body for downstream tooling. */ | |
| 62 | export const AI_DOC_UPDATE_MARKER = "<!-- gluecron-ai-doc-update:proposed -->"; | |
| 63 | ||
| 64 | /** Label name we surface (and create on the repo) for these PRs. */ | |
| 65 | export const AI_DOC_UPDATE_LABEL = "ai:doc-update"; | |
| 66 | ||
| 67 | /** Opening / closing markers used to bound a tracked region in markdown. */ | |
| 68 | export const DOC_TRACK_OPEN_RE = | |
| 69 | /<!--\s*gluecron:doc-track\s+src=([^\s>]+?)\s*-->/g; | |
| 70 | export const DOC_TRACK_CLOSE = "<!-- /gluecron:doc-track -->"; | |
| 71 | ||
| 72 | /** Max bytes of any single doc / source file we'll hand to Claude. */ | |
| 73 | const MAX_BYTES_FOR_CLAUDE = 64 * 1024; | |
| 74 | ||
| 75 | /** | |
| 76 | * One tracked region inside a markdown file. `marker` is a stable | |
| 77 | * identifier we use as the unique key on `doc_tracking`; we derive it from | |
| 78 | * the source path so multiple regions in the same doc tracking the same | |
| 79 | * source don't collide (we suffix the body hash for disambiguation). | |
| 80 | */ | |
| 81 | export interface TrackedSection { | |
| 82 | marker: string; | |
| 83 | /** Snippet of prose currently inside the region (between the markers). */ | |
| 84 | claim: string; | |
| 85 | /** Source path the region claims to describe. */ | |
| 86 | claimedFor: string; | |
| 87 | /** SHA-256 of the source file's current bytes, hex. */ | |
| 88 | currentSrcHash: string; | |
| 89 | /** What `doc_tracking.claimed_hash` says — null if never seen before. */ | |
| 90 | storedClaimedHash: string | null; | |
| 91 | /** True when storedClaimedHash differs from currentSrcHash. */ | |
| 92 | stale: boolean; | |
| 93 | } | |
| 94 | ||
| 95 | export interface TrackedDoc { | |
| 96 | /** Path of the markdown file inside the repo. */ | |
| 97 | path: string; | |
| 98 | /** Raw contents of the markdown file we parsed. */ | |
| 99 | raw: string; | |
| 100 | sections: TrackedSection[]; | |
| 101 | } | |
| 102 | ||
| 103 | // --------------------------------------------------------------------------- | |
| 104 | // Helpers | |
| 105 | // --------------------------------------------------------------------------- | |
| 106 | ||
| 107 | /** SHA-256 hex of a string. Used for both source-file and marker derivation. */ | |
| 108 | export function sha256Hex(s: string): string { | |
| 109 | return createHash("sha256").update(s).digest("hex"); | |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * Derive the stable marker for a region. We hash `srcPath|claim` so two | |
| 114 | * regions tracking the same source in the same doc can coexist as long | |
| 115 | * as their prose differs. Truncated for index-readability. | |
| 116 | */ | |
| 117 | export function deriveSectionMarker(srcPath: string, claim: string): string { | |
| 118 | return sha256Hex(`${srcPath}|${claim}`).slice(0, 16); | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * Pure parser: extract every `<!-- gluecron:doc-track src=PATH -->...<!-- /gluecron:doc-track -->` | |
| 123 | * region from a markdown blob. | |
| 124 | * | |
| 125 | * Returns sections with `currentSrcHash`/`storedClaimedHash`/`stale` left | |
| 126 | * blank — the caller fills those in after consulting git + the DB. | |
| 127 | */ | |
| 128 | export function parseTrackedSections( | |
| 129 | raw: string | |
| 130 | ): Array<{ marker: string; claim: string; claimedFor: string }> { | |
| 131 | if (!raw || typeof raw !== "string") return []; | |
| 132 | const sections: Array<{ | |
| 133 | marker: string; | |
| 134 | claim: string; | |
| 135 | claimedFor: string; | |
| 136 | }> = []; | |
| 137 | ||
| 138 | // Reset the regex's lastIndex since it's `g`-flagged and shared. | |
| 139 | DOC_TRACK_OPEN_RE.lastIndex = 0; | |
| 140 | let m: RegExpExecArray | null; | |
| 141 | while ((m = DOC_TRACK_OPEN_RE.exec(raw)) !== null) { | |
| 142 | const srcPath = m[1].trim(); | |
| 143 | if (!srcPath) continue; | |
| 144 | const openEnd = m.index + m[0].length; | |
| 145 | const closeIdx = raw.indexOf(DOC_TRACK_CLOSE, openEnd); | |
| 146 | if (closeIdx === -1) continue; // unclosed region — skip | |
| 147 | const inner = raw.slice(openEnd, closeIdx).trim(); | |
| 148 | if (!inner) continue; | |
| 149 | sections.push({ | |
| 150 | marker: deriveSectionMarker(srcPath, inner), | |
| 151 | claim: inner, | |
| 152 | claimedFor: srcPath, | |
| 153 | }); | |
| 154 | // Advance past the close marker so we don't re-match nested cases. | |
| 155 | DOC_TRACK_OPEN_RE.lastIndex = closeIdx + DOC_TRACK_CLOSE.length; | |
| 156 | } | |
| 157 | ||
| 158 | return sections; | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Resolve `{ owner, name, defaultBranch }` for a repo row. Returns null | |
| 163 | * if missing — caller bails gracefully. | |
| 164 | */ | |
| 165 | async function resolveRepoMeta( | |
| 166 | repositoryId: string | |
| 167 | ): Promise<{ | |
| 168 | owner: string; | |
| 169 | name: string; | |
| 170 | ownerId: string; | |
| 171 | defaultBranch: string; | |
| 172 | } | null> { | |
| 173 | try { | |
| 174 | const [row] = await db | |
| 175 | .select({ | |
| 176 | ownerId: repositories.ownerId, | |
| 177 | ownerUsername: users.username, | |
| 178 | name: repositories.name, | |
| 179 | defaultBranch: repositories.defaultBranch, | |
| 180 | }) | |
| 181 | .from(repositories) | |
| 182 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 183 | .where(eq(repositories.id, repositoryId)) | |
| 184 | .limit(1); | |
| 185 | if (!row) return null; | |
| 186 | return { | |
| 187 | owner: row.ownerUsername, | |
| 188 | name: row.name, | |
| 189 | ownerId: row.ownerId, | |
| 190 | defaultBranch: row.defaultBranch || "main", | |
| 191 | }; | |
| 192 | } catch (err) { | |
| 193 | console.error( | |
| 194 | "[ai-doc-updater] resolveRepoMeta failed:", | |
| 195 | err instanceof Error ? err.message : err | |
| 196 | ); | |
| 197 | return null; | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | /** | |
| 202 | * Walk the repo tree for markdown files. We cap the candidate set to | |
| 203 | * keep the per-push cost predictable — only files at most 3 path | |
| 204 | * segments deep + ending in `.md` qualify, biased toward the obvious | |
| 205 | * README locations. | |
| 206 | */ | |
| 207 | async function listMarkdownFiles( | |
| 208 | owner: string, | |
| 209 | name: string, | |
| 210 | ref: string | |
| 211 | ): Promise<string[]> { | |
| 212 | try { | |
| 213 | const tree = await getTreeRecursive(owner, name, ref, 50_000); | |
| 214 | if (!tree) return []; | |
| 215 | return tree.tree | |
| 216 | .filter( | |
| 217 | (e) => | |
| 218 | e.type === "blob" && | |
| 219 | /\.md$/i.test(e.path) && | |
| 220 | e.path.split("/").length <= 3 | |
| 221 | ) | |
| 222 | .map((e) => e.path); | |
| 223 | } catch { | |
| 224 | return []; | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * Best-effort: ensure the `ai:doc-update` label row exists on the repo so | |
| 230 | * downstream tools can attach it. Mirrors ai-patch-generator.ensurePatchLabel. | |
| 231 | */ | |
| 232 | async function ensureDocUpdateLabel(repositoryId: string): Promise<void> { | |
| 233 | try { | |
| 234 | await db | |
| 235 | .insert(labels) | |
| 236 | .values({ | |
| 237 | repositoryId, | |
| 238 | name: AI_DOC_UPDATE_LABEL, | |
| 6fd5915 | 239 | color: "#5f8fa0", |
| d199847 | 240 | description: |
| 241 | "Documentation update proposed automatically by GlueCron AI after source drift", | |
| 242 | }) | |
| 243 | .onConflictDoNothing?.(); | |
| 244 | } catch (err) { | |
| 245 | console.warn( | |
| 246 | "[ai-doc-updater] ensureDocUpdateLabel failed:", | |
| 247 | err instanceof Error ? err.message : err | |
| 248 | ); | |
| 249 | } | |
| 250 | } | |
| 251 | ||
| 252 | // --------------------------------------------------------------------------- | |
| 253 | // findTrackedDocs — discover + drift-detect | |
| 254 | // --------------------------------------------------------------------------- | |
| 255 | ||
| 256 | export interface FindTrackedDocsOptions { | |
| 257 | /** Override the default branch ref to scan. Useful in tests. */ | |
| 258 | ref?: string; | |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * Walk the repository at `ref` (defaults to repo default branch), find | |
| 263 | * every markdown file with at least one tracked region, hash the | |
| 264 | * referenced source, and join against `doc_tracking` to decide whether | |
| 265 | * each region is stale. | |
| 266 | * | |
| 267 | * NEVER throws. Returns [] on any failure (missing repo, git error, | |
| 268 | * missing table, etc.). | |
| 269 | */ | |
| 270 | export async function findTrackedDocs( | |
| 271 | repositoryId: string, | |
| 272 | opts: FindTrackedDocsOptions = {} | |
| 273 | ): Promise<TrackedDoc[]> { | |
| 274 | const meta = await resolveRepoMeta(repositoryId); | |
| 275 | if (!meta) return []; | |
| 276 | ||
| 277 | let ref: string | undefined = opts.ref; | |
| 278 | if (!ref) { | |
| 279 | try { | |
| 280 | const resolved = await getDefaultBranch(meta.owner, meta.name); | |
| 281 | ref = resolved || meta.defaultBranch; | |
| 282 | } catch { | |
| 283 | ref = meta.defaultBranch; | |
| 284 | } | |
| 285 | } | |
| 286 | if (!ref) return []; | |
| 287 | ||
| 288 | const mdPaths = await listMarkdownFiles(meta.owner, meta.name, ref); | |
| 289 | if (!mdPaths.length) return []; | |
| 290 | ||
| 291 | // Pre-load every stored row for this repo so we can join in-memory. | |
| 292 | let storedRows: Array<{ | |
| 293 | docPath: string; | |
| 294 | sectionMarker: string; | |
| 295 | claimedHash: string; | |
| 296 | }> = []; | |
| 297 | try { | |
| 298 | storedRows = await db | |
| 299 | .select({ | |
| 300 | docPath: docTracking.docPath, | |
| 301 | sectionMarker: docTracking.sectionMarker, | |
| 302 | claimedHash: docTracking.claimedHash, | |
| 303 | }) | |
| 304 | .from(docTracking) | |
| 305 | .where(eq(docTracking.repositoryId, repositoryId)); | |
| 306 | } catch { | |
| 307 | storedRows = []; | |
| 308 | } | |
| 309 | const storedByKey = new Map<string, string>(); | |
| 310 | for (const row of storedRows) { | |
| 311 | storedByKey.set(`${row.docPath}::${row.sectionMarker}`, row.claimedHash); | |
| 312 | } | |
| 313 | ||
| 314 | const out: TrackedDoc[] = []; | |
| 315 | ||
| 316 | for (const docPath of mdPaths) { | |
| 317 | let raw = ""; | |
| 318 | try { | |
| 319 | const blob = await getBlob(meta.owner, meta.name, ref, docPath); | |
| 320 | if (!blob || blob.isBinary || !blob.content) continue; | |
| 321 | raw = blob.content; | |
| 322 | } catch { | |
| 323 | continue; | |
| 324 | } | |
| 325 | const parsed = parseTrackedSections(raw); | |
| 326 | if (parsed.length === 0) continue; | |
| 327 | ||
| 328 | const sections: TrackedSection[] = []; | |
| 329 | // Group source-file reads so we don't re-fetch the same blob N times. | |
| 330 | const srcCache = new Map<string, string | null>(); | |
| 331 | for (const p of parsed) { | |
| 332 | let srcContent: string | null = srcCache.has(p.claimedFor) | |
| 333 | ? srcCache.get(p.claimedFor)! | |
| 334 | : null; | |
| 335 | if (!srcCache.has(p.claimedFor)) { | |
| 336 | try { | |
| 337 | const blob = await getBlob( | |
| 338 | meta.owner, | |
| 339 | meta.name, | |
| 340 | ref, | |
| 341 | p.claimedFor | |
| 342 | ); | |
| 343 | srcContent = blob && !blob.isBinary ? blob.content : null; | |
| 344 | } catch { | |
| 345 | srcContent = null; | |
| 346 | } | |
| 347 | srcCache.set(p.claimedFor, srcContent); | |
| 348 | } | |
| 349 | if (srcContent == null) { | |
| 350 | // Source file missing — record a synthetic "no-source" hash so we | |
| 351 | // surface the broken pointer in the UI but don't churn PRs. | |
| 352 | const synthetic = "missing:" + p.claimedFor; | |
| 353 | const stored = storedByKey.get(`${docPath}::${p.marker}`) ?? null; | |
| 354 | sections.push({ | |
| 355 | marker: p.marker, | |
| 356 | claim: p.claim, | |
| 357 | claimedFor: p.claimedFor, | |
| 358 | currentSrcHash: synthetic, | |
| 359 | storedClaimedHash: stored, | |
| 360 | stale: stored !== null && stored !== synthetic, | |
| 361 | }); | |
| 362 | continue; | |
| 363 | } | |
| 364 | const hash = sha256Hex(srcContent); | |
| 365 | const stored = storedByKey.get(`${docPath}::${p.marker}`) ?? null; | |
| 366 | sections.push({ | |
| 367 | marker: p.marker, | |
| 368 | claim: p.claim, | |
| 369 | claimedFor: p.claimedFor, | |
| 370 | currentSrcHash: hash, | |
| 371 | storedClaimedHash: stored, | |
| 372 | // If we've never seen this region before (stored == null) we treat | |
| 373 | // it as NOT stale and seed the row on the next push. That avoids | |
| 374 | // an avalanche of "drift" PRs the first time a repo opts in. | |
| 375 | stale: stored !== null && stored !== hash, | |
| 376 | }); | |
| 377 | } | |
| 378 | ||
| 379 | out.push({ path: docPath, raw, sections }); | |
| 380 | } | |
| 381 | ||
| 382 | return out; | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * UPSERT every section we just observed back into `doc_tracking` so the | |
| 387 | * next push has an up-to-date baseline. Called after a successful | |
| 388 | * proposeDocUpdate so we don't propose the same drift twice. Best-effort. | |
| 389 | */ | |
| 390 | export async function persistObservedSections( | |
| 391 | repositoryId: string, | |
| 392 | doc: TrackedDoc | |
| 393 | ): Promise<void> { | |
| 394 | for (const s of doc.sections) { | |
| 395 | try { | |
| 396 | await db | |
| 397 | .insert(docTracking) | |
| 398 | .values({ | |
| 399 | repositoryId, | |
| 400 | docPath: doc.path, | |
| 401 | sectionMarker: s.marker, | |
| 402 | srcPath: s.claimedFor, | |
| 403 | claimedHash: s.currentSrcHash, | |
| 404 | }) | |
| 405 | .onConflictDoUpdate({ | |
| 406 | target: [ | |
| 407 | docTracking.repositoryId, | |
| 408 | docTracking.docPath, | |
| 409 | docTracking.sectionMarker, | |
| 410 | ], | |
| 411 | set: { | |
| 412 | claimedHash: s.currentSrcHash, | |
| 413 | srcPath: s.claimedFor, | |
| 414 | lastCheckedAt: new Date(), | |
| 415 | }, | |
| 416 | }); | |
| 417 | } catch (err) { | |
| 418 | console.warn( | |
| 419 | `[ai-doc-updater] persist failed for ${doc.path}::${s.marker}:`, | |
| 420 | err instanceof Error ? err.message : err | |
| 421 | ); | |
| 422 | } | |
| 423 | } | |
| 424 | } | |
| 425 | ||
| 426 | // --------------------------------------------------------------------------- | |
| 427 | // proposeDocUpdate — ask Claude to rewrite, open a PR | |
| 428 | // --------------------------------------------------------------------------- | |
| 429 | ||
| 430 | export interface ProposeDocUpdateOptions { | |
| 431 | repositoryId: string; | |
| 432 | /** Path of the markdown file. */ | |
| 433 | path: string; | |
| 434 | /** Stale sections from `findTrackedDocs`. */ | |
| 435 | sections: TrackedSection[]; | |
| 436 | /** | |
| 437 | * Optional Anthropic client override — primarily for tests. When | |
| 438 | * omitted, production code lazily constructs one via `ai-client`. | |
| 439 | */ | |
| 440 | client?: Pick<Anthropic, "messages">; | |
| 441 | /** | |
| 442 | * Override branch name (tests). Production code derives the name | |
| 443 | * from the doc basename + a timestamp. | |
| 444 | */ | |
| 445 | branchOverride?: string; | |
| 446 | } | |
| 447 | ||
| 448 | export interface ProposeDocUpdateResult { | |
| 449 | branch: string; | |
| 450 | prNumber: number; | |
| 451 | updatedSections: number; | |
| 452 | } | |
| 453 | ||
| 454 | interface ClaudeDocPatch { | |
| 455 | path: string; | |
| 456 | new_content: string; | |
| 457 | } | |
| 458 | ||
| 459 | interface ClaudeDocPatchResponse { | |
| 460 | explanation?: string; | |
| 461 | patches?: ClaudeDocPatch[]; | |
| 462 | } | |
| 463 | ||
| 464 | /** | |
| 465 | * Build the prompt that asks Claude to rewrite stale doc regions. | |
| 466 | * Exported (pure) so tests can assert against the shape. | |
| 467 | */ | |
| 468 | export function buildDocUpdatePrompt(args: { | |
| 469 | docPath: string; | |
| 470 | docRaw: string; | |
| 471 | staleSections: Array<{ | |
| 472 | marker: string; | |
| 473 | claim: string; | |
| 474 | claimedFor: string; | |
| 475 | sourceContent: string; | |
| 476 | }>; | |
| 477 | }): string { | |
| 478 | const { docPath, docRaw, staleSections } = args; | |
| 479 | const sectionBlobs = staleSections | |
| 480 | .map( | |
| 481 | (s, i) => | |
| 482 | [ | |
| 483 | `### Section ${i + 1} (marker=${s.marker})`, | |
| 484 | `Tracks: \`${s.claimedFor}\``, | |
| 485 | "", | |
| 486 | "Current prose in the doc:", | |
| 487 | "```markdown", | |
| 488 | s.claim, | |
| 489 | "```", | |
| 490 | "", | |
| 491 | "Current source contents:", | |
| 492 | "```", | |
| 493 | s.sourceContent.slice(0, MAX_BYTES_FOR_CLAUDE), | |
| 494 | "```", | |
| 495 | ].join("\n") | |
| 496 | ) | |
| 497 | .join("\n\n---\n\n"); | |
| 498 | ||
| 499 | return [ | |
| 500 | "An AI-tracked documentation region in a markdown file has drifted", | |
| 501 | "from the source it claims to describe. Rewrite ONLY the prose inside", | |
| 502 | "the tracked region so it once again accurately describes the source.", | |
| 503 | "", | |
| 504 | `**Doc file:** \`${docPath}\``, | |
| 505 | "", | |
| 506 | "Full current doc (for context — do NOT rewrite anything outside the", | |
| 507 | "tracked region(s) and KEEP the `<!-- gluecron:doc-track src=... -->`", | |
| 508 | "and `<!-- /gluecron:doc-track -->` markers verbatim):", | |
| 509 | "```markdown", | |
| 510 | docRaw.slice(0, MAX_BYTES_FOR_CLAUDE), | |
| 511 | "```", | |
| 512 | "", | |
| 513 | "## Stale sections", | |
| 514 | "", | |
| 515 | sectionBlobs, | |
| 516 | "", | |
| 517 | "Respond ONLY with JSON of this exact shape:", | |
| 518 | "{", | |
| 519 | ' "explanation": "1-3 sentence summary of what drifted and how you updated the prose",', | |
| 520 | ' "patches": [', | |
| 521 | ' { "path": "same/doc/path", "new_content": "FULL replacement contents of the doc file" }', | |
| 522 | " ]", | |
| 523 | "}", | |
| 524 | "", | |
| 525 | "Rules:", | |
| 526 | "- Return [] (empty patches) if the doc is already accurate or you cannot update it safely.", | |
| 527 | "- new_content MUST be the entire markdown file, not a diff.", | |
| 528 | "- Preserve every `<!-- gluecron:doc-track ... -->` and `<!-- /gluecron:doc-track -->` marker exactly.", | |
| 529 | "- Do not touch prose outside the marked regions.", | |
| 530 | "- Keep tone, voice, and formatting consistent with the rest of the document.", | |
| 531 | ].join("\n"); | |
| 532 | } | |
| 533 | ||
| 534 | /** Branch name helper (exported for tests). */ | |
| 535 | export function docUpdateBranchName( | |
| 536 | docPath: string, | |
| 537 | override?: string | |
| 538 | ): string { | |
| 539 | if (override && override.trim()) return override.trim(); | |
| 540 | const base = docPath | |
| 541 | .split("/") | |
| 542 | .pop()! | |
| 543 | .replace(/\.md$/i, "") | |
| 544 | .replace(/[^a-z0-9_-]+/gi, "-") | |
| 545 | .replace(/^-+|-+$/g, "") | |
| 546 | .toLowerCase() || "doc"; | |
| 547 | return `ai-doc-update/${base}-${Date.now()}`; | |
| 548 | } | |
| 549 | ||
| 550 | /** | |
| 551 | * Render the PR body. Pure helper exported for tests. | |
| 552 | */ | |
| 553 | export function renderDocUpdatePrBody(args: { | |
| 554 | docPath: string; | |
| 555 | explanation: string; | |
| 556 | updatedSections: TrackedSection[]; | |
| 557 | }): string { | |
| 558 | const { docPath, explanation, updatedSections } = args; | |
| 559 | const sectionLines = updatedSections | |
| 560 | .map( | |
| 561 | (s) => | |
| 562 | `- \`${s.claimedFor}\` (marker \`${s.marker}\`) — source hash drifted from \`${(s.storedClaimedHash ?? "(unseen)").slice(0, 12)}\` to \`${s.currentSrcHash.slice(0, 12)}\`` | |
| 563 | ) | |
| 564 | .join("\n"); | |
| 565 | return [ | |
| 566 | AI_DOC_UPDATE_MARKER, | |
| 567 | "## Documentation drift detected", | |
| 568 | "", | |
| 569 | `> Source files referenced by \`${docPath}\` have changed since the prose was last verified.`, | |
| 570 | "", | |
| 571 | "### What changed", | |
| 572 | explanation || "_(no explanation provided)_", | |
| 573 | "", | |
| 574 | "### Stale sections", | |
| 575 | sectionLines || "_(none)_", | |
| 576 | "", | |
| 577 | "---", | |
| 578 | "", | |
| 579 | `Labels: \`${AI_DOC_UPDATE_LABEL}\``, | |
| 580 | "", | |
| 581 | "_Auto-generated by GlueCron AI. Review the wording before merging — Claude may have over-edited._", | |
| 582 | ].join("\n"); | |
| 583 | } | |
| 584 | ||
| 585 | async function askClaudeForDocPatch( | |
| 586 | client: Pick<Anthropic, "messages">, | |
| 587 | prompt: string | |
| 588 | ): Promise<ClaudeDocPatchResponse | null> { | |
| 589 | try { | |
| 590 | const message = await client.messages.create({ | |
| 591 | model: MODEL_SONNET, | |
| 592 | max_tokens: 6144, | |
| 593 | messages: [{ role: "user", content: prompt }], | |
| 594 | }); | |
| 595 | // Best-effort cost tracking. Mirrors ai-patch-generator. | |
| 596 | try { | |
| 597 | const { recordAiCost, extractUsage } = await import("./ai-cost-tracker"); | |
| 598 | const usage = extractUsage(message); | |
| 599 | await recordAiCost({ | |
| 600 | model: MODEL_SONNET, | |
| 601 | inputTokens: usage.input, | |
| 602 | outputTokens: usage.output, | |
| 603 | category: "other", | |
| 604 | sourceKind: "ai_doc_update", | |
| 605 | }); | |
| 606 | } catch { | |
| 607 | /* swallow */ | |
| 608 | } | |
| 609 | const text = extractText(message); | |
| 610 | const parsed = parseJsonResponse<ClaudeDocPatchResponse>(text); | |
| 611 | if (!parsed) return null; | |
| 612 | return parsed; | |
| 613 | } catch (err) { | |
| 614 | console.warn( | |
| 615 | "[ai-doc-updater] Claude call failed:", | |
| 616 | err instanceof Error ? err.message : err | |
| 617 | ); | |
| 618 | return null; | |
| 619 | } | |
| 620 | } | |
| 621 | ||
| 622 | /** | |
| 623 | * Seed a fresh branch from the default branch HEAD. Mirrors | |
| 624 | * ai-patch-generator.seedBranchFromBase but here we always branch from | |
| 625 | * the default tip — doc fixes don't have a "base sha" of their own. | |
| 626 | */ | |
| 627 | async function seedBranchFromDefault( | |
| 628 | owner: string, | |
| 629 | name: string, | |
| 630 | branch: string, | |
| 631 | baseBranch: string | |
| 632 | ): Promise<string | null> { | |
| 633 | const fullRef = `refs/heads/${branch}`; | |
| 634 | if (await refExists(owner, name, fullRef)) { | |
| 635 | return await resolveRef(owner, name, branch); | |
| 636 | } | |
| 637 | const baseSha = await resolveRef(owner, name, baseBranch); | |
| 638 | if (!baseSha) return null; | |
| 639 | const ok = await updateRef(owner, name, fullRef, baseSha); | |
| 640 | return ok ? baseSha : null; | |
| 641 | } | |
| 642 | ||
| 643 | /** | |
| 644 | * Ask Claude to rewrite the stale prose and open a PR. Returns the new | |
| 645 | * branch + PR number, or `null` if: | |
| 646 | * | |
| 647 | * - Anthropic isn't configured AND no `client` was injected | |
| 648 | * - the repository can't be resolved | |
| 649 | * - no section was actually stale | |
| 650 | * - Claude returned zero patches | |
| 651 | * - a PR is already open for the same (repo, doc, marker) combination | |
| 652 | * - any DB / git step failed (logged + swallowed) | |
| 653 | * | |
| 654 | * NEVER throws. | |
| 655 | */ | |
| 656 | export async function proposeDocUpdate( | |
| 657 | opts: ProposeDocUpdateOptions | |
| 658 | ): Promise<ProposeDocUpdateResult | null> { | |
| 659 | const stale = opts.sections.filter((s) => s.stale); | |
| 660 | if (!stale.length) return null; | |
| 661 | ||
| 662 | // Resolve client lazily so tests can inject without an API key. | |
| 663 | let client: Pick<Anthropic, "messages">; | |
| 664 | if (opts.client) { | |
| 665 | client = opts.client; | |
| 666 | } else { | |
| 667 | if (!config.anthropicApiKey) return null; | |
| 668 | try { | |
| 669 | client = getAnthropic(); | |
| 670 | } catch { | |
| 671 | return null; | |
| 672 | } | |
| 673 | } | |
| 674 | ||
| 675 | const meta = await resolveRepoMeta(opts.repositoryId); | |
| 676 | if (!meta) return null; | |
| 677 | ||
| 678 | // Dedupe: skip when an open PR already exists for any of the stale | |
| 679 | // sections (look up by `last_pr_id`). | |
| 680 | try { | |
| 681 | const markers = stale.map((s) => s.marker); | |
| 682 | const rows = await db | |
| 683 | .select({ | |
| 684 | sectionMarker: docTracking.sectionMarker, | |
| 685 | lastPrId: docTracking.lastPrId, | |
| 686 | }) | |
| 687 | .from(docTracking) | |
| 688 | .where( | |
| 689 | and( | |
| 690 | eq(docTracking.repositoryId, opts.repositoryId), | |
| 691 | eq(docTracking.docPath, opts.path), | |
| 692 | markers.length > 0 | |
| 693 | ? inArray(docTracking.sectionMarker, markers) | |
| 694 | : eq(docTracking.sectionMarker, "") | |
| 695 | ) | |
| 696 | ); | |
| 697 | const openPrIds = rows | |
| 698 | .map((r) => r.lastPrId) | |
| 699 | .filter((id): id is string => !!id); | |
| 700 | if (openPrIds.length > 0) { | |
| 701 | const stillOpen = await db | |
| 702 | .select({ id: pullRequests.id, state: pullRequests.state }) | |
| 703 | .from(pullRequests) | |
| 704 | .where(inArray(pullRequests.id, openPrIds)); | |
| 705 | if (stillOpen.some((p) => p.state === "open")) { | |
| 706 | return null; | |
| 707 | } | |
| 708 | } | |
| 709 | } catch { | |
| 710 | // dedupe failure is non-fatal — better to propose twice than zero times | |
| 711 | } | |
| 712 | ||
| 713 | await ensureDocUpdateLabel(opts.repositoryId); | |
| 714 | ||
| 715 | // Resolve the doc + source contents at the current default branch tip. | |
| 716 | const defaultBranch = | |
| 717 | (await getDefaultBranch(meta.owner, meta.name).catch(() => null)) || | |
| 718 | meta.defaultBranch || | |
| 719 | "main"; | |
| 720 | ||
| 721 | const docBlob = await getBlob( | |
| 722 | meta.owner, | |
| 723 | meta.name, | |
| 724 | defaultBranch, | |
| 725 | opts.path | |
| 726 | ).catch(() => null); | |
| 727 | if (!docBlob || docBlob.isBinary) return null; | |
| 728 | ||
| 729 | const sourcesForPrompt: Array<{ | |
| 730 | marker: string; | |
| 731 | claim: string; | |
| 732 | claimedFor: string; | |
| 733 | sourceContent: string; | |
| 734 | }> = []; | |
| 735 | for (const s of stale) { | |
| 736 | const blob = await getBlob( | |
| 737 | meta.owner, | |
| 738 | meta.name, | |
| 739 | defaultBranch, | |
| 740 | s.claimedFor | |
| 741 | ).catch(() => null); | |
| 742 | if (!blob || blob.isBinary) continue; | |
| 743 | sourcesForPrompt.push({ | |
| 744 | marker: s.marker, | |
| 745 | claim: s.claim, | |
| 746 | claimedFor: s.claimedFor, | |
| 747 | sourceContent: blob.content, | |
| 748 | }); | |
| 749 | } | |
| 750 | if (sourcesForPrompt.length === 0) return null; | |
| 751 | ||
| 752 | const prompt = buildDocUpdatePrompt({ | |
| 753 | docPath: opts.path, | |
| 754 | docRaw: docBlob.content, | |
| 755 | staleSections: sourcesForPrompt, | |
| 756 | }); | |
| 757 | ||
| 758 | const response = await askClaudeForDocPatch(client, prompt); | |
| 759 | if (!response || !Array.isArray(response.patches) || response.patches.length === 0) { | |
| 760 | return null; | |
| 761 | } | |
| 762 | ||
| 763 | // Filter patches: only allow touching the doc we asked about. Defence | |
| 764 | // in depth against a wandering model. | |
| 765 | const goodPatches = response.patches.filter( | |
| 766 | (p): p is ClaudeDocPatch => | |
| 767 | !!p && | |
| 768 | typeof p.path === "string" && | |
| 769 | typeof p.new_content === "string" && | |
| 770 | p.path === opts.path | |
| 771 | ); | |
| 772 | if (!goodPatches.length) return null; | |
| 773 | ||
| 774 | const branch = docUpdateBranchName(opts.path, opts.branchOverride); | |
| 775 | const seeded = await seedBranchFromDefault( | |
| 776 | meta.owner, | |
| 777 | meta.name, | |
| 778 | branch, | |
| 779 | defaultBranch | |
| 780 | ); | |
| 781 | if (!seeded) { | |
| 782 | console.warn( | |
| 783 | `[ai-doc-updater] could not seed branch ${branch} from ${defaultBranch} for ${meta.owner}/${meta.name}` | |
| 784 | ); | |
| 785 | return null; | |
| 786 | } | |
| 787 | ||
| 788 | const writtenPaths: string[] = []; | |
| 789 | for (const patch of goodPatches) { | |
| 790 | const res = await createOrUpdateFileOnBranch({ | |
| 791 | owner: meta.owner, | |
| 792 | name: meta.name, | |
| 793 | branch, | |
| 794 | filePath: patch.path, | |
| 795 | bytes: new TextEncoder().encode(patch.new_content), | |
| 796 | message: `docs(ai-doc-update): refresh tracked section(s) in ${patch.path}`, | |
| 797 | authorName: "GlueCron AI", | |
| 798 | authorEmail: "ai@gluecron.com", | |
| 799 | }); | |
| 800 | if ("error" in res) { | |
| 801 | console.warn( | |
| 802 | `[ai-doc-updater] write failed (${res.error}) for ${patch.path}` | |
| 803 | ); | |
| 804 | continue; | |
| 805 | } | |
| 806 | writtenPaths.push(patch.path); | |
| 807 | } | |
| 808 | if (writtenPaths.length === 0) return null; | |
| 809 | ||
| 810 | const body = renderDocUpdatePrBody({ | |
| 811 | docPath: opts.path, | |
| 812 | explanation: response.explanation || "", | |
| 813 | updatedSections: stale, | |
| 814 | }); | |
| 815 | const title = `[ai-doc-update] Refresh tracked section(s) in ${opts.path}`; | |
| 816 | ||
| 817 | let prId: string | null = null; | |
| 818 | let prNumber: number | null = null; | |
| 819 | try { | |
| 820 | const [pr] = await db | |
| 821 | .insert(pullRequests) | |
| 822 | .values({ | |
| 823 | repositoryId: opts.repositoryId, | |
| 824 | authorId: meta.ownerId, | |
| 825 | title, | |
| 826 | body, | |
| 827 | baseBranch: defaultBranch, | |
| 828 | headBranch: branch, | |
| 829 | isDraft: false, | |
| 830 | }) | |
| 831 | .returning({ id: pullRequests.id, number: pullRequests.number }); | |
| 832 | if (pr) { | |
| 833 | prId = pr.id; | |
| 834 | prNumber = pr.number; | |
| 835 | try { | |
| 836 | await db.insert(prComments).values({ | |
| 837 | pullRequestId: pr.id, | |
| 838 | authorId: meta.ownerId, | |
| 839 | isAiReview: true, | |
| 840 | body: `${AI_DOC_UPDATE_MARKER}\nApplied label: \`${AI_DOC_UPDATE_LABEL}\``, | |
| 841 | }); | |
| 842 | } catch (err) { | |
| 843 | console.warn( | |
| 844 | "[ai-doc-updater] failed to insert label-marker comment:", | |
| 845 | err instanceof Error ? err.message : err | |
| 846 | ); | |
| 847 | } | |
| 848 | } | |
| 849 | } catch (err) { | |
| 850 | console.error( | |
| 851 | "[ai-doc-updater] failed to insert pullRequests row:", | |
| 852 | err instanceof Error ? err.message : err | |
| 853 | ); | |
| 854 | return null; | |
| 855 | } | |
| 856 | if (prNumber == null || prId == null) return null; | |
| 857 | ||
| 858 | // Update doc_tracking rows so we don't re-propose immediately and so | |
| 859 | // the UI can deep-link to the open PR. | |
| 860 | for (const s of stale) { | |
| 861 | try { | |
| 862 | await db | |
| 863 | .insert(docTracking) | |
| 864 | .values({ | |
| 865 | repositoryId: opts.repositoryId, | |
| 866 | docPath: opts.path, | |
| 867 | sectionMarker: s.marker, | |
| 868 | srcPath: s.claimedFor, | |
| 869 | claimedHash: s.currentSrcHash, | |
| 870 | lastPrId: prId, | |
| 871 | }) | |
| 872 | .onConflictDoUpdate({ | |
| 873 | target: [ | |
| 874 | docTracking.repositoryId, | |
| 875 | docTracking.docPath, | |
| 876 | docTracking.sectionMarker, | |
| 877 | ], | |
| 878 | set: { | |
| 879 | srcPath: s.claimedFor, | |
| 880 | claimedHash: s.currentSrcHash, | |
| 881 | lastCheckedAt: new Date(), | |
| 882 | lastPrId: prId, | |
| 883 | }, | |
| 884 | }); | |
| 885 | } catch (err) { | |
| 886 | console.warn( | |
| 887 | `[ai-doc-updater] doc_tracking upsert failed for ${opts.path}::${s.marker}:`, | |
| 888 | err instanceof Error ? err.message : err | |
| 889 | ); | |
| 890 | } | |
| 891 | } | |
| 892 | ||
| 893 | await audit({ | |
| 894 | userId: null, | |
| 895 | action: "ai.doc_update.opened", | |
| 896 | repositoryId: opts.repositoryId, | |
| 897 | metadata: { | |
| 898 | branch, | |
| 899 | prNumber, | |
| 900 | docPath: opts.path, | |
| 901 | sectionCount: stale.length, | |
| 902 | }, | |
| 903 | }); | |
| 904 | ||
| 905 | return { branch, prNumber, updatedSections: writtenPaths.length }; | |
| 906 | } | |
| 907 | ||
| 908 | /** | |
| 909 | * Convenience driver used by the post-receive hook. Walks every tracked | |
| 910 | * doc and proposes one PR per stale doc. Never throws. | |
| 911 | */ | |
| 912 | export async function runDocDriftCheckForRepo( | |
| 913 | repositoryId: string | |
| 914 | ): Promise<{ docs: number; proposed: number }> { | |
| 915 | const docs = await findTrackedDocs(repositoryId); | |
| 916 | let proposed = 0; | |
| 917 | for (const d of docs) { | |
| 918 | const hasStale = d.sections.some((s) => s.stale); | |
| 919 | if (!hasStale) { | |
| 920 | // First-time observation: just seed the claimed_hash rows so the | |
| 921 | // next push has a baseline to compare against. | |
| 922 | await persistObservedSections(repositoryId, d); | |
| 923 | continue; | |
| 924 | } | |
| 925 | const out = await proposeDocUpdate({ | |
| 926 | repositoryId, | |
| 927 | path: d.path, | |
| 928 | sections: d.sections, | |
| 929 | }).catch(() => null); | |
| 930 | if (out) proposed += 1; | |
| 931 | } | |
| 932 | return { docs: docs.length, proposed }; | |
| 933 | } | |
| 934 | ||
| 935 | /** | |
| 936 | * Test-only re-exports of internal helpers. | |
| 937 | */ | |
| 938 | export const __test = { | |
| 939 | resolveRepoMeta, | |
| 940 | listMarkdownFiles, | |
| 941 | ensureDocUpdateLabel, | |
| 942 | askClaudeForDocPatch, | |
| 943 | seedBranchFromDefault, | |
| 944 | }; |