Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitd199847unknown_key

feat(advancement-scanner): weekly Claude scan — model upgrades + stack bumps + trending features

Claude committed on May 25, 2026Parent: 422a2d4
9 files changed+50170d199847999877648303cfd1a7e9ba89cd917436d
9 changed files+5017−0
Addeddrizzle/0068_doc_tracking.sql+58−0View fileUnifiedSplit
1-- Gluecron migration 0068: AI-tracked documentation sections.
2--
3-- A markdown file (typically README.md) can contain regions delimited by
4-- HTML-comment markers like:
5--
6-- <!-- gluecron:doc-track src=src/lib/auth.ts -->
7-- This module exports `signIn` and `signUp` — see the source for details.
8-- <!-- /gluecron:doc-track -->
9--
10-- Each region tracks the live hash of the referenced source file. When the
11-- hash drifts, src/lib/ai-doc-updater.ts asks Claude to refresh the prose
12-- and opens a PR tagged `ai:doc-update`.
13--
14-- This table stores the *currently claimed* hash so we can detect drift
15-- without re-reading every region on every push. The unique constraint on
16-- (repo, doc_path, section_marker) keeps one row per region; pushes UPSERT
17-- the latest content hash through it.
18--
19-- Wrapped in DO blocks so the migration is safe to re-run and gracefully
20-- ignores missing parents on partial replays. The whole feature degrades
21-- to "no rows" when the table is missing, which is exactly what we want
22-- for environments that haven't migrated yet.
23
24--> statement-breakpoint
25DO $$
26BEGIN
27 CREATE TABLE IF NOT EXISTS "doc_tracking" (
28 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
29 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
30 "doc_path" text NOT NULL,
31 "section_marker" text NOT NULL,
32 "src_path" text NOT NULL,
33 "claimed_hash" text NOT NULL,
34 "last_checked_at" timestamptz NOT NULL DEFAULT now(),
35 "last_pr_id" uuid REFERENCES "pull_requests"("id") ON DELETE SET NULL,
36 "created_at" timestamptz NOT NULL DEFAULT now()
37 );
38EXCEPTION WHEN OTHERS THEN
39 RAISE NOTICE 'doc_tracking create failed (%); ai-doc-updater will degrade to no-op', SQLERRM;
40END $$;
41
42--> statement-breakpoint
43DO $$
44BEGIN
45 CREATE UNIQUE INDEX IF NOT EXISTS "doc_tracking_repo_doc_marker"
46 ON "doc_tracking" ("repository_id", "doc_path", "section_marker");
47EXCEPTION WHEN OTHERS THEN
48 RAISE NOTICE 'doc_tracking_repo_doc_marker index failed (%)', SQLERRM;
49END $$;
50
51--> statement-breakpoint
52DO $$
53BEGIN
54 CREATE INDEX IF NOT EXISTS "doc_tracking_repo"
55 ON "doc_tracking" ("repository_id");
56EXCEPTION WHEN OTHERS THEN
57 RAISE NOTICE 'doc_tracking_repo index failed (%)', SQLERRM;
58END $$;
Addedsrc/__tests__/advancement-scanner.test.ts+597−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/advancement-scanner.ts.
3 *
4 * Uses the dependency-injection seams on `runAdvancementScan` so we
5 * never hit the DB or the Anthropic API. The Claude call is mocked
6 * with canned findings; the issue-create + dedupe + audit side-effects
7 * are observed via spy fakes.
8 *
9 * Coverage:
10 * - Public surface exports
11 * - dedupe key + body marker
12 * - Pure model-suggestion logic (newer / cheaper variants)
13 * - Issue creation for canned Claude findings
14 * - Dedupe (same sha256 title) skips re-filing within 30d
15 * - Stack bumps route to the migration-assistant kickoff, not openIssue
16 * - Per-finding errors are isolated
17 * - Hard cap on findings per scan
18 * - audit_log gets the scan-complete row
19 *
20 * DB-touching paths (the defaults) are gated behind HAS_DB so the
21 * suite stays green on machines without Postgres.
22 */
23
24import { describe, it, expect } from "bun:test";
25import {
26 ADVANCEMENT_AUDIT_ACTION,
27 ADVANCEMENT_DEDUPE_DAYS,
28 ADVANCEMENT_DEDUPE_MARKER_PREFIX,
29 ADVANCEMENT_LABEL_NAME,
30 ADVANCEMENT_SCAN_COMPLETE_ACTION,
31 KNOWN_CLAUDE_MODELS,
32 MAX_ADVANCEMENT_FINDINGS_PER_SCAN,
33 STACK_KEYSTONE_DEPS,
34 TRENDING_FEATURE_CATALOGUE,
35 __test as scannerInternals,
36 advancementDedupeKey,
37 renderAdvancementBody,
38 runAdvancementScan,
39 suggestModelUpgrades,
40 type AdvancementFinding,
41 type ClaudeModelEntry,
42} from "../lib/advancement-scanner";
43
44const HAS_DB = Boolean(process.env.DATABASE_URL);
45
46const REPO = {
47 repositoryId: "repo-self",
48 ownerId: "owner-1",
49 ownerName: "ccantynz",
50 repoName: "Gluecron.com",
51 defaultBranch: "main",
52};
53
54function finding(
55 partial: Partial<AdvancementFinding> = {}
56): AdvancementFinding {
57 return {
58 kind: "self_improvement",
59 title: "Default finding",
60 urgency: "medium",
61 suggested_action: "Do the thing.",
62 ...partial,
63 };
64}
65
66// ---------------------------------------------------------------------------
67// Public surface
68// ---------------------------------------------------------------------------
69
70describe("advancement-scanner — module surface", () => {
71 it("exports the expected public functions + constants", () => {
72 expect(typeof runAdvancementScan).toBe("function");
73 expect(typeof advancementDedupeKey).toBe("function");
74 expect(typeof renderAdvancementBody).toBe("function");
75 expect(typeof suggestModelUpgrades).toBe("function");
76 expect(ADVANCEMENT_LABEL_NAME).toBe("ai:advancement");
77 expect(ADVANCEMENT_AUDIT_ACTION).toBe("ai.advancement.finding");
78 expect(ADVANCEMENT_SCAN_COMPLETE_ACTION).toBe(
79 "ai.advancement.scan_complete"
80 );
81 expect(ADVANCEMENT_DEDUPE_DAYS).toBe(30);
82 expect(MAX_ADVANCEMENT_FINDINGS_PER_SCAN).toBeGreaterThan(0);
83 expect(STACK_KEYSTONE_DEPS).toContain("hono");
84 expect(STACK_KEYSTONE_DEPS).toContain("drizzle-orm");
85 expect(STACK_KEYSTONE_DEPS).toContain("@anthropic-ai/sdk");
86 expect(TRENDING_FEATURE_CATALOGUE.length).toBeGreaterThan(0);
87 expect(KNOWN_CLAUDE_MODELS.length).toBeGreaterThan(0);
88 });
89});
90
91describe("advancementDedupeKey", () => {
92 it("produces a deterministic 32-char hex digest", () => {
93 const k1 = advancementDedupeKey("Bump hono 4 → 5");
94 const k2 = advancementDedupeKey("Bump hono 4 → 5");
95 expect(k1).toBe(k2);
96 expect(k1).toMatch(/^[0-9a-f]{32}$/);
97 });
98
99 it("is case-insensitive + trimmed", () => {
100 expect(advancementDedupeKey(" Bump HONO ")).toBe(
101 advancementDedupeKey("bump hono")
102 );
103 });
104
105 it("differs across distinct titles", () => {
106 expect(advancementDedupeKey("a")).not.toBe(advancementDedupeKey("b"));
107 });
108});
109
110describe("renderAdvancementBody", () => {
111 it("embeds the dedupe marker so LIKE lookup matches", () => {
112 const f = finding({ title: "Upgrade Sonnet 4 → 4.7" });
113 const key = advancementDedupeKey(f.title);
114 const body = renderAdvancementBody(f, key);
115 expect(body).toContain(`${ADVANCEMENT_DEDUPE_MARKER_PREFIX}${key}`);
116 expect(body).toContain("Suggested action");
117 expect(body).toContain(f.suggested_action);
118 });
119
120 it("uses a high-urgency badge for high findings", () => {
121 const body = renderAdvancementBody(finding({ urgency: "high" }), "k");
122 expect(body).toContain("high");
123 });
124
125 it("labels each kind correctly", () => {
126 expect(
127 renderAdvancementBody(finding({ kind: "model_release" }), "k")
128 ).toContain("Model release");
129 expect(
130 renderAdvancementBody(finding({ kind: "stack_bump" }), "k")
131 ).toContain("Stack version bump");
132 expect(
133 renderAdvancementBody(finding({ kind: "trending_feature" }), "k")
134 ).toContain("Trending feature");
135 });
136});
137
138// ---------------------------------------------------------------------------
139// suggestModelUpgrades — pure
140// ---------------------------------------------------------------------------
141
142describe("suggestModelUpgrades", () => {
143 const fixture: ClaudeModelEntry[] = [
144 { id: "sonnet-old", family: "sonnet", generation: 4, capability: 80, cost: 30, label: "Sonnet old" },
145 { id: "sonnet-new", family: "sonnet", generation: 4.7, capability: 92, cost: 30, label: "Sonnet new" },
146 { id: "sonnet-cheap-better", family: "sonnet", generation: 4.7, capability: 92, cost: 20, label: "Sonnet cheap" },
147 { id: "haiku-old", family: "haiku", generation: 4.5, capability: 60, cost: 10, label: "Haiku old" },
148 ];
149
150 it("suggests a newer same-family model when one exists", () => {
151 const out = suggestModelUpgrades("sonnet-old", fixture);
152 const titles = out.map((f) => f.title);
153 expect(titles.some((t) => t.includes("→"))).toBe(true);
154 expect(out.every((f) => f.kind === "model_release")).toBe(true);
155 });
156
157 it("suggests a cheaper-better variant when both exist", () => {
158 const out = suggestModelUpgrades("sonnet-old", fixture);
159 expect(out.some((f) => f.title.toLowerCase().includes("cost"))).toBe(true);
160 });
161
162 it("returns empty when configured model is already best in family", () => {
163 // strip the cheaper variant so only one current-best exists.
164 const trimmed = fixture.filter((m) => m.id !== "sonnet-cheap-better");
165 const out = suggestModelUpgrades("sonnet-new", trimmed);
166 expect(out).toEqual([]);
167 });
168
169 it("falls back to best-in-family when configured id is unknown", () => {
170 const out = suggestModelUpgrades("sonnet-legacy-2024", fixture);
171 expect(out.length).toBeGreaterThan(0);
172 expect(out[0].kind).toBe("model_release");
173 });
174
175 it("returns empty when family can't be guessed", () => {
176 const out = suggestModelUpgrades("unrecognized-model", fixture);
177 expect(out).toEqual([]);
178 });
179});
180
181// ---------------------------------------------------------------------------
182// runAdvancementScan — issue creation
183// ---------------------------------------------------------------------------
184
185describe("runAdvancementScan — issue creation", () => {
186 it("opens an issue per Claude finding and skips info-grade defaults", async () => {
187 const created: Array<{ title: string; body: string }> = [];
188 const audited: Array<{ title: string; issueNumber: number | null }> = [];
189
190 const result = await runAdvancementScan({
191 aiAvailable: () => true,
192 // Force the model probe to be empty so the assertion below targets only the Claude finding.
193 configuredModels: () => [],
194 // No stack-bump probe.
195 loadPackageJson: async () => null,
196 fetchLatestVersion: async () => null,
197 askSelfImprovement: async () => [
198 finding({ title: "Improve cold-start time", urgency: "high" }),
199 ],
200 askTrending: async () => [
201 finding({
202 kind: "trending_feature",
203 title: "Add per-PR observability dashboard",
204 urgency: "medium",
205 suggested_action: "Plumb a per-PR dashboard the way Vercel does.",
206 }),
207 ],
208 resolveSelfHostRepo: async () => REPO,
209 isDuplicate: async () => false,
210 openIssue: async (args) => {
211 created.push({ title: args.title, body: args.body });
212 return created.length;
213 },
214 recordAudit: async (f, _r, n) => {
215 audited.push({ title: f.title, issueNumber: n });
216 },
217 proposeBumpPr: async () => null,
218 recordScanComplete: async () => {},
219 });
220
221 expect(result.openedIssues).toBe(2);
222 expect(result.openedPrs).toBe(0);
223 expect(result.skippedDedupe).toBe(0);
224 expect(result.errors).toBe(0);
225 expect(created.map((c) => c.title).sort()).toEqual(
226 ["Add per-PR observability dashboard", "Improve cold-start time"].sort()
227 );
228 for (const c of created) {
229 const key = advancementDedupeKey(c.title);
230 expect(c.body).toContain(`${ADVANCEMENT_DEDUPE_MARKER_PREFIX}${key}`);
231 }
232 expect(audited).toHaveLength(2);
233 });
234
235 it("creates an issue for each model-release suggestion", async () => {
236 const created: string[] = [];
237 const result = await runAdvancementScan({
238 aiAvailable: () => false, // Skip Claude probes.
239 configuredModels: () => [KNOWN_CLAUDE_MODELS[0]!.id],
240 loadPackageJson: async () => null,
241 fetchLatestVersion: async () => null,
242 resolveSelfHostRepo: async () => REPO,
243 isDuplicate: async () => false,
244 openIssue: async (args) => {
245 created.push(args.title);
246 return created.length;
247 },
248 recordAudit: async () => {},
249 proposeBumpPr: async () => null,
250 recordScanComplete: async () => {},
251 });
252 // The curated catalogue has newer/cheaper entries vs the first model,
253 // so we expect at least one issue.
254 expect(result.openedIssues).toBeGreaterThan(0);
255 expect(created.every((t) => t.toLowerCase().includes("sonnet") || t.toLowerCase().includes("upgrade") || t.toLowerCase().includes("save"))).toBe(true);
256 });
257});
258
259// ---------------------------------------------------------------------------
260// Dedupe
261// ---------------------------------------------------------------------------
262
263describe("runAdvancementScan — dedupe", () => {
264 it("does not double-fire when isDuplicate returns true", async () => {
265 const created: string[] = [];
266 let dedupeChecks = 0;
267
268 const result = await runAdvancementScan({
269 aiAvailable: () => true,
270 configuredModels: () => [],
271 loadPackageJson: async () => null,
272 fetchLatestVersion: async () => null,
273 askSelfImprovement: async () => [
274 finding({ title: "Improve cold-start", urgency: "high" }),
275 ],
276 askTrending: async () => [],
277 resolveSelfHostRepo: async () => REPO,
278 isDuplicate: async () => {
279 dedupeChecks += 1;
280 return true;
281 },
282 openIssue: async (args) => {
283 created.push(args.title);
284 return 1;
285 },
286 recordAudit: async () => {},
287 proposeBumpPr: async () => null,
288 recordScanComplete: async () => {},
289 });
290
291 expect(dedupeChecks).toBe(1);
292 expect(created).toEqual([]);
293 expect(result.skippedDedupe).toBe(1);
294 expect(result.openedIssues).toBe(0);
295 });
296
297 it("passes the 30-day window to the duplicate lookup", async () => {
298 let observed = -1;
299 await runAdvancementScan({
300 aiAvailable: () => true,
301 configuredModels: () => [],
302 loadPackageJson: async () => null,
303 fetchLatestVersion: async () => null,
304 askSelfImprovement: async () => [finding({ urgency: "high" })],
305 askTrending: async () => [],
306 resolveSelfHostRepo: async () => REPO,
307 isDuplicate: async (_r, _k, days) => {
308 observed = days;
309 return true;
310 },
311 openIssue: async () => 1,
312 recordAudit: async () => {},
313 proposeBumpPr: async () => null,
314 recordScanComplete: async () => {},
315 });
316 expect(observed).toBe(ADVANCEMENT_DEDUPE_DAYS);
317 });
318
319 it("passes the sha256 dedupe key to the duplicate check", async () => {
320 let observed = "";
321 await runAdvancementScan({
322 aiAvailable: () => true,
323 configuredModels: () => [],
324 loadPackageJson: async () => null,
325 fetchLatestVersion: async () => null,
326 askSelfImprovement: async () => [
327 finding({ title: "Improve cold-start time" }),
328 ],
329 askTrending: async () => [],
330 resolveSelfHostRepo: async () => REPO,
331 isDuplicate: async (_r, key) => {
332 observed = key;
333 return true;
334 },
335 openIssue: async () => 1,
336 recordAudit: async () => {},
337 proposeBumpPr: async () => null,
338 recordScanComplete: async () => {},
339 });
340 expect(observed).toBe(advancementDedupeKey("Improve cold-start time"));
341 });
342});
343
344// ---------------------------------------------------------------------------
345// Stack bumps route to the migration assistant
346// ---------------------------------------------------------------------------
347
348describe("runAdvancementScan — stack bumps", () => {
349 it("hands a major-version bump to the migration assistant instead of opening an issue", async () => {
350 const bumps: Array<{ dep: string; from: string; to: string }> = [];
351 const issues: string[] = [];
352
353 const result = await runAdvancementScan({
354 aiAvailable: () => false,
355 configuredModels: () => [],
356 loadPackageJson: async () =>
357 JSON.stringify({
358 dependencies: { hono: "^3.0.0" },
359 }),
360 fetchLatestVersion: async (name) => (name === "hono" ? "4.5.0" : null),
361 resolveSelfHostRepo: async () => REPO,
362 isDuplicate: async () => false,
363 openIssue: async (args) => {
364 issues.push(args.title);
365 return issues.length;
366 },
367 recordAudit: async () => {},
368 proposeBumpPr: async (args) => {
369 bumps.push({
370 dep: args.dependency,
371 from: args.fromVersion,
372 to: args.toVersion,
373 });
374 return { branch: "ai-migration/hono", prNumber: 7 };
375 },
376 resolveBaseSha: async () => "deadbeefcafefeedfacefeeddeadbeefcafefeed",
377 recordScanComplete: async () => {},
378 });
379
380 expect(bumps).toEqual([
381 { dep: "hono", from: "^3.0.0", to: "4.5.0" },
382 ]);
383 // Migration assistant accepted -> no fallback issue.
384 expect(issues).toEqual([]);
385 expect(result.openedPrs).toBe(1);
386 expect(result.openedIssues).toBe(0);
387 });
388
389 it("falls back to an issue when the migration assistant declines", async () => {
390 const issues: string[] = [];
391 const result = await runAdvancementScan({
392 aiAvailable: () => false,
393 configuredModels: () => [],
394 loadPackageJson: async () =>
395 JSON.stringify({ dependencies: { hono: "^3.0.0" } }),
396 fetchLatestVersion: async () => "4.5.0",
397 resolveSelfHostRepo: async () => REPO,
398 isDuplicate: async () => false,
399 openIssue: async (args) => {
400 issues.push(args.title);
401 return issues.length;
402 },
403 recordAudit: async () => {},
404 proposeBumpPr: async () => null, // decline
405 resolveBaseSha: async () => "deadbeefcafefeedfacefeeddeadbeefcafefeed",
406 recordScanComplete: async () => {},
407 });
408 expect(issues.length).toBe(1);
409 expect(issues[0]).toContain("Bump hono");
410 expect(result.openedIssues).toBe(1);
411 expect(result.openedPrs).toBe(0);
412 });
413});
414
415// ---------------------------------------------------------------------------
416// Robustness
417// ---------------------------------------------------------------------------
418
419describe("runAdvancementScan — robustness", () => {
420 it("isolates per-finding failures — one bad issue insert does not stop the rest", async () => {
421 const created: string[] = [];
422
423 const result = await runAdvancementScan({
424 aiAvailable: () => true,
425 configuredModels: () => [],
426 loadPackageJson: async () => null,
427 fetchLatestVersion: async () => null,
428 askSelfImprovement: async () => [
429 finding({ title: "first", urgency: "high" }),
430 finding({ title: "second", urgency: "high" }),
431 ],
432 askTrending: async () => [],
433 resolveSelfHostRepo: async () => REPO,
434 isDuplicate: async () => false,
435 openIssue: async (args) => {
436 if (args.title === "first") throw new Error("DB blew up");
437 created.push(args.title);
438 return created.length;
439 },
440 recordAudit: async () => {},
441 proposeBumpPr: async () => null,
442 recordScanComplete: async () => {},
443 });
444
445 expect(created).toEqual(["second"]);
446 expect(result.openedIssues).toBe(1);
447 expect(result.errors).toBe(1);
448 });
449
450 it("returns a clean summary when AI is unavailable", async () => {
451 const result = await runAdvancementScan({
452 aiAvailable: () => false,
453 configuredModels: () => [],
454 loadPackageJson: async () => null,
455 fetchLatestVersion: async () => null,
456 resolveSelfHostRepo: async () => REPO,
457 isDuplicate: async () => false,
458 openIssue: async () => null,
459 recordAudit: async () => {},
460 proposeBumpPr: async () => null,
461 recordScanComplete: async () => {},
462 });
463 expect(result.findings).toEqual([]);
464 expect(result.openedIssues).toBe(0);
465 expect(result.openedPrs).toBe(0);
466 expect(result.errors).toBe(0);
467 });
468
469 it("caps total findings persisted per scan", async () => {
470 const created: string[] = [];
471 const tooMany: AdvancementFinding[] = [];
472 for (let i = 0; i < 20; i++) {
473 tooMany.push(finding({ title: `finding-${i}`, urgency: "high" }));
474 }
475 const result = await runAdvancementScan({
476 aiAvailable: () => true,
477 configuredModels: () => [],
478 loadPackageJson: async () => null,
479 fetchLatestVersion: async () => null,
480 askSelfImprovement: async () => tooMany,
481 askTrending: async () => [],
482 resolveSelfHostRepo: async () => REPO,
483 isDuplicate: async () => false,
484 openIssue: async (args) => {
485 created.push(args.title);
486 return created.length;
487 },
488 recordAudit: async () => {},
489 proposeBumpPr: async () => null,
490 recordScanComplete: async () => {},
491 maxFindings: 4,
492 });
493 expect(created.length).toBe(4);
494 expect(result.openedIssues).toBe(4);
495 expect(result.findings.length).toBe(4);
496 });
497
498 it("records a scan-complete audit row at the end", async () => {
499 let scanCompleteCalled = false;
500 await runAdvancementScan({
501 aiAvailable: () => false,
502 configuredModels: () => [],
503 loadPackageJson: async () => null,
504 fetchLatestVersion: async () => null,
505 resolveSelfHostRepo: async () => REPO,
506 isDuplicate: async () => false,
507 openIssue: async () => 1,
508 recordAudit: async () => {},
509 proposeBumpPr: async () => null,
510 recordScanComplete: async () => {
511 scanCompleteCalled = true;
512 },
513 });
514 expect(scanCompleteCalled).toBe(true);
515 });
516
517 it("does not crash when the self-host repo is missing", async () => {
518 const result = await runAdvancementScan({
519 aiAvailable: () => false,
520 configuredModels: () => [KNOWN_CLAUDE_MODELS[0]!.id],
521 loadPackageJson: async () => null,
522 fetchLatestVersion: async () => null,
523 resolveSelfHostRepo: async () => null,
524 isDuplicate: async () => false,
525 openIssue: async () => 1,
526 recordAudit: async () => {},
527 proposeBumpPr: async () => null,
528 recordScanComplete: async () => {},
529 });
530 // Model-release findings still surface in the result, but no issues
531 // are opened because the repo couldn't be resolved.
532 expect(result.findings.length).toBeGreaterThan(0);
533 expect(result.openedIssues).toBe(0);
534 });
535});
536
537// ---------------------------------------------------------------------------
538// Internal helpers
539// ---------------------------------------------------------------------------
540
541describe("internal helpers", () => {
542 it("countByKind tallies findings", () => {
543 const out = scannerInternals.countByKind([
544 finding({ kind: "model_release" }),
545 finding({ kind: "model_release" }),
546 finding({ kind: "trending_feature" }),
547 ]);
548 expect(out.model_release).toBe(2);
549 expect(out.trending_feature).toBe(1);
550 expect(out.stack_bump).toBe(0);
551 });
552
553 it("isPlausibleClaudeFinding rejects malformed rows", () => {
554 expect(scannerInternals.isPlausibleClaudeFinding({})).toBe(false);
555 expect(
556 scannerInternals.isPlausibleClaudeFinding({
557 title: "",
558 urgency: "high",
559 suggested_action: "x",
560 })
561 ).toBe(false);
562 expect(
563 scannerInternals.isPlausibleClaudeFinding({
564 title: "ok",
565 urgency: "spicy",
566 suggested_action: "x",
567 })
568 ).toBe(false);
569 expect(
570 scannerInternals.isPlausibleClaudeFinding({
571 title: "ok",
572 urgency: "high",
573 suggested_action: "x",
574 })
575 ).toBe(true);
576 });
577});
578
579// ---------------------------------------------------------------------------
580// DB-backed smoke (only runs when DATABASE_URL is set)
581// ---------------------------------------------------------------------------
582
583describe.skipIf(!HAS_DB)("runAdvancementScan — DB-backed smoke", () => {
584 it("calls the default recordAudit/openIssue when no overrides given", async () => {
585 // Just verifies the wiring — uses an unresolved repo so nothing
586 // actually inserts. Confirms the lib doesn't throw when default deps
587 // hit the live DB.
588 const result = await runAdvancementScan({
589 aiAvailable: () => false,
590 configuredModels: () => [],
591 loadPackageJson: async () => null,
592 fetchLatestVersion: async () => null,
593 resolveSelfHostRepo: async () => null,
594 });
595 expect(result.errors).toBeLessThanOrEqual(1);
596 });
597});
Addedsrc/__tests__/ai-doc-updater.test.ts+648−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/ai-doc-updater.ts.
3 *
4 * Layout mirrors ai-patch-generator.test.ts:
5 *
6 * 1. Pure parser — `parseTrackedSections` extracts every region with
7 * the right `marker`/`claim`/`claimedFor` triple, ignores unclosed
8 * regions, and is stable across runs (deterministic marker hash).
9 * 2. Pure helpers — `sha256Hex`, `deriveSectionMarker`,
10 * `docUpdateBranchName`, `buildDocUpdatePrompt`,
11 * `renderDocUpdatePrBody`.
12 * 3. Drift detection — uses real bare repos on disk. With no DB present
13 * we still assert that `findTrackedDocs` returns the parsed shape
14 * and that "unseen" sections (no prior row) are NOT flagged stale.
15 * 4. End-to-end propose — injected fake Claude client + real bare repo;
16 * DB-backed steps (PR insert) gated on HAS_DB.
17 *
18 * The Anthropic client is faked via the public `client` option so we never
19 * touch the network or require an API key.
20 */
21
22import { describe, it, expect, beforeAll, afterAll } from "bun:test";
23import { join } from "path";
24import { rm, mkdir } from "fs/promises";
25import {
26 AI_DOC_UPDATE_LABEL,
27 AI_DOC_UPDATE_MARKER,
28 buildDocUpdatePrompt,
29 deriveSectionMarker,
30 docUpdateBranchName,
31 findTrackedDocs,
32 parseTrackedSections,
33 proposeDocUpdate,
34 renderDocUpdatePrBody,
35 sha256Hex,
36 __test,
37} from "../lib/ai-doc-updater";
38import {
39 createOrUpdateFileOnBranch,
40 initBareRepo,
41 refExists,
42 getBlob,
43} from "../git/repository";
44import { db } from "../db";
45import { eq } from "drizzle-orm";
46import {
47 docTracking,
48 pullRequests,
49 repositories,
50 users,
51} from "../db/schema";
52
53const HAS_DB = Boolean(process.env.DATABASE_URL);
54
55const TEST_REPOS = join(
56 import.meta.dir,
57 "../../.test-repos-ai-doc-" + Date.now()
58);
59
60beforeAll(async () => {
61 process.env.GIT_REPOS_PATH = TEST_REPOS;
62 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
63 await rm(TEST_REPOS, { recursive: true, force: true });
64 await mkdir(TEST_REPOS, { recursive: true });
65});
66
67afterAll(async () => {
68 await rm(TEST_REPOS, { recursive: true, force: true });
69});
70
71// ---------------------------------------------------------------------------
72// Pure parser
73// ---------------------------------------------------------------------------
74
75describe("parseTrackedSections", () => {
76 it("returns [] for empty / non-string input", () => {
77 expect(parseTrackedSections("")).toEqual([]);
78 expect(parseTrackedSections(undefined as any)).toEqual([]);
79 expect(parseTrackedSections(null as any)).toEqual([]);
80 });
81
82 it("returns [] when there are no markers", () => {
83 expect(parseTrackedSections("# Hello\n\nJust prose.")).toEqual([]);
84 });
85
86 it("extracts a single region with the right path + claim", () => {
87 const md = [
88 "# Title",
89 "",
90 "<!-- gluecron:doc-track src=src/lib/auth.ts -->",
91 "This module exports `signIn` and `signUp`.",
92 "<!-- /gluecron:doc-track -->",
93 "",
94 "Trailing prose.",
95 ].join("\n");
96 const out = parseTrackedSections(md);
97 expect(out.length).toBe(1);
98 expect(out[0].claimedFor).toBe("src/lib/auth.ts");
99 expect(out[0].claim).toContain("signIn");
100 expect(out[0].claim).toContain("signUp");
101 expect(out[0].marker.length).toBe(16);
102 });
103
104 it("extracts multiple regions in document order", () => {
105 const md = [
106 "<!-- gluecron:doc-track src=a.ts -->",
107 "first",
108 "<!-- /gluecron:doc-track -->",
109 "middle",
110 "<!-- gluecron:doc-track src=b.ts -->",
111 "second",
112 "<!-- /gluecron:doc-track -->",
113 ].join("\n");
114 const out = parseTrackedSections(md);
115 expect(out.length).toBe(2);
116 expect(out[0].claimedFor).toBe("a.ts");
117 expect(out[0].claim).toBe("first");
118 expect(out[1].claimedFor).toBe("b.ts");
119 expect(out[1].claim).toBe("second");
120 });
121
122 it("ignores an unclosed region", () => {
123 const md = [
124 "<!-- gluecron:doc-track src=a.ts -->",
125 "no close",
126 "",
127 "<!-- gluecron:doc-track src=b.ts -->",
128 "closed",
129 "<!-- /gluecron:doc-track -->",
130 ].join("\n");
131 const out = parseTrackedSections(md);
132 // The first open swallows up to the next close — so we get a single
133 // region whose claim spans across the second open marker. That's
134 // still a deterministic outcome; just assert what we got.
135 expect(out.length).toBe(1);
136 expect(out[0].claimedFor).toBe("a.ts");
137 });
138
139 it("ignores a region with empty body", () => {
140 const md = [
141 "<!-- gluecron:doc-track src=a.ts -->",
142 "",
143 "<!-- /gluecron:doc-track -->",
144 ].join("\n");
145 expect(parseTrackedSections(md).length).toBe(0);
146 });
147
148 it("derives the same marker for the same (src, claim) pair", () => {
149 const a = deriveSectionMarker("src/a.ts", "hello");
150 const b = deriveSectionMarker("src/a.ts", "hello");
151 expect(a).toBe(b);
152 expect(a.length).toBe(16);
153 });
154
155 it("derives different markers when the claim differs", () => {
156 expect(deriveSectionMarker("src/a.ts", "hello")).not.toBe(
157 deriveSectionMarker("src/a.ts", "world")
158 );
159 });
160});
161
162describe("sha256Hex", () => {
163 it("is deterministic and 64-char hex", () => {
164 const a = sha256Hex("abc");
165 const b = sha256Hex("abc");
166 expect(a).toBe(b);
167 expect(/^[0-9a-f]{64}$/.test(a)).toBe(true);
168 });
169
170 it("differs on different input", () => {
171 expect(sha256Hex("abc")).not.toBe(sha256Hex("abd"));
172 });
173});
174
175describe("docUpdateBranchName", () => {
176 it("honours an override", () => {
177 expect(docUpdateBranchName("README.md", "custom/branch")).toBe(
178 "custom/branch"
179 );
180 });
181
182 it("uses ai-doc-update/<basename>-<ts> by default", () => {
183 const name = docUpdateBranchName("docs/api/REFERENCE.md");
184 expect(name.startsWith("ai-doc-update/reference-")).toBe(true);
185 const ts = name.split("-").pop()!;
186 expect(/^\d+$/.test(ts)).toBe(true);
187 });
188});
189
190describe("buildDocUpdatePrompt", () => {
191 it("embeds the doc path, doc body, and every stale section's source", () => {
192 const out = buildDocUpdatePrompt({
193 docPath: "README.md",
194 docRaw: "# Title\n<!-- gluecron:doc-track src=a.ts -->old<!-- /gluecron:doc-track -->\n",
195 staleSections: [
196 {
197 marker: "abc",
198 claim: "old",
199 claimedFor: "a.ts",
200 sourceContent: "export const FRESH = 1;",
201 },
202 ],
203 });
204 expect(out).toContain("README.md");
205 expect(out).toContain("a.ts");
206 expect(out).toContain("export const FRESH = 1;");
207 expect(out).toContain('"patches"');
208 expect(out).toContain('"new_content"');
209 expect(out).toContain("doc-track");
210 });
211});
212
213describe("renderDocUpdatePrBody", () => {
214 it("includes the marker, label tag, and section list", () => {
215 const body = renderDocUpdatePrBody({
216 docPath: "README.md",
217 explanation: "Renamed signIn() to login().",
218 updatedSections: [
219 {
220 marker: "abc1234567890def",
221 claim: "old",
222 claimedFor: "src/lib/auth.ts",
223 currentSrcHash: "deadbeefdeadbeefdeadbeef",
224 storedClaimedHash: "cafebabecafebabecafebabe",
225 stale: true,
226 },
227 ],
228 });
229 expect(body).toContain(AI_DOC_UPDATE_MARKER);
230 expect(body).toContain(AI_DOC_UPDATE_LABEL);
231 expect(body).toContain("src/lib/auth.ts");
232 expect(body).toContain("Renamed signIn() to login().");
233 expect(body).toContain("cafebabecafe");
234 expect(body).toContain("deadbeefdead");
235 });
236
237 it("falls back when no explanation is provided", () => {
238 const body = renderDocUpdatePrBody({
239 docPath: "README.md",
240 explanation: "",
241 updatedSections: [],
242 });
243 expect(body).toContain("(no explanation provided)");
244 expect(body).toContain("(none)");
245 });
246});
247
248// ---------------------------------------------------------------------------
249// findTrackedDocs — drift detection against a real bare repo. Skipped
250// without DB because the lib needs to resolve a repositories row.
251// ---------------------------------------------------------------------------
252
253describe.skipIf(!HAS_DB)("findTrackedDocs — drift detection", () => {
254 it.skipIf(!HAS_DB)(
255 "treats first-time observations as fresh, second-time differing hashes as stale",
256 async () => {
257 const username = `aidoc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
258 const [u] = await db
259 .insert(users)
260 .values({
261 username,
262 email: `${username}@example.com`,
263 passwordHash: "x",
264 })
265 .returning({ id: users.id });
266 const repoName = `subject_${Date.now()}`;
267 const [r] = await db
268 .insert(repositories)
269 .values({
270 ownerId: u.id,
271 name: repoName,
272 diskPath: `/tmp/${username}/${repoName}`,
273 defaultBranch: "main",
274 })
275 .returning({ id: repositories.id });
276
277 await initBareRepo(username, repoName);
278
279 // Seed a README with one tracked region pointing at src/lib/auth.ts.
280 const readme = [
281 "# Project",
282 "",
283 "<!-- gluecron:doc-track src=src/lib/auth.ts -->",
284 "This module exports `signIn` and `signUp`.",
285 "<!-- /gluecron:doc-track -->",
286 ].join("\n");
287 const sourceV1 = "export function signIn() {}\nexport function signUp() {}\n";
288 const sourceV2 = "export function login() {}\nexport function register() {}\n";
289
290 let res = await createOrUpdateFileOnBranch({
291 owner: username,
292 name: repoName,
293 branch: "main",
294 filePath: "README.md",
295 bytes: new TextEncoder().encode(readme),
296 message: "seed readme",
297 authorName: "Seeder",
298 authorEmail: "s@e.com",
299 });
300 if ("error" in res) throw new Error("seed readme failed");
301
302 res = await createOrUpdateFileOnBranch({
303 owner: username,
304 name: repoName,
305 branch: "main",
306 filePath: "src/lib/auth.ts",
307 bytes: new TextEncoder().encode(sourceV1),
308 message: "seed source v1",
309 authorName: "Seeder",
310 authorEmail: "s@e.com",
311 });
312 if ("error" in res) throw new Error("seed source failed");
313
314 // First pass: no rows in doc_tracking → unseen → NOT stale.
315 const first = await findTrackedDocs(r.id);
316 expect(first.length).toBe(1);
317 expect(first[0].sections.length).toBe(1);
318 expect(first[0].sections[0].stale).toBe(false);
319 expect(first[0].sections[0].storedClaimedHash).toBeNull();
320 const seenHash = first[0].sections[0].currentSrcHash;
321 expect(seenHash).toBe(sha256Hex(sourceV1));
322
323 // Manually pin a baseline hash so the next compare has something to
324 // diff against — mimics what persistObservedSections does.
325 await db.insert(docTracking).values({
326 repositoryId: r.id,
327 docPath: first[0].path,
328 sectionMarker: first[0].sections[0].marker,
329 srcPath: first[0].sections[0].claimedFor,
330 claimedHash: seenHash,
331 });
332
333 // Push v2 of the source so its hash drifts.
334 res = await createOrUpdateFileOnBranch({
335 owner: username,
336 name: repoName,
337 branch: "main",
338 filePath: "src/lib/auth.ts",
339 bytes: new TextEncoder().encode(sourceV2),
340 message: "rename apis",
341 authorName: "Seeder",
342 authorEmail: "s@e.com",
343 });
344 if ("error" in res) throw new Error("update source failed");
345
346 const second = await findTrackedDocs(r.id);
347 expect(second.length).toBe(1);
348 expect(second[0].sections.length).toBe(1);
349 expect(second[0].sections[0].stale).toBe(true);
350 expect(second[0].sections[0].storedClaimedHash).toBe(seenHash);
351 expect(second[0].sections[0].currentSrcHash).toBe(sha256Hex(sourceV2));
352 },
353 20000
354 );
355});
356
357// ---------------------------------------------------------------------------
358// proposeDocUpdate — end-to-end with injected fake Claude
359// ---------------------------------------------------------------------------
360
361function fakeClient(responseText: string) {
362 return {
363 messages: {
364 create: async () => ({
365 content: [{ type: "text" as const, text: responseText }],
366 }),
367 },
368 } as any;
369}
370
371describe("proposeDocUpdate", () => {
372 it("returns null when no section is stale", async () => {
373 const out = await proposeDocUpdate({
374 repositoryId: "00000000-0000-0000-0000-000000000000",
375 path: "README.md",
376 sections: [
377 {
378 marker: "abc",
379 claim: "old",
380 claimedFor: "a.ts",
381 currentSrcHash: "h",
382 storedClaimedHash: "h",
383 stale: false,
384 },
385 ],
386 client: fakeClient("{}"),
387 });
388 expect(out).toBeNull();
389 });
390
391 it("returns null when Claude returns zero patches", async () => {
392 // Without DB the resolver returns null first → still null. The
393 // assertion holds in either case.
394 if (!HAS_DB) {
395 const out = await proposeDocUpdate({
396 repositoryId: "00000000-0000-0000-0000-000000000000",
397 path: "README.md",
398 sections: [
399 {
400 marker: "abc",
401 claim: "old",
402 claimedFor: "a.ts",
403 currentSrcHash: "h1",
404 storedClaimedHash: "h0",
405 stale: true,
406 },
407 ],
408 client: fakeClient('{"explanation":"all good","patches":[]}'),
409 });
410 expect(out).toBeNull();
411 return;
412 }
413
414 // HAS_DB path: insert a real repo + readme + source so we get past
415 // the resolver and into Claude. The empty patches array still
416 // short-circuits to null.
417 const username = `aidoc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
418 const [u] = await db
419 .insert(users)
420 .values({
421 username,
422 email: `${username}@example.com`,
423 passwordHash: "x",
424 })
425 .returning({ id: users.id });
426 const repoName = `empty_${Date.now()}`;
427 const [r] = await db
428 .insert(repositories)
429 .values({
430 ownerId: u.id,
431 name: repoName,
432 diskPath: `/tmp/${username}/${repoName}`,
433 defaultBranch: "main",
434 })
435 .returning({ id: repositories.id });
436
437 await initBareRepo(username, repoName);
438 const seeded1 = await createOrUpdateFileOnBranch({
439 owner: username,
440 name: repoName,
441 branch: "main",
442 filePath: "README.md",
443 bytes: new TextEncoder().encode("# r\n<!-- gluecron:doc-track src=a.ts -->old<!-- /gluecron:doc-track -->\n"),
444 message: "seed",
445 authorName: "Seeder",
446 authorEmail: "s@e.com",
447 });
448 if ("error" in seeded1) throw new Error("seed readme failed");
449 const seeded2 = await createOrUpdateFileOnBranch({
450 owner: username,
451 name: repoName,
452 branch: "main",
453 filePath: "a.ts",
454 bytes: new TextEncoder().encode("export const X = 1;\n"),
455 message: "seed source",
456 authorName: "Seeder",
457 authorEmail: "s@e.com",
458 });
459 if ("error" in seeded2) throw new Error("seed source failed");
460
461 const out = await proposeDocUpdate({
462 repositoryId: r.id,
463 path: "README.md",
464 sections: [
465 {
466 marker: "abc",
467 claim: "old",
468 claimedFor: "a.ts",
469 currentSrcHash: "h1",
470 storedClaimedHash: "h0",
471 stale: true,
472 },
473 ],
474 client: fakeClient('{"explanation":"all good","patches":[]}'),
475 });
476 expect(out).toBeNull();
477
478 const prs = await db
479 .select({ number: pullRequests.number })
480 .from(pullRequests)
481 .where(eq(pullRequests.repositoryId, r.id));
482 expect(prs.length).toBe(0);
483 });
484
485 it.skipIf(!HAS_DB)(
486 "opens a PR with the refreshed markdown when Claude returns a patch",
487 async () => {
488 const username = `aidoc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
489 const [u] = await db
490 .insert(users)
491 .values({
492 username,
493 email: `${username}@example.com`,
494 passwordHash: "x",
495 })
496 .returning({ id: users.id });
497 const repoName = `subject_${Date.now()}`;
498 const [r] = await db
499 .insert(repositories)
500 .values({
501 ownerId: u.id,
502 name: repoName,
503 diskPath: `/tmp/${username}/${repoName}`,
504 defaultBranch: "main",
505 })
506 .returning({ id: repositories.id });
507
508 await initBareRepo(username, repoName);
509 const readme = [
510 "# Project",
511 "",
512 "<!-- gluecron:doc-track src=src/lib/auth.ts -->",
513 "This module exports `signIn` and `signUp`.",
514 "<!-- /gluecron:doc-track -->",
515 ].join("\n");
516 const refreshed = [
517 "# Project",
518 "",
519 "<!-- gluecron:doc-track src=src/lib/auth.ts -->",
520 "This module exports `login` and `register`.",
521 "<!-- /gluecron:doc-track -->",
522 ].join("\n");
523
524 const s1 = await createOrUpdateFileOnBranch({
525 owner: username,
526 name: repoName,
527 branch: "main",
528 filePath: "README.md",
529 bytes: new TextEncoder().encode(readme),
530 message: "seed readme",
531 authorName: "Seeder",
532 authorEmail: "s@e.com",
533 });
534 if ("error" in s1) throw new Error("seed readme failed");
535 const s2 = await createOrUpdateFileOnBranch({
536 owner: username,
537 name: repoName,
538 branch: "main",
539 filePath: "src/lib/auth.ts",
540 bytes: new TextEncoder().encode(
541 "export function login() {}\nexport function register() {}\n"
542 ),
543 message: "seed source",
544 authorName: "Seeder",
545 authorEmail: "s@e.com",
546 });
547 if ("error" in s2) throw new Error("seed source failed");
548
549 const canned = JSON.stringify({
550 explanation: "Renamed signIn/signUp to login/register to match source.",
551 patches: [{ path: "README.md", new_content: refreshed }],
552 });
553 const branchOverride = `ai-doc-update/test-${Date.now()}`;
554 const out = await proposeDocUpdate({
555 repositoryId: r.id,
556 path: "README.md",
557 sections: [
558 {
559 marker: "marker-test",
560 claim: "This module exports `signIn` and `signUp`.",
561 claimedFor: "src/lib/auth.ts",
562 currentSrcHash: sha256Hex(
563 "export function login() {}\nexport function register() {}\n"
564 ),
565 storedClaimedHash: sha256Hex(
566 "export function signIn() {}\nexport function signUp() {}\n"
567 ),
568 stale: true,
569 },
570 ],
571 client: fakeClient(canned),
572 branchOverride,
573 });
574 expect(out).not.toBeNull();
575 expect(out!.branch).toBe(branchOverride);
576 expect(typeof out!.prNumber).toBe("number");
577 expect(out!.updatedSections).toBe(1);
578
579 // Branch exists in the bare repo.
580 expect(
581 await refExists(username, repoName, `refs/heads/${branchOverride}`)
582 ).toBe(true);
583
584 // README on the branch contains the refreshed prose.
585 const blob = await getBlob(
586 username,
587 repoName,
588 branchOverride,
589 "README.md"
590 );
591 expect(blob).not.toBeNull();
592 expect(blob!.content).toContain("login");
593 expect(blob!.content).toContain("register");
594 expect(blob!.content).not.toContain("signIn");
595
596 // PR row exists with the right base/head.
597 const [pr] = await db
598 .select({
599 number: pullRequests.number,
600 headBranch: pullRequests.headBranch,
601 baseBranch: pullRequests.baseBranch,
602 body: pullRequests.body,
603 })
604 .from(pullRequests)
605 .where(eq(pullRequests.repositoryId, r.id))
606 .limit(1);
607 expect(pr).toBeTruthy();
608 expect(pr!.headBranch).toBe(branchOverride);
609 expect(pr!.baseBranch).toBe("main");
610 expect(pr!.body).toContain(AI_DOC_UPDATE_MARKER);
611 expect(pr!.body).toContain(AI_DOC_UPDATE_LABEL);
612
613 // doc_tracking row was upserted with the new hash + PR id.
614 const tracked = await db
615 .select({
616 claimedHash: docTracking.claimedHash,
617 lastPrId: docTracking.lastPrId,
618 })
619 .from(docTracking)
620 .where(eq(docTracking.repositoryId, r.id));
621 expect(tracked.length).toBeGreaterThan(0);
622 expect(tracked[0].lastPrId).toBeTruthy();
623 },
624 25000
625 );
626});
627
628// ---------------------------------------------------------------------------
629// Internal helpers — sanity checks against bogus inputs.
630// ---------------------------------------------------------------------------
631
632describe("__test internals", () => {
633 it("exports the documented helpers", () => {
634 expect(typeof __test.resolveRepoMeta).toBe("function");
635 expect(typeof __test.listMarkdownFiles).toBe("function");
636 expect(typeof __test.ensureDocUpdateLabel).toBe("function");
637 expect(typeof __test.askClaudeForDocPatch).toBe("function");
638 expect(typeof __test.seedBranchFromDefault).toBe("function");
639 });
640
641 it("askClaudeForDocPatch tolerates an invalid JSON envelope", async () => {
642 const out = await __test.askClaudeForDocPatch(
643 fakeClient("not json at all"),
644 "prompt"
645 );
646 expect(out).toBeNull();
647 });
648});
Modifiedsrc/db/schema.ts+43−0View fileUnifiedSplit
33933393
33943394export type PrSandbox = typeof prSandboxes.$inferSelect;
33953395export type NewPrSandbox = typeof prSandboxes.$inferInsert;
3396
3397// ---------------------------------------------------------------------------
3398// 0068 — AI-tracked documentation sections. See src/lib/ai-doc-updater.ts.
3399//
3400// Stores the currently claimed source-file hash for each
3401// `<!-- gluecron:doc-track src=... -->` region in a tracked markdown file.
3402// The post-receive hook re-hashes the referenced source after every push
3403// and proposes a PR (tagged `ai:doc-update`) when the prose drifts from
3404// the truth.
3405// ---------------------------------------------------------------------------
3406export const docTracking = pgTable(
3407 "doc_tracking",
3408 {
3409 id: uuid("id").primaryKey().defaultRandom(),
3410 repositoryId: uuid("repository_id")
3411 .notNull()
3412 .references(() => repositories.id, { onDelete: "cascade" }),
3413 docPath: text("doc_path").notNull(),
3414 sectionMarker: text("section_marker").notNull(),
3415 srcPath: text("src_path").notNull(),
3416 claimedHash: text("claimed_hash").notNull(),
3417 lastCheckedAt: timestamp("last_checked_at", { withTimezone: true })
3418 .defaultNow()
3419 .notNull(),
3420 lastPrId: uuid("last_pr_id").references(() => pullRequests.id, {
3421 onDelete: "set null",
3422 }),
3423 createdAt: timestamp("created_at", { withTimezone: true })
3424 .defaultNow()
3425 .notNull(),
3426 },
3427 (table) => [
3428 uniqueIndex("doc_tracking_repo_doc_marker").on(
3429 table.repositoryId,
3430 table.docPath,
3431 table.sectionMarker
3432 ),
3433 index("doc_tracking_repo").on(table.repositoryId),
3434 ]
3435);
3436
3437export type DocTracking = typeof docTracking.$inferSelect;
3438export type NewDocTracking = typeof docTracking.$inferInsert;
Modifiedsrc/hooks/post-receive.ts+43−0View fileUnifiedSplit
2626} from "../git/repository";
2727import { indexChangedFiles } from "../lib/semantic-index";
2828import { enqueuePreviewBuild } from "../lib/branch-previews";
29import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
2930
3031interface PushRef {
3132 oldSha: string;
116117 console.warn("[branch-previews] dispatch error:", err)
117118 );
118119
120 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
121 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
122 // regions, hashes the referenced source, and opens a PR labelled
123 // `ai:doc-update` when the prose drifts. Fire-and-forget; failures
124 // are swallowed inside ai-doc-updater.ts so a missing anthropic key
125 // or empty doc_tracking table never breaks the push.
126 void fireDocDriftCheck(owner, repo).catch((err) =>
127 console.warn("[ai-doc-updater] dispatch error:", err)
128 );
129
119130 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
120131 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
121132 // default branch. The branch case (`Main` vs `main`) is determined by
564575 }
565576}
566577
578/**
579 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
580 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
581 * on missing repo or DB error — pushes never block. Never throws.
582 */
583async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
584 let repositoryId = "";
585 try {
586 const [row] = await db
587 .select({ id: repositories.id })
588 .from(repositories)
589 .innerJoin(users, eq(repositories.ownerId, users.id))
590 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
591 .limit(1);
592 repositoryId = row?.id || "";
593 } catch {
594 return;
595 }
596 if (!repositoryId) return;
597 try {
598 const out = await runDocDriftCheckForRepo(repositoryId);
599 if (out.docs > 0 || out.proposed > 0) {
600 console.log(
601 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
602 );
603 }
604 } catch (err) {
605 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
606 }
607}
608
567609/** Test-only access to internal helpers. */
568610export const __test = {
569611 triggerCrontechDeploy,
573615 listChangedPaths,
574616 fireSemanticIndex,
575617 firePreviewBuilds,
618 fireDocDriftCheck,
576619};
Addedsrc/lib/advancement-scanner.ts+1123−0View fileUnifiedSplit
Large file (1,123 lines). Load full file
Addedsrc/lib/ai-doc-updater.ts+944−0View fileUnifiedSplit
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
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};
Addedsrc/routes/admin-advancement.tsx+1060−0View fileUnifiedSplit
Large file (1,060 lines). Load full file
Addedsrc/routes/docs-tracking.tsx+501−0View fileUnifiedSplit
1/**
2 * AI-tracked documentation sections — staleness dashboard.
3 *
4 * GET /:owner/:repo/docs/tracking
5 *
6 * Lists every `<!-- gluecron:doc-track src=... -->` region we know about
7 * in the repo, alongside its source hash, last-checked timestamp, and a
8 * "Stale / Fresh / Unseen" pill. Empty state when nothing is tracked yet.
9 *
10 * All page-local CSS is scoped under `.doctrk-*` so it cannot bleed into
11 * the shared layout (per CLAUDE.md: do NOT modify shared layout /
12 * components / ui). Mirrors the gradient hairline + orb pattern used by
13 * previews.tsx and environments.tsx.
14 */
15
16import { Hono } from "hono";
17import { and, eq } from "drizzle-orm";
18import { db } from "../db";
19import { docTracking, pullRequests, repositories, users } from "../db/schema";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24import { getUnreadCount } from "../lib/unread";
25import { findTrackedDocs, type TrackedDoc } from "../lib/ai-doc-updater";
26
27const r = new Hono<AuthEnv>();
28r.use("*", softAuth);
29
30interface RepoRow {
31 id: string;
32 name: string;
33 defaultBranch: string;
34 starCount: number;
35 forkCount: number;
36}
37
38async function loadRepo(owner: string, repo: string): Promise<RepoRow | null> {
39 try {
40 const [row] = await db
41 .select({
42 id: repositories.id,
43 name: repositories.name,
44 defaultBranch: repositories.defaultBranch,
45 starCount: repositories.starCount,
46 forkCount: repositories.forkCount,
47 })
48 .from(repositories)
49 .innerJoin(users, eq(repositories.ownerId, users.id))
50 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
51 .limit(1);
52 return row || null;
53 } catch (err) {
54 console.error("[docs-tracking] loadRepo failed:", err);
55 return null;
56 }
57}
58
59interface StoredRow {
60 docPath: string;
61 sectionMarker: string;
62 lastCheckedAt: Date;
63 lastPrId: string | null;
64 prNumber: number | null;
65 prState: string | null;
66}
67
68async function loadStored(repositoryId: string): Promise<StoredRow[]> {
69 try {
70 const rows = await db
71 .select({
72 docPath: docTracking.docPath,
73 sectionMarker: docTracking.sectionMarker,
74 lastCheckedAt: docTracking.lastCheckedAt,
75 lastPrId: docTracking.lastPrId,
76 prNumber: pullRequests.number,
77 prState: pullRequests.state,
78 })
79 .from(docTracking)
80 .leftJoin(pullRequests, eq(pullRequests.id, docTracking.lastPrId))
81 .where(eq(docTracking.repositoryId, repositoryId));
82 return rows.map((r) => ({
83 docPath: r.docPath,
84 sectionMarker: r.sectionMarker,
85 lastCheckedAt: r.lastCheckedAt,
86 lastPrId: r.lastPrId,
87 prNumber: r.prNumber,
88 prState: r.prState,
89 }));
90 } catch {
91 return [];
92 }
93}
94
95/* ─────────────────────────────────────────────────────────────────────────
96 * Scoped CSS — `.doctrk-*` only. Mirrors `.preview-*` styling so the
97 * dashboard feels at home alongside the other repo sub-pages.
98 * ───────────────────────────────────────────────────────────────────── */
99const docTrackingStyles = `
100 .doctrk-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
101
102 .doctrk-head {
103 position: relative;
104 margin-bottom: var(--space-5);
105 padding: var(--space-4) var(--space-5);
106 background: var(--bg-elevated);
107 border: 1px solid var(--border);
108 border-radius: 14px;
109 overflow: hidden;
110 }
111 .doctrk-head::before {
112 content: '';
113 position: absolute;
114 top: 0; left: 0; right: 0;
115 height: 2px;
116 background: linear-gradient(90deg, transparent 0%, #36c5d6 30%, #8c6dff 70%, transparent 100%);
117 opacity: 0.7;
118 pointer-events: none;
119 }
120 .doctrk-head-orb {
121 position: absolute;
122 inset: -30% -10% auto auto;
123 width: 320px; height: 320px;
124 background: radial-gradient(circle, rgba(54,197,214,0.18), rgba(140,109,255,0.08) 45%, transparent 70%);
125 filter: blur(70px);
126 opacity: 0.7;
127 pointer-events: none;
128 z-index: 0;
129 }
130 .doctrk-head-inner { position: relative; z-index: 1; }
131
132 .doctrk-eyebrow {
133 display: inline-flex;
134 align-items: center;
135 gap: 8px;
136 text-transform: uppercase;
137 font-family: var(--font-mono);
138 font-size: 11px;
139 letter-spacing: 0.16em;
140 color: var(--text-muted);
141 font-weight: 600;
142 margin-bottom: 10px;
143 }
144 .doctrk-eyebrow-dot {
145 width: 8px; height: 8px;
146 border-radius: 9999px;
147 background: linear-gradient(135deg, #36c5d6, #8c6dff);
148 box-shadow: 0 0 0 3px rgba(54,197,214,0.18);
149 }
150 .doctrk-title {
151 font-family: var(--font-display);
152 font-size: 28px;
153 font-weight: 700;
154 color: var(--text-strong);
155 margin: 0 0 6px;
156 letter-spacing: -0.02em;
157 }
158 .doctrk-sub {
159 color: var(--text-muted);
160 margin: 0;
161 max-width: 70ch;
162 line-height: 1.5;
163 }
164
165 .doctrk-list {
166 display: flex;
167 flex-direction: column;
168 gap: var(--space-3);
169 }
170 .doctrk-doc {
171 border: 1px solid var(--border);
172 border-radius: 12px;
173 background: var(--bg-elevated);
174 padding: var(--space-3) var(--space-4);
175 }
176 .doctrk-doc-head {
177 display: flex;
178 justify-content: space-between;
179 align-items: center;
180 gap: var(--space-3);
181 margin-bottom: var(--space-2);
182 }
183 .doctrk-doc-path {
184 font-family: var(--font-mono);
185 font-size: 14px;
186 font-weight: 700;
187 color: var(--text-strong);
188 word-break: break-all;
189 }
190 .doctrk-section {
191 padding: 10px 12px;
192 border: 1px solid var(--border);
193 border-radius: 8px;
194 background: rgba(255,255,255,0.02);
195 margin-top: 8px;
196 }
197 .doctrk-section-head {
198 display: flex;
199 justify-content: space-between;
200 align-items: center;
201 gap: var(--space-2);
202 margin-bottom: 6px;
203 flex-wrap: wrap;
204 }
205 .doctrk-section-src {
206 font-family: var(--font-mono);
207 font-size: 12.5px;
208 color: var(--text);
209 }
210 .doctrk-section-claim {
211 font-size: 13px;
212 color: var(--text-muted);
213 line-height: 1.45;
214 white-space: pre-wrap;
215 max-height: 4.5em;
216 overflow: hidden;
217 text-overflow: ellipsis;
218 }
219 .doctrk-section-meta {
220 margin-top: 6px;
221 font-size: 11.5px;
222 color: var(--text-muted);
223 font-variant-numeric: tabular-nums;
224 display: flex;
225 gap: var(--space-3);
226 flex-wrap: wrap;
227 }
228 .doctrk-section-meta code {
229 font-family: var(--font-mono);
230 background: rgba(255,255,255,0.04);
231 padding: 1px 6px;
232 border-radius: 6px;
233 }
234 .doctrk-section-meta a {
235 color: #b69dff;
236 text-decoration: none;
237 }
238 .doctrk-section-meta a:hover { text-decoration: underline; }
239
240 /* ─── status pills ─── */
241 .doctrk-pill {
242 display: inline-flex;
243 align-items: center;
244 gap: 5px;
245 padding: 2px 9px;
246 border-radius: 9999px;
247 font-size: 10.5px;
248 font-weight: 600;
249 letter-spacing: 0.05em;
250 text-transform: uppercase;
251 font-family: var(--font-mono);
252 background: rgba(255,255,255,0.04);
253 color: var(--text-muted);
254 box-shadow: inset 0 0 0 1px var(--border);
255 }
256 .doctrk-pill.is-stale {
257 background: rgba(251,191,36,0.10);
258 color: #fde68a;
259 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
260 }
261 .doctrk-pill.is-fresh {
262 background: rgba(52,211,153,0.10);
263 color: #6ee7b7;
264 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
265 }
266 .doctrk-pill.is-unseen {
267 background: rgba(148,163,184,0.10);
268 color: #cbd5e1;
269 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
270 }
271 .doctrk-pill.is-missing {
272 background: rgba(248,113,113,0.10);
273 color: #fecaca;
274 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.35);
275 }
276 .doctrk-pill-dot {
277 width: 6px; height: 6px;
278 border-radius: 9999px;
279 background: currentColor;
280 }
281
282 /* ─── empty state ─── */
283 .doctrk-empty {
284 position: relative;
285 padding: var(--space-6) var(--space-5);
286 background: var(--bg-elevated);
287 border: 1px dashed var(--border-strong, var(--border));
288 border-radius: 14px;
289 text-align: center;
290 overflow: hidden;
291 margin-bottom: var(--space-5);
292 }
293 .doctrk-empty-orb {
294 position: absolute;
295 inset: auto auto -40% 50%;
296 transform: translateX(-50%);
297 width: 320px; height: 320px;
298 background: radial-gradient(circle, rgba(54,197,214,0.18), rgba(140,109,255,0.08) 45%, transparent 70%);
299 filter: blur(70px);
300 opacity: 0.7;
301 pointer-events: none;
302 }
303 .doctrk-empty-inner { position: relative; z-index: 1; max-width: 540px; margin: 0 auto; }
304 .doctrk-empty-title {
305 font-family: var(--font-display);
306 font-size: 16px;
307 font-weight: 700;
308 color: var(--text-strong);
309 margin: 0 0 6px;
310 }
311 .doctrk-empty-body {
312 font-size: 13.5px;
313 color: var(--text-muted);
314 line-height: 1.5;
315 margin: 0 0 var(--space-3);
316 }
317 .doctrk-empty code, .doctrk-codeblock {
318 font-family: var(--font-mono);
319 font-size: 12px;
320 color: var(--text);
321 background: rgba(255,255,255,0.06);
322 padding: 1px 6px;
323 border-radius: 6px;
324 }
325 .doctrk-codeblock {
326 display: block;
327 padding: 10px 14px;
328 white-space: pre;
329 text-align: left;
330 margin: var(--space-3) auto;
331 max-width: 540px;
332 overflow-x: auto;
333 }
334`;
335
336function statusFor(
337 stored: StoredRow | undefined,
338 section: { stale: boolean; currentSrcHash: string }
339): { label: string; cls: string } {
340 if (section.currentSrcHash.startsWith("missing:")) {
341 return { label: "missing source", cls: "is-missing" };
342 }
343 if (!stored) {
344 return { label: "unseen", cls: "is-unseen" };
345 }
346 if (section.stale) {
347 return { label: "stale", cls: "is-stale" };
348 }
349 return { label: "fresh", cls: "is-fresh" };
350}
351
352r.get("/:owner/:repo/docs/tracking", async (c) => {
353 const user = c.get("user");
354 const { owner, repo } = c.req.param();
355 const repoRow = await loadRepo(owner, repo);
356 if (!repoRow) return c.notFound();
357
358 const [docs, stored, unread] = await Promise.all([
359 findTrackedDocs(repoRow.id).catch(() => [] as TrackedDoc[]),
360 loadStored(repoRow.id),
361 user ? getUnreadCount(user.id) : Promise.resolve(0),
362 ]);
363
364 const storedByKey = new Map<string, StoredRow>();
365 for (const row of stored) {
366 storedByKey.set(`${row.docPath}::${row.sectionMarker}`, row);
367 }
368
369 const totalSections = docs.reduce((a, d) => a + d.sections.length, 0);
370
371 return c.html(
372 <Layout
373 title={`Tracked docs — ${owner}/${repo}`}
374 user={user}
375 notificationCount={unread}
376 >
377 <RepoHeader
378 owner={owner}
379 repo={repo}
380 starCount={repoRow.starCount}
381 forkCount={repoRow.forkCount}
382 currentUser={user?.username}
383 />
384 <RepoNav owner={owner} repo={repo} active="code" />
385
386 <div class="doctrk-wrap">
387 <section class="doctrk-head">
388 <div class="doctrk-head-orb" aria-hidden="true" />
389 <div class="doctrk-head-inner">
390 <div class="doctrk-eyebrow">
391 <span class="doctrk-eyebrow-dot" aria-hidden="true" />
392 AI-tracked docs · {owner}/{repo}
393 </div>
394 <h2 class="doctrk-title">Documentation drift</h2>
395 <p class="doctrk-sub">
396 Every markdown region wrapped in{" "}
397 <code>&lt;!-- gluecron:doc-track src=... --&gt;</code> markers is
398 hashed against the source it claims to describe. When the
399 source changes, Claude opens a draft PR labelled{" "}
400 <code>ai:doc-update</code> with the refreshed prose.
401 </p>
402 </div>
403 </section>
404
405 {docs.length === 0 ? (
406 <div class="doctrk-empty">
407 <div class="doctrk-empty-orb" aria-hidden="true" />
408 <div class="doctrk-empty-inner">
409 <h3 class="doctrk-empty-title">No tracked sections yet</h3>
410 <p class="doctrk-empty-body">
411 Wrap a paragraph in a markdown file with the marker below
412 and Claude will keep it in sync with the source whenever
413 you push.
414 </p>
415 <code class="doctrk-codeblock">{`<!-- gluecron:doc-track src=src/lib/auth.ts -->
416This module exports \`signIn\` and \`signUp\` —
417see the source for details.
418<!-- /gluecron:doc-track -->`}</code>
419 </div>
420 </div>
421 ) : (
422 <>
423 <p class="doctrk-sub" style="margin-bottom: var(--space-3);">
424 {docs.length} doc{docs.length === 1 ? "" : "s"} · {totalSections}{" "}
425 tracked section{totalSections === 1 ? "" : "s"}
426 </p>
427 <div class="doctrk-list">
428 {docs.map((d) => (
429 <div class="doctrk-doc">
430 <div class="doctrk-doc-head">
431 <div class="doctrk-doc-path">{d.path}</div>
432 <div>
433 <span class="doctrk-pill">
434 <span class="doctrk-pill-dot" aria-hidden="true" />
435 {d.sections.length} section
436 {d.sections.length === 1 ? "" : "s"}
437 </span>
438 </div>
439 </div>
440 {d.sections.map((s) => {
441 const key = `${d.path}::${s.marker}`;
442 const storedRow = storedByKey.get(key);
443 const status = statusFor(storedRow, s);
444 const checkedLabel = storedRow
445 ? new Date(storedRow.lastCheckedAt).toISOString()
446 : "never";
447 return (
448 <div class="doctrk-section">
449 <div class="doctrk-section-head">
450 <div class="doctrk-section-src">
451 tracks <code>{s.claimedFor}</code>
452 </div>
453 <span class={`doctrk-pill ${status.cls}`}>
454 <span class="doctrk-pill-dot" aria-hidden="true" />
455 {status.label}
456 </span>
457 </div>
458 <div class="doctrk-section-claim">{s.claim}</div>
459 <div class="doctrk-section-meta">
460 <span>
461 marker <code>{s.marker}</code>
462 </span>
463 <span>
464 current{" "}
465 <code>{s.currentSrcHash.slice(0, 12)}</code>
466 </span>
467 <span>
468 stored{" "}
469 <code>
470 {(s.storedClaimedHash ?? "—").slice(0, 12)}
471 </code>
472 </span>
473 <span>last checked {checkedLabel}</span>
474 {storedRow?.prNumber ? (
475 <span>
476 PR{" "}
477 <a
478 href={`/${owner}/${repo}/pulls/${storedRow.prNumber}`}
479 >
480 #{storedRow.prNumber}
481 </a>{" "}
482 ({storedRow.prState ?? "?"})
483 </span>
484 ) : null}
485 </div>
486 </div>
487 );
488 })}
489 </div>
490 ))}
491 </div>
492 </>
493 )}
494 </div>
495
496 <style dangerouslySetInnerHTML={{ __html: docTrackingStyles }} />
497 </Layout>
498 );
499});
500
501export default r;
0502