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

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.

ai-doc-updater.tsBlame944 lines · 1 contributor
d199847Claude1/**
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
31import { createHash } from "crypto";
32import { and, eq, inArray } from "drizzle-orm";
33import type Anthropic from "@anthropic-ai/sdk";
34import { db } from "../db";
35import {
36 docTracking,
37 labels,
38 prComments,
39 pullRequests,
40 repositories,
41 users,
42} from "../db/schema";
43import {
44 createOrUpdateFileOnBranch,
45 getBlob,
46 getDefaultBranch,
47 getTreeRecursive,
48 refExists,
49 resolveRef,
50 updateRef,
51} from "../git/repository";
52import { config } from "./config";
53import { audit } from "./notify";
54import {
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. */
62export const AI_DOC_UPDATE_MARKER = "<!-- gluecron-ai-doc-update:proposed -->";
63
64/** Label name we surface (and create on the repo) for these PRs. */
65export const AI_DOC_UPDATE_LABEL = "ai:doc-update";
66
67/** Opening / closing markers used to bound a tracked region in markdown. */
68export const DOC_TRACK_OPEN_RE =
69 /<!--\s*gluecron:doc-track\s+src=([^\s>]+?)\s*-->/g;
70export const DOC_TRACK_CLOSE = "<!-- /gluecron:doc-track -->";
71
72/** Max bytes of any single doc / source file we'll hand to Claude. */
73const 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 */
81export 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
95export 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. */
108export 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 */
117export 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 */
128export 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 */
165async 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 */
207async 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 */
232async function ensureDocUpdateLabel(repositoryId: string): Promise<void> {
233 try {
234 await db
235 .insert(labels)
236 .values({
237 repositoryId,
238 name: AI_DOC_UPDATE_LABEL,
239 color: "#36c5d6",
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
256export 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 */
270export 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 */
390export 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
430export 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
448export interface ProposeDocUpdateResult {
449 branch: string;
450 prNumber: number;
451 updatedSections: number;
452}
453
454interface ClaudeDocPatch {
455 path: string;
456 new_content: string;
457}
458
459interface 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 */
468export 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). */
535export 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 */
553export 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
585async 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 */
627async 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 */
656export 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 */
912export 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 */
938export const __test = {
939 resolveRepoMeta,
940 listMarkdownFiles,
941 ensureDocUpdateLabel,
942 askClaudeForDocPatch,
943 seedBranchFromDefault,
944};