CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
insights.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.
| 16b325c | 1 | /** |
| 2 | * Insight routes — time-travel, dependency analysis, rollback. | |
| 3 | * | |
| 4 | * These are the pages that don't exist on GitHub. | |
| 5 | * This is why developers will switch. | |
| 6 | */ | |
| 7 | ||
| 8 | import { Hono } from "hono"; | |
| 9 | import { Layout } from "../views/layout"; | |
| 10 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 11 | import { | |
| 12 | getFileTimeline, | |
| 13 | getFunctionTimeline, | |
| 14 | detectCoupledFiles, | |
| 15 | getRepoStory, | |
| 16 | } from "../lib/timetravel"; | |
| 17 | import { | |
| 18 | buildImportGraph, | |
| 19 | analyzeUpgradeImpact, | |
| 20 | findUnusedDeps, | |
| 21 | } from "../lib/depimpact"; | |
| 22 | import { findRollbackTarget, executeRollback } from "../lib/rollback"; | |
| 23 | import { | |
| 24 | repoExists, | |
| 25 | getDefaultBranch, | |
| 26 | listBranches, | |
| 27 | } from "../git/repository"; | |
| 28 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 29 | import type { AuthEnv } from "../middleware/auth"; | |
| 30 | ||
| 31 | const insights = new Hono<AuthEnv>(); | |
| 32 | ||
| 33 | insights.use("*", softAuth); | |
| 34 | ||
| 35 | // ─── TIME TRAVEL ───────────────────────────────────────────── | |
| 36 | ||
| 37 | // File evolution timeline | |
| 38 | insights.get("/:owner/:repo/timeline/:ref{.+$}", async (c) => { | |
| 39 | const { owner, repo } = c.req.param(); | |
| 40 | const user = c.get("user"); | |
| 41 | const refAndPath = c.req.param("ref"); | |
| 42 | ||
| 43 | const branches = await listBranches(owner, repo); | |
| 44 | let ref = ""; | |
| 45 | let filePath = ""; | |
| 46 | ||
| 47 | for (const branch of branches) { | |
| 48 | if (refAndPath.startsWith(branch + "/")) { | |
| 49 | ref = branch; | |
| 50 | filePath = refAndPath.slice(branch.length + 1); | |
| 51 | break; | |
| 52 | } | |
| 53 | } | |
| 54 | if (!ref) { | |
| 55 | const idx = refAndPath.indexOf("/"); | |
| 56 | if (idx === -1) return c.notFound(); | |
| 57 | ref = refAndPath.slice(0, idx); | |
| 58 | filePath = refAndPath.slice(idx + 1); | |
| 59 | } | |
| 60 | ||
| 61 | const timeline = await getFileTimeline(owner, repo, ref, filePath); | |
| 62 | if (!timeline) return c.notFound(); | |
| 63 | ||
| 64 | return c.html( | |
| 65 | <Layout title={`Timeline: ${filePath} — ${owner}/${repo}`} user={user}> | |
| 66 | <RepoHeader owner={owner} repo={repo} /> | |
| 67 | <RepoNav owner={owner} repo={repo} active="code" /> | |
| 68 | <h2 style="margin-bottom: 4px"> | |
| 69 | Time Travel: {filePath} | |
| 70 | </h2> | |
| 71 | <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px"> | |
| 72 | {timeline.totalRevisions} revision{timeline.totalRevisions !== 1 ? "s" : ""} | First seen{" "} | |
| 73 | {new Date(timeline.firstSeen.date).toLocaleDateString()} by {timeline.firstSeen.author} | |
| 74 | </p> | |
| 75 | ||
| 76 | <div class="timeline"> | |
| 77 | {timeline.revisions.map((rev, i) => ( | |
| 78 | <div class="timeline-item"> | |
| 79 | <div class="timeline-dot" /> | |
| 80 | <div class="timeline-content"> | |
| 81 | <div style="display: flex; justify-content: space-between; align-items: start"> | |
| 82 | <div> | |
| 83 | <a | |
| 84 | href={`/${owner}/${repo}/commit/${rev.sha}`} | |
| 85 | style="font-weight: 600; font-size: 14px" | |
| 86 | > | |
| 87 | {rev.message} | |
| 88 | </a> | |
| 89 | <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px"> | |
| 90 | {rev.author} — {new Date(rev.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} | |
| 91 | </div> | |
| 92 | </div> | |
| 93 | <div style="text-align: right; font-size: 12px; white-space: nowrap"> | |
| 94 | <span class="stat-add">+{rev.linesAdded}</span>{" "} | |
| 95 | <span class="stat-del">-{rev.linesRemoved}</span> | |
| 96 | <div style="color: var(--text-muted)">{rev.sizeAfter} bytes</div> | |
| 97 | </div> | |
| 98 | </div> | |
| 99 | </div> | |
| 100 | </div> | |
| 101 | ))} | |
| 102 | </div> | |
| 103 | </Layout> | |
| 104 | ); | |
| 105 | }); | |
| 106 | ||
| 107 | // Coupled files analysis | |
| 108 | insights.get("/:owner/:repo/coupling", async (c) => { | |
| 109 | const { owner, repo } = c.req.param(); | |
| 110 | const user = c.get("user"); | |
| 111 | ||
| 112 | if (!(await repoExists(owner, repo))) return c.notFound(); | |
| 113 | const ref = (await getDefaultBranch(owner, repo)) || "main"; | |
| 114 | ||
| 115 | const coupled = await detectCoupledFiles(owner, repo, ref); | |
| 116 | const story = await getRepoStory(owner, repo, ref); | |
| 117 | const milestones = story.filter((s) => s.significance !== "normal").slice(0, 20); | |
| 118 | ||
| 119 | return c.html( | |
| 120 | <Layout title={`Insights — ${owner}/${repo}`} user={user}> | |
| 121 | <RepoHeader owner={owner} repo={repo} /> | |
| 122 | <RepoNav owner={owner} repo={repo} active="code" /> | |
| 123 | <h2 style="margin-bottom: 20px">Code Insights</h2> | |
| 124 | ||
| 125 | <h3 style="margin-bottom: 12px">Coupled Files</h3> | |
| 126 | <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 12px"> | |
| 127 | Files that change together frequently — potential architectural coupling | |
| 128 | </p> | |
| 129 | {coupled.length === 0 ? ( | |
| 130 | <p style="color: var(--text-muted)">No strong coupling detected.</p> | |
| 131 | ) : ( | |
| 132 | <div class="issue-list" style="margin-bottom: 32px"> | |
| 133 | {coupled.map((pair) => ( | |
| 134 | <div class="issue-item"> | |
| 135 | <div style="font-size: 13px; font-family: var(--font-mono)"> | |
| 136 | <a href={`/${owner}/${repo}/blob/${ref}/${pair.files[0]}`}> | |
| 137 | {pair.files[0]} | |
| 138 | </a> | |
| 139 | <span style="color: var(--text-muted); margin: 0 8px">+</span> | |
| 140 | <a href={`/${owner}/${repo}/blob/${ref}/${pair.files[1]}`}> | |
| 141 | {pair.files[1]} | |
| 142 | </a> | |
| 143 | </div> | |
| 144 | <div style="font-size: 12px; color: var(--text-muted); white-space: nowrap"> | |
| 145 | {pair.cochanges} co-changes ({pair.percentage}%) | |
| 146 | </div> | |
| 147 | </div> | |
| 148 | ))} | |
| 149 | </div> | |
| 150 | )} | |
| 151 | ||
| 152 | <h3 style="margin-bottom: 12px">Project Milestones</h3> | |
| 153 | {milestones.length === 0 ? ( | |
| 154 | <p style="color: var(--text-muted)">No milestones detected yet.</p> | |
| 155 | ) : ( | |
| 156 | <div class="timeline"> | |
| 157 | {milestones.map((m) => ( | |
| 158 | <div class="timeline-item"> | |
| 159 | <div | |
| 160 | class="timeline-dot" | |
| 161 | style={m.significance === "milestone" ? "background: var(--green); width: 12px; height: 12px" : ""} | |
| 162 | /> | |
| 163 | <div class="timeline-content"> | |
| 164 | <a href={`/${owner}/${repo}/commit/${m.sha}`} style="font-weight: 600; font-size: 14px"> | |
| 165 | {m.message} | |
| 166 | </a> | |
| 167 | <div style="font-size: 12px; color: var(--text-muted)"> | |
| 168 | {m.author} — {new Date(m.date).toLocaleDateString()} |{" "} | |
| 169 | <span class="stat-add">+{m.stats.additions}</span>{" "} | |
| 170 | <span class="stat-del">-{m.stats.deletions}</span> in {m.stats.files} files | |
| 171 | </div> | |
| 172 | </div> | |
| 173 | </div> | |
| 174 | ))} | |
| 175 | </div> | |
| 176 | )} | |
| 177 | </Layout> | |
| 178 | ); | |
| 179 | }); | |
| 180 | ||
| 181 | // ─── DEPENDENCY INSIGHTS ───────────────────────────────────── | |
| 182 | ||
| 183 | insights.get("/:owner/:repo/dependencies", async (c) => { | |
| 184 | const { owner, repo } = c.req.param(); | |
| 185 | const user = c.get("user"); | |
| 186 | ||
| 187 | if (!(await repoExists(owner, repo))) return c.notFound(); | |
| 188 | const ref = (await getDefaultBranch(owner, repo)) || "main"; | |
| 189 | ||
| 190 | const graph = await buildImportGraph(owner, repo, ref); | |
| 191 | const unused = findUnusedDeps(graph); | |
| 192 | ||
| 193 | return c.html( | |
| 194 | <Layout title={`Dependencies — ${owner}/${repo}`} user={user}> | |
| 195 | <RepoHeader owner={owner} repo={repo} /> | |
| 196 | <RepoNav owner={owner} repo={repo} active="code" /> | |
| 197 | <h2 style="margin-bottom: 4px">Dependency Intelligence</h2> | |
| 198 | <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px"> | |
| 199 | {graph.externalDependencies} dependencies | {graph.internalModules} source files | |
| 200 | {graph.circularDeps.length > 0 && ( | |
| 201 | <span style="color: var(--red)"> | |
| 202 | {" "}| {graph.circularDeps.length} circular dependency chain{graph.circularDeps.length !== 1 ? "s" : ""} | |
| 203 | </span> | |
| 204 | )} | |
| 205 | </p> | |
| 206 | ||
| 207 | {unused.length > 0 && ( | |
| 208 | <div style="background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 20px"> | |
| 209 | <strong style="color: var(--red)">Unused dependencies:</strong>{" "} | |
| 210 | <span style="font-family: var(--font-mono); font-size: 13px"> | |
| 211 | {unused.join(", ")} | |
| 212 | </span> | |
| 213 | <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px"> | |
| 214 | These are installed but never imported. Removing them reduces install time and attack surface. | |
| 215 | </div> | |
| 216 | </div> | |
| 217 | )} | |
| 218 | ||
| 219 | <div class="issue-list"> | |
| 220 | {graph.dependencies.map((dep) => ( | |
| 221 | <div class="issue-item" style="flex-direction: column; align-items: stretch"> | |
| 222 | <div style="display: flex; justify-content: space-between; align-items: center"> | |
| 223 | <div> | |
| 224 | <strong style="font-size: 14px">{dep.name}</strong> | |
| 225 | <span style="margin-left: 8px; font-size: 12px; color: var(--text-muted)"> | |
| 226 | {dep.version} | |
| 227 | </span> | |
| 228 | {dep.isDevDep && ( | |
| 229 | <span class="badge" style="margin-left: 8px; font-size: 10px"> | |
| 230 | dev | |
| 231 | </span> | |
| 232 | )} | |
| 233 | </div> | |
| 234 | <span style="font-size: 13px; color: var(--text-muted)"> | |
| 235 | {dep.totalImports === 0 ? ( | |
| 236 | <span style="color: var(--red)">unused</span> | |
| 237 | ) : ( | |
| 238 | `${dep.totalImports} import${dep.totalImports !== 1 ? "s" : ""}` | |
| 239 | )} | |
| 240 | </span> | |
| 241 | </div> | |
| 242 | {dep.usedIn.length > 0 && ( | |
| 243 | <div style="margin-top: 8px; font-size: 12px"> | |
| 244 | {dep.usedIn.slice(0, 3).map((usage) => ( | |
| 245 | <div style="color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px"> | |
| 246 | <a href={`/${owner}/${repo}/blob/${ref}/${usage.file}`}> | |
| 247 | {usage.file}:{usage.line} | |
| 248 | </a> | |
| 249 | <span style="margin-left: 8px"> | |
| 250 | {"{"} | |
| 251 | {usage.importedSymbols.join(", ")} | |
| 252 | {"}"} | |
| 253 | </span> | |
| 254 | </div> | |
| 255 | ))} | |
| 256 | {dep.usedIn.length > 3 && ( | |
| 257 | <div style="color: var(--text-muted); margin-top: 2px"> | |
| 258 | +{dep.usedIn.length - 3} more | |
| 259 | </div> | |
| 260 | )} | |
| 261 | </div> | |
| 262 | )} | |
| 263 | </div> | |
| 264 | ))} | |
| 265 | </div> | |
| 266 | </Layout> | |
| 267 | ); | |
| 268 | }); | |
| 269 | ||
| 270 | // ─── ROLLBACK ──────────────────────────────────────────────── | |
| 271 | ||
| 272 | insights.post("/:owner/:repo/rollback", requireAuth, async (c) => { | |
| 273 | const { owner, repo } = c.req.param(); | |
| 274 | const user = c.get("user")!; | |
| 275 | const body = await c.req.parseBody(); | |
| 276 | const branch = String(body.branch || "main"); | |
| 277 | const targetSha = String(body.target_sha || ""); | |
| 278 | ||
| 279 | if (!targetSha) { | |
| 280 | return c.redirect(`/${owner}/${repo}`); | |
| 281 | } | |
| 282 | ||
| 283 | const result = await executeRollback(owner, repo, branch, targetSha); | |
| 284 | if (!result.success) { | |
| 285 | return c.redirect(`/${owner}/${repo}?error=${encodeURIComponent(result.error || "Rollback failed")}`); | |
| 286 | } | |
| 287 | ||
| 288 | return c.redirect(`/${owner}/${repo}/commit/${result.newSha}`); | |
| 289 | }); | |
| 290 | ||
| 291 | export default insights; |