CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
docs-tracking.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| d199847 | 1 | /** |
| 2 | * AI-tracked documentation sections — 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 | ||
| 16 | import { Hono } from "hono"; | |
| 17 | import { and, eq } from "drizzle-orm"; | |
| 18 | import { db } from "../db"; | |
| 19 | import { docTracking, pullRequests, repositories, users } from "../db/schema"; | |
| 20 | import { softAuth } from "../middleware/auth"; | |
| 21 | import type { AuthEnv } from "../middleware/auth"; | |
| 22 | import { Layout } from "../views/layout"; | |
| 23 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 24 | import { getUnreadCount } from "../lib/unread"; | |
| 25 | import { findTrackedDocs, type TrackedDoc } from "../lib/ai-doc-updater"; | |
| 26 | ||
| 27 | const r = new Hono<AuthEnv>(); | |
| 28 | r.use("*", softAuth); | |
| 29 | ||
| 30 | interface RepoRow { | |
| 31 | id: string; | |
| 32 | name: string; | |
| 33 | defaultBranch: string; | |
| 34 | starCount: number; | |
| 35 | forkCount: number; | |
| 36 | } | |
| 37 | ||
| 38 | async 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 | ||
| 59 | interface StoredRow { | |
| 60 | docPath: string; | |
| 61 | sectionMarker: string; | |
| 62 | lastCheckedAt: Date; | |
| 63 | lastPrId: string | null; | |
| 64 | prNumber: number | null; | |
| 65 | prState: string | null; | |
| 66 | } | |
| 67 | ||
| 68 | async 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| 99 | const docTrackingStyles = ` | |
| eed4684 | 100 | .doctrk-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); } |
| d199847 | 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; | |
| 6fd5915 | 116 | background: linear-gradient(90deg, transparent 0%, #5f8fa0 30%, #5b6ee8 70%, transparent 100%); |
| d199847 | 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; | |
| 6fd5915 | 124 | background: radial-gradient(circle, rgba(95,143,160,0.18), rgba(91,110,232,0.08) 45%, transparent 70%); |
| d199847 | 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; | |
| 6fd5915 | 147 | background: linear-gradient(135deg, #5f8fa0, #5b6ee8); |
| 148 | box-shadow: 0 0 0 3px rgba(95,143,160,0.18); | |
| d199847 | 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 { | |
| 6fd5915 | 235 | color: #5b6ee8; |
| d199847 | 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; | |
| 6fd5915 | 298 | background: radial-gradient(circle, rgba(95,143,160,0.18), rgba(91,110,232,0.08) 45%, transparent 70%); |
| d199847 | 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 | ||
| 336 | function 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 | ||
| 352 | r.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><!-- gluecron:doc-track src=... --></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 --> | |
| 416 | This module exports \`signIn\` and \`signUp\` — | |
| 417 | see 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 | ||
| 501 | export default r; |