CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
engineering-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.
| 8286942 | 1 | /** |
| 2 | * Engineering Intelligence Dashboard | |
| 3 | * | |
| 4 | * An executive-level analytics surface for CTOs and VPs of Engineering. | |
| 5 | * Answers: "Which team is shipping the most? Where is technical debt | |
| 6 | * accumulating? Which developers are blocked? What's the velocity trend?" | |
| 7 | * | |
| 8 | * Routes: | |
| 9 | * GET /insights — org-level dashboard (all repos the user has access to) | |
| 10 | * GET /:owner/:repo/insights/engineering — per-repo analytics | |
| 11 | * GET /insights/api/summary — JSON endpoint for the main KPIs | |
| 12 | * | |
| 13 | * No new migrations needed — queries existing tables only. | |
| 14 | */ | |
| 15 | ||
| 16 | import { Hono } from "hono"; | |
| 17 | import { db } from "../db"; | |
| 18 | import { | |
| 19 | repositories, | |
| 20 | pullRequests, | |
| 21 | issues, | |
| 22 | issueComments, | |
| 23 | activityFeed, | |
| 24 | users, | |
| 25 | prComments, | |
| 26 | gateRuns, | |
| 27 | prReviews, | |
| 28 | } from "../db/schema"; | |
| 29 | import { | |
| 30 | eq, | |
| 31 | and, | |
| 32 | gte, | |
| 33 | lte, | |
| 34 | sql, | |
| 35 | desc, | |
| 36 | isNotNull, | |
| 37 | inArray, | |
| 38 | ilike, | |
| 39 | or, | |
| 40 | count, | |
| 41 | avg, | |
| 42 | } from "drizzle-orm"; | |
| 43 | import { Layout } from "../views/layout"; | |
| 44 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 45 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 46 | import type { AuthEnv } from "../middleware/auth"; | |
| 47 | import { getUnreadCount } from "../lib/unread"; | |
| 48 | ||
| 49 | const engineeringInsights = new Hono<AuthEnv>(); | |
| 50 | engineeringInsights.use("*", softAuth); | |
| 51 | ||
| 52 | // ─── CSS ────────────────────────────────────────────────────────────────────── | |
| 53 | ||
| 54 | const styles = ` | |
| 55 | .ei-wrap { | |
| 56 | max-width: 1280px; | |
| 57 | margin: 0 auto; | |
| 58 | padding: var(--space-5) var(--space-4); | |
| 59 | } | |
| 60 | ||
| 61 | /* Hero */ | |
| 62 | .ei-hero { | |
| 63 | position: relative; | |
| 64 | margin-bottom: var(--space-5); | |
| 65 | padding: var(--space-5) var(--space-6); | |
| 66 | background: var(--bg-elevated); | |
| 67 | border: 1px solid var(--border); | |
| 68 | border-radius: 16px; | |
| 69 | overflow: hidden; | |
| 70 | } | |
| 71 | .ei-hero::before { | |
| 72 | content: ''; | |
| 73 | position: absolute; | |
| 74 | top: 0; left: 0; right: 0; | |
| 75 | height: 2px; | |
| 76 | background: linear-gradient(90deg, transparent 0%, #6366f1 25%, #8b5cf6 50%, #06b6d4 75%, transparent 100%); | |
| 77 | opacity: 0.8; | |
| 78 | pointer-events: none; | |
| 79 | } | |
| 80 | .ei-hero-orb { | |
| 81 | position: absolute; | |
| 82 | inset: -30% -15% auto auto; | |
| 83 | width: 500px; height: 500px; | |
| 84 | background: radial-gradient(circle, rgba(99,102,241,0.18), rgba(6,182,212,0.08) 45%, transparent 70%); | |
| 85 | filter: blur(90px); | |
| 86 | opacity: 0.7; | |
| 87 | pointer-events: none; | |
| 88 | z-index: 0; | |
| 89 | } | |
| 90 | .ei-hero-inner { position: relative; z-index: 1; } | |
| 91 | .ei-hero-top { | |
| 92 | display: flex; | |
| 93 | align-items: flex-start; | |
| 94 | justify-content: space-between; | |
| 95 | gap: var(--space-4); | |
| 96 | flex-wrap: wrap; | |
| 97 | margin-bottom: var(--space-4); | |
| 98 | } | |
| 99 | .ei-eyebrow { | |
| 100 | display: inline-flex; | |
| 101 | align-items: center; | |
| 102 | gap: 8px; | |
| 103 | text-transform: uppercase; | |
| 104 | font-family: var(--font-mono); | |
| 105 | font-size: 11px; | |
| 106 | letter-spacing: 0.18em; | |
| 107 | color: var(--text-muted); | |
| 108 | font-weight: 600; | |
| 109 | margin-bottom: 12px; | |
| 110 | } | |
| 111 | .ei-eyebrow-dot { | |
| 112 | width: 8px; height: 8px; | |
| 113 | border-radius: 9999px; | |
| 114 | background: linear-gradient(135deg, #6366f1, #06b6d4); | |
| 115 | box-shadow: 0 0 0 3px rgba(99,102,241,0.18); | |
| 116 | } | |
| 117 | .ei-hero-title { | |
| 118 | font-family: var(--font-display); | |
| 119 | font-size: clamp(26px, 3.5vw, 36px); | |
| 120 | font-weight: 800; | |
| 121 | letter-spacing: -0.025em; | |
| 122 | color: var(--text-strong); | |
| 123 | margin: 0 0 var(--space-2); | |
| 124 | line-height: 1.1; | |
| 125 | } | |
| 126 | .ei-hero-title-grad { | |
| 127 | background-image: linear-gradient(135deg, #818cf8 0%, #6366f1 40%, #06b6d4 100%); | |
| 128 | -webkit-background-clip: text; | |
| 129 | background-clip: text; | |
| 130 | -webkit-text-fill-color: transparent; | |
| 131 | color: transparent; | |
| 132 | } | |
| 133 | .ei-hero-sub { | |
| 134 | font-size: 14px; | |
| 135 | color: var(--text-muted); | |
| 136 | margin: 0; | |
| 137 | line-height: 1.6; | |
| 138 | } | |
| 139 | ||
| 140 | /* Time range selector */ | |
| 141 | .ei-range-bar { | |
| 142 | display: flex; | |
| 143 | align-items: center; | |
| 144 | gap: 6px; | |
| 145 | flex-wrap: wrap; | |
| 146 | } | |
| 147 | .ei-range-label { | |
| 148 | font-size: 12px; | |
| 149 | color: var(--text-muted); | |
| 150 | margin-right: 4px; | |
| 151 | } | |
| 152 | .ei-range-btn { | |
| 153 | display: inline-block; | |
| 154 | padding: 5px 13px; | |
| 155 | border-radius: 7px; | |
| 156 | font-size: 12px; | |
| 157 | font-weight: 500; | |
| 158 | text-decoration: none; | |
| 159 | border: 1px solid var(--border); | |
| 160 | color: var(--text-muted); | |
| 161 | background: var(--bg); | |
| 162 | transition: border-color 120ms ease, color 120ms ease, background 120ms ease; | |
| 163 | white-space: nowrap; | |
| 164 | } | |
| 165 | .ei-range-btn:hover { color: var(--text); border-color: var(--border-strong, var(--border)); } | |
| 166 | .ei-range-btn.active { | |
| 167 | background: #6366f1; | |
| 168 | border-color: #6366f1; | |
| 169 | color: #fff; | |
| 170 | } | |
| 171 | ||
| 172 | /* KPI cards row */ | |
| 173 | .ei-kpi-row { | |
| 174 | display: grid; | |
| 175 | grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); | |
| 176 | gap: var(--space-3); | |
| 177 | margin-bottom: var(--space-5); | |
| 178 | } | |
| 179 | .ei-kpi { | |
| 180 | position: relative; | |
| 181 | background: var(--bg-elevated); | |
| 182 | border: 1px solid var(--border); | |
| 183 | border-radius: 14px; | |
| 184 | padding: var(--space-4); | |
| 185 | transition: border-color 120ms ease, transform 120ms ease; | |
| 186 | overflow: hidden; | |
| 187 | } | |
| 188 | .ei-kpi:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); } | |
| 189 | .ei-kpi-icon { | |
| 190 | width: 32px; height: 32px; | |
| 191 | border-radius: 8px; | |
| 192 | display: flex; | |
| 193 | align-items: center; | |
| 194 | justify-content: center; | |
| 195 | font-size: 16px; | |
| 196 | margin-bottom: var(--space-3); | |
| 197 | } | |
| 198 | .ei-kpi-icon-blue { background: rgba(99,102,241,0.12); } | |
| 199 | .ei-kpi-icon-green { background: rgba(52,211,153,0.12); } | |
| 200 | .ei-kpi-icon-amber { background: rgba(251,191,36,0.12); } | |
| 201 | .ei-kpi-icon-purple { background: rgba(139,92,246,0.12); } | |
| 202 | .ei-kpi-label { | |
| 203 | font-size: 11px; | |
| 204 | letter-spacing: 0.1em; | |
| 205 | text-transform: uppercase; | |
| 206 | color: var(--text-muted); | |
| 207 | font-weight: 700; | |
| 208 | margin-bottom: 6px; | |
| 209 | } | |
| 210 | .ei-kpi-value { | |
| 211 | font-family: var(--font-display); | |
| 212 | font-size: 32px; | |
| 213 | font-weight: 800; | |
| 214 | letter-spacing: -0.022em; | |
| 215 | color: var(--text-strong); | |
| 216 | font-variant-numeric: tabular-nums; | |
| 217 | line-height: 1; | |
| 218 | margin-bottom: 8px; | |
| 219 | } | |
| 220 | .ei-kpi-value.na { color: var(--text-muted); font-size: 22px; } | |
| 221 | .ei-kpi-delta { | |
| 222 | display: inline-flex; | |
| 223 | align-items: center; | |
| 224 | gap: 4px; | |
| 225 | font-size: 12px; | |
| 226 | font-variant-numeric: tabular-nums; | |
| 227 | color: var(--text-muted); | |
| 228 | margin-bottom: 4px; | |
| 229 | } | |
| 230 | .ei-kpi-delta.up { color: #34d399; } | |
| 231 | .ei-kpi-delta.down { color: #f87171; } | |
| 232 | .ei-kpi-sub { | |
| 233 | font-size: 12px; | |
| 234 | color: var(--text-muted); | |
| 235 | line-height: 1.4; | |
| 236 | } | |
| 237 | ||
| 238 | /* Section */ | |
| 239 | .ei-section { | |
| 240 | margin-bottom: var(--space-6); | |
| 241 | } | |
| 242 | .ei-section-head { | |
| 243 | display: flex; | |
| 244 | align-items: baseline; | |
| 245 | justify-content: space-between; | |
| 246 | gap: var(--space-3); | |
| 247 | flex-wrap: wrap; | |
| 248 | margin-bottom: var(--space-3); | |
| 249 | } | |
| 250 | .ei-section-title { | |
| 251 | font-family: var(--font-display); | |
| 252 | font-size: 16px; | |
| 253 | font-weight: 700; | |
| 254 | letter-spacing: -0.015em; | |
| 255 | color: var(--text-strong); | |
| 256 | margin: 0; | |
| 257 | } | |
| 258 | .ei-section-sub { | |
| 259 | font-size: 12.5px; | |
| 260 | color: var(--text-muted); | |
| 261 | } | |
| 262 | ||
| 263 | /* Velocity chart */ | |
| 264 | .ei-chart-wrap { | |
| 265 | background: var(--bg-elevated); | |
| 266 | border: 1px solid var(--border); | |
| 267 | border-radius: 12px; | |
| 268 | padding: var(--space-4); | |
| 269 | overflow-x: auto; | |
| 270 | } | |
| 271 | .ei-chart-bars { | |
| 272 | display: flex; | |
| 273 | align-items: flex-end; | |
| 274 | gap: 8px; | |
| 275 | height: 120px; | |
| 276 | padding-top: 8px; | |
| 277 | } | |
| 278 | .ei-chart-col { | |
| 279 | display: flex; | |
| 280 | flex-direction: column; | |
| 281 | align-items: center; | |
| 282 | flex: 1; | |
| 283 | min-width: 40px; | |
| 284 | } | |
| 285 | .ei-chart-bar-wrap { | |
| 286 | flex: 1; | |
| 287 | width: 100%; | |
| 288 | display: flex; | |
| 289 | align-items: flex-end; | |
| 290 | justify-content: center; | |
| 291 | } | |
| 292 | .ei-chart-bar { | |
| 293 | width: 70%; | |
| 294 | border-radius: 4px 4px 0 0; | |
| 295 | min-height: 4px; | |
| 296 | transition: opacity 120ms ease; | |
| 297 | position: relative; | |
| 298 | } | |
| 299 | .ei-chart-bar.pr-bar { background: linear-gradient(180deg, #818cf8, #6366f1); } | |
| 300 | .ei-chart-bar.issue-bar { background: linear-gradient(180deg, #34d399, #10b981); opacity: 0.6; } | |
| 301 | .ei-chart-bar:hover { opacity: 0.8; } | |
| 302 | .ei-chart-label { | |
| 303 | font-family: var(--font-mono); | |
| 304 | font-size: 10px; | |
| 305 | color: var(--text-muted); | |
| 306 | margin-top: 6px; | |
| 307 | white-space: nowrap; | |
| 308 | } | |
| 309 | .ei-chart-val { | |
| 310 | font-family: var(--font-mono); | |
| 311 | font-size: 10px; | |
| 312 | color: var(--text-muted); | |
| 313 | margin-bottom: 4px; | |
| 314 | } | |
| 315 | .ei-chart-legend { | |
| 316 | display: flex; | |
| 317 | gap: var(--space-4); | |
| 318 | margin-top: var(--space-3); | |
| 319 | padding-top: var(--space-3); | |
| 320 | border-top: 1px solid var(--border); | |
| 321 | } | |
| 322 | .ei-chart-legend-item { | |
| 323 | display: flex; | |
| 324 | align-items: center; | |
| 325 | gap: 6px; | |
| 326 | font-size: 12px; | |
| 327 | color: var(--text-muted); | |
| 328 | } | |
| 329 | .ei-legend-dot { | |
| 330 | width: 10px; height: 10px; | |
| 331 | border-radius: 2px; | |
| 332 | } | |
| 333 | ||
| 334 | /* Contributors table */ | |
| 335 | .ei-table-wrap { | |
| 336 | background: var(--bg-elevated); | |
| 337 | border: 1px solid var(--border); | |
| 338 | border-radius: 12px; | |
| 339 | overflow: hidden; | |
| 340 | } | |
| 341 | .ei-table { | |
| 342 | width: 100%; | |
| 343 | border-collapse: collapse; | |
| 344 | font-size: 13px; | |
| 345 | } | |
| 346 | .ei-table th { | |
| 347 | padding: 10px 16px; | |
| 348 | text-align: left; | |
| 349 | font-size: 11px; | |
| 350 | font-weight: 700; | |
| 351 | letter-spacing: 0.08em; | |
| 352 | text-transform: uppercase; | |
| 353 | color: var(--text-muted); | |
| 354 | border-bottom: 1px solid var(--border); | |
| 355 | background: var(--bg); | |
| 356 | white-space: nowrap; | |
| 357 | } | |
| 358 | .ei-table th:not(:first-child) { text-align: right; } | |
| 359 | .ei-table td { | |
| 360 | padding: 10px 16px; | |
| 361 | border-bottom: 1px solid var(--border); | |
| 362 | color: var(--text); | |
| 363 | vertical-align: middle; | |
| 364 | } | |
| 365 | .ei-table td:not(:first-child) { | |
| 366 | text-align: right; | |
| 367 | font-variant-numeric: tabular-nums; | |
| 368 | font-family: var(--font-mono); | |
| 369 | font-size: 12px; | |
| 370 | } | |
| 371 | .ei-table tr:last-child td { border-bottom: none; } | |
| 372 | .ei-table tr:hover td { background: var(--bg-hover); } | |
| 373 | .ei-contributor-rank { | |
| 374 | display: inline-flex; | |
| 375 | align-items: center; | |
| 376 | justify-content: center; | |
| 377 | width: 20px; height: 20px; | |
| 378 | border-radius: 50%; | |
| 379 | font-size: 10px; | |
| 380 | font-weight: 700; | |
| 381 | background: rgba(99,102,241,0.12); | |
| 382 | color: #818cf8; | |
| 383 | margin-right: 8px; | |
| 384 | flex-shrink: 0; | |
| 385 | } | |
| 386 | .ei-contributor-rank.top1 { background: rgba(251,191,36,0.15); color: #fbbf24; } | |
| 387 | .ei-contributor-rank.top2 { background: rgba(156,163,175,0.15); color: #9ca3af; } | |
| 388 | .ei-contributor-rank.top3 { background: rgba(217,119,6,0.15); color: #d97706; } | |
| 389 | .ei-avatar { | |
| 390 | display: inline-flex; | |
| 391 | align-items: center; | |
| 392 | justify-content: center; | |
| 393 | width: 24px; height: 24px; | |
| 394 | border-radius: 50%; | |
| 395 | font-size: 11px; | |
| 396 | font-weight: 700; | |
| 397 | background: rgba(99,102,241,0.18); | |
| 398 | color: #818cf8; | |
| 399 | margin-right: 8px; | |
| 400 | flex-shrink: 0; | |
| 401 | } | |
| 402 | .ei-contributor-cell { | |
| 403 | display: flex; | |
| 404 | align-items: center; | |
| 405 | } | |
| 406 | ||
| 407 | /* Alert banners */ | |
| 408 | .ei-alerts { | |
| 409 | display: flex; | |
| 410 | flex-direction: column; | |
| 411 | gap: var(--space-2); | |
| 412 | margin-bottom: var(--space-5); | |
| 413 | } | |
| 414 | .ei-alert { | |
| 415 | display: flex; | |
| 416 | align-items: flex-start; | |
| 417 | gap: 12px; | |
| 418 | padding: 12px 16px; | |
| 419 | border-radius: 10px; | |
| 420 | font-size: 13px; | |
| 421 | line-height: 1.55; | |
| 422 | } | |
| 423 | .ei-alert.red { | |
| 424 | border: 1px solid rgba(248,113,113,0.3); | |
| 425 | background: rgba(248,113,113,0.07); | |
| 426 | color: #fca5a5; | |
| 427 | } | |
| 428 | .ei-alert.amber { | |
| 429 | border: 1px solid rgba(251,191,36,0.3); | |
| 430 | background: rgba(251,191,36,0.07); | |
| 431 | color: #fcd34d; | |
| 432 | } | |
| 433 | .ei-alert.green { | |
| 434 | border: 1px solid rgba(52,211,153,0.3); | |
| 435 | background: rgba(52,211,153,0.07); | |
| 436 | color: #6ee7b7; | |
| 437 | } | |
| 438 | .ei-alert-icon { font-size: 16px; line-height: 1.4; flex-shrink: 0; } | |
| 439 | .ei-alert strong { font-weight: 700; } | |
| 440 | ||
| 441 | /* AI impact box */ | |
| 442 | .ei-ai-box { | |
| 443 | background: var(--bg-elevated); | |
| 444 | border: 1px solid rgba(99,102,241,0.25); | |
| 445 | border-radius: 14px; | |
| 446 | padding: var(--space-4) var(--space-5); | |
| 447 | position: relative; | |
| 448 | overflow: hidden; | |
| 449 | } | |
| 450 | .ei-ai-box::before { | |
| 451 | content: ''; | |
| 452 | position: absolute; | |
| 453 | top: 0; left: 0; right: 0; | |
| 454 | height: 2px; | |
| 455 | background: linear-gradient(90deg, transparent, #6366f1 30%, #8b5cf6 60%, transparent); | |
| 456 | opacity: 0.6; | |
| 457 | } | |
| 458 | .ei-ai-grid { | |
| 459 | display: grid; | |
| 460 | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); | |
| 461 | gap: var(--space-4); | |
| 462 | margin-top: var(--space-4); | |
| 463 | } | |
| 464 | .ei-ai-stat { | |
| 465 | display: flex; | |
| 466 | flex-direction: column; | |
| 467 | gap: 4px; | |
| 468 | } | |
| 469 | .ei-ai-stat-val { | |
| 470 | font-family: var(--font-display); | |
| 471 | font-size: 26px; | |
| 472 | font-weight: 800; | |
| 473 | letter-spacing: -0.02em; | |
| 474 | font-variant-numeric: tabular-nums; | |
| 475 | color: var(--text-strong); | |
| 476 | } | |
| 477 | .ei-ai-stat-label { | |
| 478 | font-size: 12px; | |
| 479 | color: var(--text-muted); | |
| 480 | line-height: 1.4; | |
| 481 | } | |
| 482 | .ei-ai-summary-line { | |
| 483 | font-size: 13.5px; | |
| 484 | color: var(--text-muted); | |
| 485 | margin-top: var(--space-3); | |
| 486 | padding-top: var(--space-3); | |
| 487 | border-top: 1px solid var(--border); | |
| 488 | line-height: 1.55; | |
| 489 | } | |
| 490 | .ei-ai-summary-line strong { color: var(--text); font-weight: 600; } | |
| 491 | ||
| 492 | /* Health indicators grid */ | |
| 493 | .ei-health-grid { | |
| 494 | display: grid; | |
| 495 | grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); | |
| 496 | gap: var(--space-3); | |
| 497 | } | |
| 498 | .ei-health-card { | |
| 499 | background: var(--bg-elevated); | |
| 500 | border: 1px solid var(--border); | |
| 501 | border-radius: 12px; | |
| 502 | padding: var(--space-4); | |
| 503 | } | |
| 504 | .ei-health-card.red { border-color: rgba(248,113,113,0.3); } | |
| 505 | .ei-health-card.amber { border-color: rgba(251,191,36,0.3); } | |
| 506 | .ei-health-card.green { border-color: rgba(52,211,153,0.3); } | |
| 507 | .ei-health-badge { | |
| 508 | display: inline-flex; | |
| 509 | align-items: center; | |
| 510 | gap: 5px; | |
| 511 | padding: 3px 9px; | |
| 512 | border-radius: 9999px; | |
| 513 | font-size: 11px; | |
| 514 | font-weight: 700; | |
| 515 | text-transform: uppercase; | |
| 516 | letter-spacing: 0.08em; | |
| 517 | margin-bottom: var(--space-3); | |
| 518 | } | |
| 519 | .ei-health-badge.red { background: rgba(248,113,113,0.12); color: #f87171; } | |
| 520 | .ei-health-badge.amber { background: rgba(251,191,36,0.12); color: #fbbf24; } | |
| 521 | .ei-health-badge.green { background: rgba(52,211,153,0.12); color: #34d399; } | |
| 522 | .ei-health-num { | |
| 523 | font-family: var(--font-display); | |
| 524 | font-size: 28px; | |
| 525 | font-weight: 800; | |
| 526 | letter-spacing: -0.02em; | |
| 527 | font-variant-numeric: tabular-nums; | |
| 528 | color: var(--text-strong); | |
| 529 | line-height: 1; | |
| 530 | margin-bottom: 4px; | |
| 531 | } | |
| 532 | .ei-health-label { | |
| 533 | font-size: 13px; | |
| 534 | color: var(--text-muted); | |
| 535 | line-height: 1.4; | |
| 536 | } | |
| 537 | ||
| 538 | /* Quality table */ | |
| 539 | .ei-quality-row { | |
| 540 | display: flex; | |
| 541 | align-items: center; | |
| 542 | gap: var(--space-3); | |
| 543 | padding: 10px 0; | |
| 544 | border-bottom: 1px solid var(--border); | |
| 545 | } | |
| 546 | .ei-quality-row:last-child { border-bottom: none; } | |
| 547 | .ei-quality-name { | |
| 548 | flex: 1; | |
| 549 | font-size: 13px; | |
| 550 | color: var(--text); | |
| 551 | font-weight: 500; | |
| 552 | } | |
| 553 | .ei-quality-bar-wrap { | |
| 554 | width: 120px; | |
| 555 | height: 6px; | |
| 556 | background: var(--bg-hover); | |
| 557 | border-radius: 9999px; | |
| 558 | overflow: hidden; | |
| 559 | } | |
| 560 | .ei-quality-bar { | |
| 561 | height: 100%; | |
| 562 | border-radius: 9999px; | |
| 563 | background: linear-gradient(90deg, #6366f1, #06b6d4); | |
| 564 | } | |
| 565 | .ei-quality-val { | |
| 566 | font-family: var(--font-mono); | |
| 567 | font-size: 12px; | |
| 568 | color: var(--text-muted); | |
| 569 | font-variant-numeric: tabular-nums; | |
| 570 | width: 40px; | |
| 571 | text-align: right; | |
| 572 | } | |
| 573 | ||
| 574 | /* Empty state */ | |
| 575 | .ei-empty { | |
| 576 | text-align: center; | |
| 577 | padding: var(--space-6) var(--space-4); | |
| 578 | color: var(--text-muted); | |
| 579 | font-size: 13px; | |
| 580 | border: 1px dashed var(--border); | |
| 581 | border-radius: 12px; | |
| 582 | } | |
| 583 | .ei-empty strong { | |
| 584 | display: block; | |
| 585 | font-size: 14px; | |
| 586 | font-weight: 600; | |
| 587 | color: var(--text); | |
| 588 | margin-bottom: 4px; | |
| 589 | } | |
| 590 | ||
| 591 | /* Two-column layout */ | |
| 592 | .ei-two-col { | |
| 593 | display: grid; | |
| 594 | grid-template-columns: 1fr 1fr; | |
| 595 | gap: var(--space-4); | |
| 596 | } | |
| 597 | @media (max-width: 768px) { | |
| 598 | .ei-two-col { grid-template-columns: 1fr; } | |
| 599 | } | |
| 600 | `; | |
| 601 | ||
| 602 | // ─── Helpers ────────────────────────────────────────────────────────────────── | |
| 603 | ||
| 604 | function weeksAgo(n: number): Date { | |
| 605 | const d = new Date(); | |
| 606 | d.setDate(d.getDate() - n * 7); | |
| 607 | return d; | |
| 608 | } | |
| 609 | ||
| 610 | function daysAgo(n: number): Date { | |
| 611 | const d = new Date(); | |
| 612 | d.setDate(d.getDate() - n); | |
| 613 | return d; | |
| 614 | } | |
| 615 | ||
| 616 | function fmtHours(hours: number | null): string { | |
| 617 | if (hours === null || isNaN(hours)) return "—"; | |
| 618 | if (hours < 1) return `${Math.round(hours * 60)}m`; | |
| 619 | if (hours < 24) return `${hours.toFixed(1)}h`; | |
| 620 | return `${(hours / 24).toFixed(1)}d`; | |
| 621 | } | |
| 622 | ||
| 623 | function shortWeekLabel(weekStart: Date): string { | |
| 624 | return weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" }); | |
| 625 | } | |
| 626 | ||
| 627 | // ─── Data queries ───────────────────────────────────────────────────────────── | |
| 628 | ||
| 629 | async function getAccessibleRepoIds(userId: string): Promise<string[]> { | |
| 630 | // Owned repos (public + private) + public repos | |
| 631 | const owned = await db | |
| 632 | .select({ id: repositories.id }) | |
| 633 | .from(repositories) | |
| 634 | .where(eq(repositories.ownerId, userId)); | |
| 635 | return owned.map((r) => r.id); | |
| 636 | } | |
| 637 | ||
| 638 | interface WeeklyBucket { | |
| 639 | weekStart: Date; | |
| 640 | prsMerged: number; | |
| 641 | issuesClosed: number; | |
| 642 | } | |
| 643 | ||
| 644 | async function getWeeklyVelocity( | |
| 645 | repoIds: string[], | |
| 646 | weeks: number | |
| 647 | ): Promise<WeeklyBucket[]> { | |
| 648 | if (repoIds.length === 0) return []; | |
| 649 | ||
| 650 | const since = weeksAgo(weeks); | |
| 651 | const buckets: WeeklyBucket[] = []; | |
| 652 | ||
| 653 | // Build week buckets | |
| 654 | for (let i = weeks - 1; i >= 0; i--) { | |
| 655 | const start = weeksAgo(i + 1); | |
| 656 | start.setHours(0, 0, 0, 0); | |
| 657 | const end = weeksAgo(i); | |
| 658 | end.setHours(23, 59, 59, 999); | |
| 659 | buckets.push({ weekStart: start, prsMerged: 0, issuesClosed: 0 }); | |
| 660 | ||
| 661 | // PRs merged in this week | |
| 662 | const prs = await db | |
| 663 | .select({ id: pullRequests.id }) | |
| 664 | .from(pullRequests) | |
| 665 | .where( | |
| 666 | and( | |
| 667 | inArray(pullRequests.repositoryId, repoIds), | |
| 668 | eq(pullRequests.state, "merged"), | |
| 669 | gte(pullRequests.mergedAt, start), | |
| 670 | lte(pullRequests.mergedAt, end) | |
| 671 | ) | |
| 672 | ); | |
| 673 | buckets[buckets.length - 1].prsMerged = prs.length; | |
| 674 | ||
| 675 | // Issues closed in this week | |
| 676 | const closedIssues = await db | |
| 677 | .select({ id: issues.id }) | |
| 678 | .from(issues) | |
| 679 | .where( | |
| 680 | and( | |
| 681 | inArray(issues.repositoryId, repoIds), | |
| 682 | eq(issues.state, "closed"), | |
| 683 | gte(issues.closedAt, start), | |
| 684 | lte(issues.closedAt, end) | |
| 685 | ) | |
| 686 | ); | |
| 687 | buckets[buckets.length - 1].issuesClosed = closedIssues.length; | |
| 688 | } | |
| 689 | ||
| 690 | return buckets; | |
| 691 | } | |
| 692 | ||
| 693 | interface PrStats { | |
| 694 | totalMerged: number; | |
| 695 | avgMergeHours: number | null; | |
| 696 | mergedThisPeriod: number; | |
| 697 | mergedLastPeriod: number; | |
| 698 | } | |
| 699 | ||
| 700 | async function getPrStats(repoIds: string[], days: number): Promise<PrStats> { | |
| 701 | if (repoIds.length === 0) { | |
| 702 | return { totalMerged: 0, avgMergeHours: null, mergedThisPeriod: 0, mergedLastPeriod: 0 }; | |
| 703 | } | |
| 704 | ||
| 705 | const periodStart = daysAgo(days); | |
| 706 | const prevPeriodStart = daysAgo(days * 2); | |
| 707 | ||
| 708 | const merged = await db | |
| 709 | .select({ | |
| 710 | id: pullRequests.id, | |
| 711 | createdAt: pullRequests.createdAt, | |
| 712 | mergedAt: pullRequests.mergedAt, | |
| 713 | }) | |
| 714 | .from(pullRequests) | |
| 715 | .where( | |
| 716 | and( | |
| 717 | inArray(pullRequests.repositoryId, repoIds), | |
| 718 | eq(pullRequests.state, "merged"), | |
| 719 | isNotNull(pullRequests.mergedAt) | |
| 720 | ) | |
| 721 | ); | |
| 722 | ||
| 723 | const thisPeriod = merged.filter( | |
| 724 | (p) => p.mergedAt && new Date(p.mergedAt) >= periodStart | |
| 725 | ); | |
| 726 | const lastPeriod = merged.filter( | |
| 727 | (p) => | |
| 728 | p.mergedAt && | |
| 729 | new Date(p.mergedAt) >= prevPeriodStart && | |
| 730 | new Date(p.mergedAt) < periodStart | |
| 731 | ); | |
| 732 | ||
| 733 | // avg merge time (hours) from created→merged | |
| 734 | const timesHours = thisPeriod | |
| 735 | .filter((p) => p.mergedAt) | |
| 736 | .map((p) => { | |
| 737 | const diff = | |
| 738 | new Date(p.mergedAt!).getTime() - new Date(p.createdAt).getTime(); | |
| 739 | return diff / 1000 / 3600; | |
| 740 | }) | |
| 741 | .filter((h) => h >= 0 && h < 10000); | |
| 742 | ||
| 743 | const avgMergeHours = | |
| 744 | timesHours.length > 0 | |
| 745 | ? timesHours.reduce((a, b) => a + b, 0) / timesHours.length | |
| 746 | : null; | |
| 747 | ||
| 748 | return { | |
| 749 | totalMerged: merged.length, | |
| 750 | avgMergeHours, | |
| 751 | mergedThisPeriod: thisPeriod.length, | |
| 752 | mergedLastPeriod: lastPeriod.length, | |
| 753 | }; | |
| 754 | } | |
| 755 | ||
| 756 | interface GateStats { | |
| 757 | passRate: number | null; | |
| 758 | totalRuns: number; | |
| 759 | passed: number; | |
| 760 | failed: number; | |
| 761 | } | |
| 762 | ||
| 763 | async function getGateStats(repoIds: string[], days: number): Promise<GateStats> { | |
| 764 | if (repoIds.length === 0) return { passRate: null, totalRuns: 0, passed: 0, failed: 0 }; | |
| 765 | ||
| 766 | const since = daysAgo(days); | |
| 767 | const runs = await db | |
| 768 | .select({ status: gateRuns.status }) | |
| 769 | .from(gateRuns) | |
| 770 | .where( | |
| 771 | and( | |
| 772 | inArray(gateRuns.repositoryId, repoIds), | |
| 773 | gte(gateRuns.createdAt, since) | |
| 774 | ) | |
| 775 | ); | |
| 776 | ||
| 777 | const passed = runs.filter((r) => r.status === "passed").length; | |
| 778 | const failed = runs.filter((r) => r.status === "failed").length; | |
| 779 | const total = runs.length; | |
| 780 | const passRate = total > 0 ? (passed / total) * 100 : null; | |
| 781 | ||
| 782 | return { passRate, totalRuns: total, passed, failed }; | |
| 783 | } | |
| 784 | ||
| 785 | interface ContributorRow { | |
| 786 | username: string; | |
| 787 | displayName: string | null; | |
| 788 | prsMerged: number; | |
| 789 | prsOpened: number; | |
| 790 | reviewsGiven: number; | |
| 791 | avgMergeHours: number | null; | |
| 792 | } | |
| 793 | ||
| 794 | async function getTopContributors( | |
| 795 | repoIds: string[], | |
| 796 | days: number | |
| 797 | ): Promise<ContributorRow[]> { | |
| 798 | if (repoIds.length === 0) return []; | |
| 799 | ||
| 800 | const since = daysAgo(days); | |
| 801 | ||
| 802 | // PRs opened per user | |
| 803 | const opened = await db | |
| 804 | .select({ | |
| 805 | authorId: pullRequests.authorId, | |
| 806 | username: users.username, | |
| 807 | displayName: users.displayName, | |
| 808 | createdAt: pullRequests.createdAt, | |
| 809 | }) | |
| 810 | .from(pullRequests) | |
| 811 | .innerJoin(users, eq(pullRequests.authorId, users.id)) | |
| 812 | .where( | |
| 813 | and( | |
| 814 | inArray(pullRequests.repositoryId, repoIds), | |
| 815 | gte(pullRequests.createdAt, since) | |
| 816 | ) | |
| 817 | ); | |
| 818 | ||
| 819 | // PRs merged per user | |
| 820 | const mergedRows = await db | |
| 821 | .select({ | |
| 822 | authorId: pullRequests.authorId, | |
| 823 | createdAt: pullRequests.createdAt, | |
| 824 | mergedAt: pullRequests.mergedAt, | |
| 825 | }) | |
| 826 | .from(pullRequests) | |
| 827 | .where( | |
| 828 | and( | |
| 829 | inArray(pullRequests.repositoryId, repoIds), | |
| 830 | eq(pullRequests.state, "merged"), | |
| 831 | isNotNull(pullRequests.mergedAt), | |
| 832 | gte(pullRequests.mergedAt, since) | |
| 833 | ) | |
| 834 | ); | |
| 835 | ||
| 836 | // Reviews (non-AI comments on PRs) | |
| 837 | const reviews = await db | |
| 838 | .select({ authorId: prComments.authorId }) | |
| 839 | .from(prComments) | |
| 840 | .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id)) | |
| 841 | .where( | |
| 842 | and( | |
| 843 | inArray(pullRequests.repositoryId, repoIds), | |
| 844 | eq(prComments.isAiReview, false), | |
| 845 | gte(prComments.createdAt, since) | |
| 846 | ) | |
| 847 | ); | |
| 848 | ||
| 849 | // Aggregate by user | |
| 850 | const byUser = new Map< | |
| 851 | string, | |
| 852 | { | |
| 853 | username: string; | |
| 854 | displayName: string | null; | |
| 855 | opened: number; | |
| 856 | merged: number; | |
| 857 | reviews: number; | |
| 858 | mergeTimes: number[]; | |
| 859 | } | |
| 860 | >(); | |
| 861 | ||
| 862 | for (const row of opened) { | |
| 863 | const key = row.authorId; | |
| 864 | if (!byUser.has(key)) { | |
| 865 | byUser.set(key, { | |
| 866 | username: row.username, | |
| 867 | displayName: row.displayName, | |
| 868 | opened: 0, | |
| 869 | merged: 0, | |
| 870 | reviews: 0, | |
| 871 | mergeTimes: [], | |
| 872 | }); | |
| 873 | } | |
| 874 | byUser.get(key)!.opened++; | |
| 875 | } | |
| 876 | ||
| 877 | for (const row of mergedRows) { | |
| 878 | const key = row.authorId; | |
| 879 | if (!byUser.has(key)) continue; | |
| 880 | byUser.get(key)!.merged++; | |
| 881 | if (row.mergedAt) { | |
| 882 | const hours = | |
| 883 | (new Date(row.mergedAt).getTime() - new Date(row.createdAt).getTime()) / | |
| 884 | 1000 / | |
| 885 | 3600; | |
| 886 | if (hours >= 0 && hours < 10000) { | |
| 887 | byUser.get(key)!.mergeTimes.push(hours); | |
| 888 | } | |
| 889 | } | |
| 890 | } | |
| 891 | ||
| 892 | for (const row of reviews) { | |
| 893 | const key = row.authorId; | |
| 894 | if (!byUser.has(key)) continue; | |
| 895 | byUser.get(key)!.reviews++; | |
| 896 | } | |
| 897 | ||
| 898 | // Sort by merged desc, then opened | |
| 899 | return Array.from(byUser.entries()) | |
| 900 | .map(([, v]) => ({ | |
| 901 | username: v.username, | |
| 902 | displayName: v.displayName, | |
| 903 | prsMerged: v.merged, | |
| 904 | prsOpened: v.opened, | |
| 905 | reviewsGiven: v.reviews, | |
| 906 | avgMergeHours: | |
| 907 | v.mergeTimes.length > 0 | |
| 908 | ? v.mergeTimes.reduce((a, b) => a + b, 0) / v.mergeTimes.length | |
| 909 | : null, | |
| 910 | })) | |
| 911 | .sort((a, b) => b.prsMerged - a.prsMerged || b.prsOpened - a.prsOpened) | |
| 912 | .slice(0, 10); | |
| 913 | } | |
| 914 | ||
| 915 | interface HealthIndicators { | |
| 916 | staleRepos: number; | |
| 917 | stuckPrs: number; | |
| 918 | abandonedIssues: number; | |
| 919 | } | |
| 920 | ||
| 921 | async function getHealthIndicators( | |
| 922 | repoIds: string[] | |
| 923 | ): Promise<HealthIndicators> { | |
| 924 | if (repoIds.length === 0) | |
| 925 | return { staleRepos: 0, stuckPrs: 0, abandonedIssues: 0 }; | |
| 926 | ||
| 927 | const thirtyDaysAgo = daysAgo(30); | |
| 928 | const sevenDaysAgo = daysAgo(7); | |
| 929 | ||
| 930 | // Stale repos: no pushAt update in 30+ days | |
| 931 | const staleReposRows = await db | |
| 932 | .select({ id: repositories.id }) | |
| 933 | .from(repositories) | |
| 934 | .where( | |
| 935 | and( | |
| 936 | inArray(repositories.id, repoIds), | |
| 937 | or( | |
| 938 | lte(repositories.pushedAt, thirtyDaysAgo), | |
| 939 | sql`${repositories.pushedAt} IS NULL` | |
| 940 | ) | |
| 941 | ) | |
| 942 | ); | |
| 943 | ||
| 944 | // Stuck PRs: open for > 7 days | |
| 945 | const stuckPrsRows = await db | |
| 946 | .select({ id: pullRequests.id }) | |
| 947 | .from(pullRequests) | |
| 948 | .where( | |
| 949 | and( | |
| 950 | inArray(pullRequests.repositoryId, repoIds), | |
| 951 | eq(pullRequests.state, "open"), | |
| 952 | lte(pullRequests.createdAt, sevenDaysAgo) | |
| 953 | ) | |
| 954 | ); | |
| 955 | ||
| 956 | // Abandoned issues: open > 30 days with no comments | |
| 957 | const openOldIssues = await db | |
| 958 | .select({ id: issues.id }) | |
| 959 | .from(issues) | |
| 960 | .where( | |
| 961 | and( | |
| 962 | inArray(issues.repositoryId, repoIds), | |
| 963 | eq(issues.state, "open"), | |
| 964 | lte(issues.createdAt, thirtyDaysAgo) | |
| 965 | ) | |
| 966 | ); | |
| 967 | ||
| 968 | // Count which have no comments | |
| 969 | let abandonedCount = 0; | |
| 970 | if (openOldIssues.length > 0) { | |
| 971 | const issueIds = openOldIssues.map((i) => i.id); | |
| 972 | const withComments = await db | |
| 973 | .select({ issueId: issueComments.issueId }) | |
| 974 | .from(issueComments) | |
| 975 | .where(inArray(issueComments.issueId, issueIds)); | |
| 976 | const commentedSet = new Set(withComments.map((c) => c.issueId)); | |
| 977 | abandonedCount = openOldIssues.filter((i) => !commentedSet.has(i.id)).length; | |
| 978 | } | |
| 979 | ||
| 980 | return { | |
| 981 | staleRepos: staleReposRows.length, | |
| 982 | stuckPrs: stuckPrsRows.length, | |
| 983 | abandonedIssues: abandonedCount, | |
| 984 | }; | |
| 985 | } | |
| 986 | ||
| 987 | interface AiImpact { | |
| 988 | aiMergedPrs: number; | |
| 989 | aiSecurityIssues: number; | |
| 990 | aiReviewComments: number; | |
| 991 | estimatedHoursSaved: number; | |
| 992 | } | |
| 993 | ||
| 994 | async function getAiImpact(repoIds: string[], days: number): Promise<AiImpact> { | |
| 995 | if (repoIds.length === 0) | |
| 996 | return { aiMergedPrs: 0, aiSecurityIssues: 0, aiReviewComments: 0, estimatedHoursSaved: 0 }; | |
| 997 | ||
| 998 | const since = daysAgo(days); | |
| 999 | ||
| 1000 | // AI-merged PRs: merged via mergedBy where the user has "ai" or "bot" in name, | |
| 1001 | // or from activity_feed with action='pr_merge' and metadata containing AI hint. | |
| 1002 | // Simpler: PRs merged where title or body contains "[AI]" or "auto-merged" | |
| 1003 | const aiMergedRows = await db | |
| 1004 | .select({ id: pullRequests.id }) | |
| 1005 | .from(pullRequests) | |
| 1006 | .where( | |
| 1007 | and( | |
| 1008 | inArray(pullRequests.repositoryId, repoIds), | |
| 1009 | eq(pullRequests.state, "merged"), | |
| 1010 | isNotNull(pullRequests.mergedAt), | |
| 1011 | gte(pullRequests.mergedAt, since), | |
| 1012 | or( | |
| 1013 | ilike(pullRequests.title, "%[ai]%"), | |
| 1014 | ilike(pullRequests.title, "%auto-merged%"), | |
| 1015 | ilike(pullRequests.body, "%auto-merged by gluecron%") | |
| 1016 | ) | |
| 1017 | ) | |
| 1018 | ); | |
| 1019 | ||
| 1020 | // AI security issues: issues with [CVE] or [AI] in title | |
| 1021 | const aiSecurityRows = await db | |
| 1022 | .select({ id: issues.id }) | |
| 1023 | .from(issues) | |
| 1024 | .where( | |
| 1025 | and( | |
| 1026 | inArray(issues.repositoryId, repoIds), | |
| 1027 | gte(issues.createdAt, since), | |
| 1028 | or( | |
| 1029 | ilike(issues.title, "%[cve]%"), | |
| 1030 | ilike(issues.title, "%[ai]%"), | |
| 1031 | ilike(issues.title, "%[security]%") | |
| 1032 | ) | |
| 1033 | ) | |
| 1034 | ); | |
| 1035 | ||
| 1036 | // AI review comments | |
| 1037 | const aiCommentsRows = await db | |
| 1038 | .select({ id: prComments.id }) | |
| 1039 | .from(prComments) | |
| 1040 | .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id)) | |
| 1041 | .where( | |
| 1042 | and( | |
| 1043 | inArray(pullRequests.repositoryId, repoIds), | |
| 1044 | eq(prComments.isAiReview, true), | |
| 1045 | gte(prComments.createdAt, since) | |
| 1046 | ) | |
| 1047 | ); | |
| 1048 | ||
| 1049 | // Estimate: avg review takes 4h; each AI-merged PR saves that | |
| 1050 | const estimatedHoursSaved = aiMergedRows.length * 4; | |
| 1051 | ||
| 1052 | return { | |
| 1053 | aiMergedPrs: aiMergedRows.length, | |
| 1054 | aiSecurityIssues: aiSecurityRows.length, | |
| 1055 | aiReviewComments: aiCommentsRows.length, | |
| 1056 | estimatedHoursSaved, | |
| 1057 | }; | |
| 1058 | } | |
| 1059 | ||
| 1060 | // ─── SVG Chart ──────────────────────────────────────────────────────────────── | |
| 1061 | ||
| 1062 | function VelocityChart({ buckets }: { buckets: WeeklyBucket[] }) { | |
| 1063 | const maxVal = Math.max(...buckets.map((b) => Math.max(b.prsMerged, b.issuesClosed)), 1); | |
| 1064 | const chartHeight = 100; | |
| 1065 | ||
| 1066 | return ( | |
| 1067 | <div class="ei-chart-wrap"> | |
| 1068 | <div class="ei-chart-bars"> | |
| 1069 | {buckets.map((b) => { | |
| 1070 | const prHeight = Math.max(4, (b.prsMerged / maxVal) * chartHeight); | |
| 1071 | const issueHeight = Math.max(4, (b.issuesClosed / maxVal) * chartHeight); | |
| 1072 | return ( | |
| 1073 | <div class="ei-chart-col"> | |
| 1074 | <div class="ei-chart-val">{b.prsMerged}</div> | |
| 1075 | <div class="ei-chart-bar-wrap"> | |
| 1076 | <div | |
| 1077 | class="ei-chart-bar pr-bar" | |
| 1078 | style={`height:${prHeight}px`} | |
| 1079 | title={`${b.prsMerged} PRs merged`} | |
| 1080 | /> | |
| 1081 | </div> | |
| 1082 | <div class="ei-chart-label">{shortWeekLabel(b.weekStart)}</div> | |
| 1083 | </div> | |
| 1084 | ); | |
| 1085 | })} | |
| 1086 | </div> | |
| 1087 | <div class="ei-chart-legend"> | |
| 1088 | <div class="ei-chart-legend-item"> | |
| 1089 | <div class="ei-legend-dot" style="background: linear-gradient(90deg,#818cf8,#6366f1)" /> | |
| 1090 | PRs merged per week | |
| 1091 | </div> | |
| 1092 | </div> | |
| 1093 | </div> | |
| 1094 | ); | |
| 1095 | } | |
| 1096 | ||
| 1097 | // ─── Global /insights dashboard ─────────────────────────────────────────────── | |
| 1098 | ||
| 1099 | engineeringInsights.get("/insights", requireAuth, async (c) => { | |
| 1100 | const user = c.get("user")!; | |
| 1101 | const rangeParam = c.req.query("range") || "30"; | |
| 1102 | const days = rangeParam === "7" ? 7 : rangeParam === "90" ? 90 : rangeParam === "365" ? 365 : 30; | |
| 1103 | const notifCount = await getUnreadCount(user.id); | |
| 1104 | ||
| 1105 | const repoIds = await getAccessibleRepoIds(user.id); | |
| 1106 | ||
| 1107 | const [prStats, gateStats, weeklyVelocity, contributors, health, aiImpact] = | |
| 1108 | await Promise.all([ | |
| 1109 | getPrStats(repoIds, days), | |
| 1110 | getGateStats(repoIds, days), | |
| 1111 | getWeeklyVelocity(repoIds, 8), | |
| 1112 | getTopContributors(repoIds, days), | |
| 1113 | getHealthIndicators(repoIds), | |
| 1114 | getAiImpact(repoIds, days), | |
| 1115 | ]); | |
| 1116 | ||
| 1117 | const rangeLabel = | |
| 1118 | days === 7 ? "7d" : days === 90 ? "90d" : days === 365 ? "12mo" : "30d"; | |
| 1119 | ||
| 1120 | // Deltas | |
| 1121 | const prDeltaPct = | |
| 1122 | prStats.mergedLastPeriod > 0 | |
| 1123 | ? Math.round( | |
| 1124 | ((prStats.mergedThisPeriod - prStats.mergedLastPeriod) / | |
| 1125 | prStats.mergedLastPeriod) * | |
| 1126 | 100 | |
| 1127 | ) | |
| 1128 | : 0; | |
| 1129 | ||
| 1130 | return c.html( | |
| 1131 | <Layout | |
| 1132 | title="Engineering Intelligence — Insights" | |
| 1133 | user={user} | |
| 1134 | notificationCount={notifCount} | |
| 1135 | description="Org-level engineering metrics: velocity, quality, AI impact, and team health." | |
| 1136 | > | |
| 1137 | <style dangerouslySetInnerHTML={{ __html: styles }} /> | |
| 1138 | <div class="ei-wrap"> | |
| 1139 | {/* Hero */} | |
| 1140 | <section class="ei-hero"> | |
| 1141 | <div class="ei-hero-orb" aria-hidden="true" /> | |
| 1142 | <div class="ei-hero-inner"> | |
| 1143 | <div class="ei-hero-top"> | |
| 1144 | <div> | |
| 1145 | <div class="ei-eyebrow"> | |
| 1146 | <span class="ei-eyebrow-dot" aria-hidden="true" /> | |
| 1147 | Engineering Intelligence | |
| 1148 | </div> | |
| 1149 | <h1 class="ei-hero-title"> | |
| 1150 | <span class="ei-hero-title-grad">Team Performance.</span>{" "} | |
| 1151 | Org-wide. | |
| 1152 | </h1> | |
| 1153 | <p class="ei-hero-sub"> | |
| 1154 | Velocity, quality, AI impact, and developer health across all | |
| 1155 | your repositories — in one view. | |
| 1156 | </p> | |
| 1157 | </div> | |
| 1158 | {/* Time range selector */} | |
| 1159 | <div> | |
| 1160 | <div class="ei-range-label">Time range</div> | |
| 1161 | <div class="ei-range-bar"> | |
| 1162 | {(["7", "30", "90", "365"] as const).map((r) => ( | |
| 1163 | <a | |
| 1164 | href={`/insights?range=${r}`} | |
| 1165 | class={`ei-range-btn${rangeParam === r ? " active" : ""}`} | |
| 1166 | > | |
| 1167 | {r === "365" ? "12mo" : `${r}d`} | |
| 1168 | </a> | |
| 1169 | ))} | |
| 1170 | </div> | |
| 1171 | </div> | |
| 1172 | </div> | |
| 1173 | </div> | |
| 1174 | </section> | |
| 1175 | ||
| 1176 | {/* KPI Row */} | |
| 1177 | <div class="ei-kpi-row"> | |
| 1178 | <div class="ei-kpi"> | |
| 1179 | <div class="ei-kpi-icon ei-kpi-icon-blue">{"⬆"}</div> | |
| 1180 | <div class="ei-kpi-label">PRs Merged</div> | |
| 1181 | <div class="ei-kpi-value">{prStats.mergedThisPeriod}</div> | |
| 1182 | {prDeltaPct !== 0 && ( | |
| 1183 | <div class={`ei-kpi-delta ${prDeltaPct > 0 ? "up" : "down"}`}> | |
| 1184 | <span>{prDeltaPct > 0 ? "▲" : "▼"}</span> | |
| 1185 | {Math.abs(prDeltaPct)}% vs last {rangeLabel} | |
| 1186 | </div> | |
| 1187 | )} | |
| 1188 | <div class="ei-kpi-sub">This {rangeLabel}</div> | |
| 1189 | </div> | |
| 1190 | ||
| 1191 | <div class="ei-kpi"> | |
| 1192 | <div class="ei-kpi-icon ei-kpi-icon-green">{"⏱"}</div> | |
| 1193 | <div class="ei-kpi-label">Avg Merge Time</div> | |
| 1194 | <div class={`ei-kpi-value${prStats.avgMergeHours === null ? " na" : ""}`}> | |
| 1195 | {fmtHours(prStats.avgMergeHours)} | |
| 1196 | </div> | |
| 1197 | <div class="ei-kpi-sub">Open → merge, merged PRs</div> | |
| 1198 | </div> | |
| 1199 | ||
| 1200 | <div class="ei-kpi"> | |
| 1201 | <div class="ei-kpi-icon ei-kpi-icon-amber">{"✓"}</div> | |
| 1202 | <div class="ei-kpi-label">Gate Pass Rate</div> | |
| 1203 | <div class={`ei-kpi-value${gateStats.passRate === null ? " na" : ""}`}> | |
| 1204 | {gateStats.passRate !== null | |
| 1205 | ? `${Math.round(gateStats.passRate)}%` | |
| 1206 | : "—"} | |
| 1207 | </div> | |
| 1208 | <div class="ei-kpi-sub"> | |
| 1209 | {gateStats.passed}/{gateStats.totalRuns} runs passed | |
| 1210 | </div> | |
| 1211 | </div> | |
| 1212 | ||
| 1213 | <div class="ei-kpi"> | |
| 1214 | <div class="ei-kpi-icon ei-kpi-icon-purple">{"✨"}</div> | |
| 1215 | <div class="ei-kpi-label">AI Impact</div> | |
| 1216 | <div class="ei-kpi-value">{aiImpact.aiMergedPrs}</div> | |
| 1217 | <div class="ei-kpi-sub"> | |
| 1218 | PRs auto-merged · ~{aiImpact.estimatedHoursSaved}h saved | |
| 1219 | </div> | |
| 1220 | </div> | |
| 1221 | </div> | |
| 1222 | ||
| 1223 | {/* At-risk alerts */} | |
| 1224 | {(health.stuckPrs > 0 || health.staleRepos > 0 || health.abandonedIssues > 0) && ( | |
| 1225 | <div class="ei-alerts"> | |
| 1226 | {health.stuckPrs > 0 && ( | |
| 1227 | <div class="ei-alert red"> | |
| 1228 | <span class="ei-alert-icon">{"⚠"}</span> | |
| 1229 | <div> | |
| 1230 | <strong> | |
| 1231 | {health.stuckPrs} PR{health.stuckPrs !== 1 ? "s" : ""} open for | |
| 1232 | {" "}{">"} 7 days | |
| 1233 | </strong>{" "} | |
| 1234 | — these need attention. Unreviewed PRs slow down the whole team. | |
| 1235 | </div> | |
| 1236 | </div> | |
| 1237 | )} | |
| 1238 | {health.staleRepos > 0 && ( | |
| 1239 | <div class="ei-alert amber"> | |
| 1240 | <span class="ei-alert-icon">{"⚡"}</span> | |
| 1241 | <div> | |
| 1242 | <strong> | |
| 1243 | {health.staleRepos} repo{health.staleRepos !== 1 ? "s" : ""} with no | |
| 1244 | commits in 30+ days | |
| 1245 | </strong>{" "} | |
| 1246 | — consider archiving or reassigning. | |
| 1247 | </div> | |
| 1248 | </div> | |
| 1249 | )} | |
| 1250 | {health.abandonedIssues > 0 && ( | |
| 1251 | <div class="ei-alert amber"> | |
| 1252 | <span class="ei-alert-icon">{"!"}</span> | |
| 1253 | <div> | |
| 1254 | <strong> | |
| 1255 | {health.abandonedIssues} issue{health.abandonedIssues !== 1 ? "s" : ""} open | |
| 1256 | {">"} 30 days with no comments | |
| 1257 | </strong>{" "} | |
| 1258 | — abandoned backlog items inflate your issue count artificially. | |
| 1259 | </div> | |
| 1260 | </div> | |
| 1261 | )} | |
| 1262 | {gateStats.passRate !== null && gateStats.passRate >= 90 && ( | |
| 1263 | <div class="ei-alert green"> | |
| 1264 | <span class="ei-alert-icon">{"✓"}</span> | |
| 1265 | <div> | |
| 1266 | Gate pass rate is{" "} | |
| 1267 | <strong>{Math.round(gateStats.passRate)}%</strong> — solid | |
| 1268 | quality signal across your repos. | |
| 1269 | </div> | |
| 1270 | </div> | |
| 1271 | )} | |
| 1272 | </div> | |
| 1273 | )} | |
| 1274 | ||
| 1275 | {/* Velocity chart */} | |
| 1276 | <section class="ei-section"> | |
| 1277 | <div class="ei-section-head"> | |
| 1278 | <h2 class="ei-section-title">PR Velocity</h2> | |
| 1279 | <span class="ei-section-sub">PRs merged per week, last 8 weeks</span> | |
| 1280 | </div> | |
| 1281 | {repoIds.length === 0 ? ( | |
| 1282 | <div class="ei-empty"> | |
| 1283 | <strong>No repositories yet</strong> | |
| 1284 | Create or import a repository to see velocity metrics. | |
| 1285 | </div> | |
| 1286 | ) : ( | |
| 1287 | <VelocityChart buckets={weeklyVelocity} /> | |
| 1288 | )} | |
| 1289 | </section> | |
| 1290 | ||
| 1291 | <div class="ei-two-col"> | |
| 1292 | {/* Top contributors */} | |
| 1293 | <section class="ei-section"> | |
| 1294 | <div class="ei-section-head"> | |
| 1295 | <h2 class="ei-section-title">Top Contributors</h2> | |
| 1296 | <span class="ei-section-sub">Last {rangeLabel}</span> | |
| 1297 | </div> | |
| 1298 | {contributors.length === 0 ? ( | |
| 1299 | <div class="ei-empty"> | |
| 1300 | <strong>No contributor data yet</strong> | |
| 1301 | Merge some PRs to see rankings. | |
| 1302 | </div> | |
| 1303 | ) : ( | |
| 1304 | <div class="ei-table-wrap"> | |
| 1305 | <table class="ei-table"> | |
| 1306 | <thead> | |
| 1307 | <tr> | |
| 1308 | <th>Developer</th> | |
| 1309 | <th>Merged</th> | |
| 1310 | <th>Opened</th> | |
| 1311 | <th>Reviews</th> | |
| 1312 | <th>Avg TTM</th> | |
| 1313 | </tr> | |
| 1314 | </thead> | |
| 1315 | <tbody> | |
| 1316 | {contributors.map((c, i) => ( | |
| 1317 | <tr> | |
| 1318 | <td> | |
| 1319 | <div class="ei-contributor-cell"> | |
| 1320 | <span | |
| 1321 | class={`ei-contributor-rank${i === 0 ? " top1" : i === 1 ? " top2" : i === 2 ? " top3" : ""}`} | |
| 1322 | > | |
| 1323 | {i + 1} | |
| 1324 | </span> | |
| 1325 | <div class="ei-avatar"> | |
| 1326 | {(c.displayName || c.username).charAt(0).toUpperCase()} | |
| 1327 | </div> | |
| 1328 | <a href={`/${c.username}`} style="color:var(--text);text-decoration:none;font-weight:500"> | |
| 1329 | {c.displayName || c.username} | |
| 1330 | </a> | |
| 1331 | </div> | |
| 1332 | </td> | |
| 1333 | <td>{c.prsMerged}</td> | |
| 1334 | <td>{c.prsOpened}</td> | |
| 1335 | <td>{c.reviewsGiven}</td> | |
| 1336 | <td>{fmtHours(c.avgMergeHours)}</td> | |
| 1337 | </tr> | |
| 1338 | ))} | |
| 1339 | </tbody> | |
| 1340 | </table> | |
| 1341 | </div> | |
| 1342 | )} | |
| 1343 | </section> | |
| 1344 | ||
| 1345 | {/* Health indicators */} | |
| 1346 | <section class="ei-section"> | |
| 1347 | <div class="ei-section-head"> | |
| 1348 | <h2 class="ei-section-title">Health Indicators</h2> | |
| 1349 | <span class="ei-section-sub">Across all repos</span> | |
| 1350 | </div> | |
| 1351 | <div class="ei-health-grid"> | |
| 1352 | <div class={`ei-health-card ${health.stuckPrs > 0 ? "red" : "green"}`}> | |
| 1353 | <div class={`ei-health-badge ${health.stuckPrs > 0 ? "red" : "green"}`}> | |
| 1354 | {health.stuckPrs > 0 ? "⚠ Stuck" : "✓ Clear"} | |
| 1355 | </div> | |
| 1356 | <div class="ei-health-num">{health.stuckPrs}</div> | |
| 1357 | <div class="ei-health-label">PRs open {">"} 7 days</div> | |
| 1358 | </div> | |
| 1359 | <div class={`ei-health-card ${health.staleRepos > 0 ? "amber" : "green"}`}> | |
| 1360 | <div class={`ei-health-badge ${health.staleRepos > 0 ? "amber" : "green"}`}> | |
| 1361 | {health.staleRepos > 0 ? "! Stale" : "✓ Active"} | |
| 1362 | </div> | |
| 1363 | <div class="ei-health-num">{health.staleRepos}</div> | |
| 1364 | <div class="ei-health-label">Repos with no commits in 30d</div> | |
| 1365 | </div> | |
| 1366 | <div class={`ei-health-card ${health.abandonedIssues > 0 ? "amber" : "green"}`}> | |
| 1367 | <div class={`ei-health-badge ${health.abandonedIssues > 0 ? "amber" : "green"}`}> | |
| 1368 | {health.abandonedIssues > 0 ? "! Backlog" : "✓ Engaged"} | |
| 1369 | </div> | |
| 1370 | <div class="ei-health-num">{health.abandonedIssues}</div> | |
| 1371 | <div class="ei-health-label">Issues {">"} 30d with no comments</div> | |
| 1372 | </div> | |
| 1373 | <div class={`ei-health-card ${gateStats.passRate !== null && gateStats.passRate < 80 ? "red" : gateStats.passRate !== null && gateStats.passRate < 95 ? "amber" : "green"}`}> | |
| 1374 | <div class={`ei-health-badge ${gateStats.passRate !== null && gateStats.passRate < 80 ? "red" : gateStats.passRate !== null && gateStats.passRate < 95 ? "amber" : "green"}`}> | |
| 1375 | {gateStats.passRate !== null && gateStats.passRate >= 95 ? "✓ Healthy" : gateStats.passRate !== null && gateStats.passRate < 80 ? "⚠ Critical" : "! Watch"} | |
| 1376 | </div> | |
| 1377 | <div class="ei-health-num"> | |
| 1378 | {gateStats.passRate !== null ? `${Math.round(gateStats.passRate)}%` : "—"} | |
| 1379 | </div> | |
| 1380 | <div class="ei-health-label">Gate pass rate</div> | |
| 1381 | </div> | |
| 1382 | </div> | |
| 1383 | </section> | |
| 1384 | </div> | |
| 1385 | ||
| 1386 | {/* AI Impact */} | |
| 1387 | <section class="ei-section"> | |
| 1388 | <div class="ei-section-head"> | |
| 1389 | <h2 class="ei-section-title">{"✨ "}AI Impact</h2> | |
| 1390 | <span class="ei-section-sub">Last {rangeLabel}</span> | |
| 1391 | </div> | |
| 1392 | <div class="ei-ai-box"> | |
| 1393 | <div class="ei-ai-grid"> | |
| 1394 | <div class="ei-ai-stat"> | |
| 1395 | <div class="ei-ai-stat-val">{aiImpact.aiMergedPrs}</div> | |
| 1396 | <div class="ei-ai-stat-label">PRs auto-merged by AI</div> | |
| 1397 | </div> | |
| 1398 | <div class="ei-ai-stat"> | |
| 1399 | <div class="ei-ai-stat-val">~{aiImpact.estimatedHoursSaved}h</div> | |
| 1400 | <div class="ei-ai-stat-label">Estimated hours saved (4h/PR avg)</div> | |
| 1401 | </div> | |
| 1402 | <div class="ei-ai-stat"> | |
| 1403 | <div class="ei-ai-stat-val">{aiImpact.aiSecurityIssues}</div> | |
| 1404 | <div class="ei-ai-stat-label">Security issues opened by AI</div> | |
| 1405 | </div> | |
| 1406 | <div class="ei-ai-stat"> | |
| 1407 | <div class="ei-ai-stat-val">{aiImpact.aiReviewComments}</div> | |
| 1408 | <div class="ei-ai-stat-label">AI review comments left</div> | |
| 1409 | </div> | |
| 1410 | </div> | |
| 1411 | {aiImpact.aiMergedPrs > 0 && ( | |
| 1412 | <div class="ei-ai-summary-line"> | |
| 1413 | AI has auto-merged{" "} | |
| 1414 | <strong>{aiImpact.aiMergedPrs} PRs</strong> saving an | |
| 1415 | estimated{" "} | |
| 1416 | <strong>~{aiImpact.estimatedHoursSaved} hours</strong> of | |
| 1417 | review time.{" "} | |
| 1418 | {aiImpact.aiSecurityIssues > 0 && ( | |
| 1419 | <> | |
| 1420 | AI also opened{" "} | |
| 1421 | <strong>{aiImpact.aiSecurityIssues} security issues</strong>{" "} | |
| 1422 | and left{" "} | |
| 1423 | <strong>{aiImpact.aiReviewComments} review comments</strong>. | |
| 1424 | </> | |
| 1425 | )} | |
| 1426 | </div> | |
| 1427 | )} | |
| 1428 | </div> | |
| 1429 | </section> | |
| 1430 | </div> | |
| 1431 | </Layout> | |
| 1432 | ); | |
| 1433 | }); | |
| 1434 | ||
| 1435 | // ─── JSON API summary ───────────────────────────────────────────────────────── | |
| 1436 | ||
| 1437 | engineeringInsights.get("/insights/api/summary", requireAuth, async (c) => { | |
| 1438 | const user = c.get("user")!; | |
| 1439 | const days = parseInt(c.req.query("days") || "30", 10); | |
| 1440 | ||
| 1441 | const repoIds = await getAccessibleRepoIds(user.id); | |
| 1442 | ||
| 1443 | const [prStats, gateStats, health, aiImpact, contributors] = await Promise.all([ | |
| 1444 | getPrStats(repoIds, days), | |
| 1445 | getGateStats(repoIds, days), | |
| 1446 | getHealthIndicators(repoIds), | |
| 1447 | getAiImpact(repoIds, days), | |
| 1448 | getTopContributors(repoIds, days), | |
| 1449 | ]); | |
| 1450 | ||
| 1451 | return c.json({ | |
| 1452 | ok: true, | |
| 1453 | period: { days }, | |
| 1454 | repos: repoIds.length, | |
| 1455 | velocity: { | |
| 1456 | prsMergedThisPeriod: prStats.mergedThisPeriod, | |
| 1457 | prsMergedLastPeriod: prStats.mergedLastPeriod, | |
| 1458 | avgMergeHours: prStats.avgMergeHours, | |
| 1459 | }, | |
| 1460 | quality: { | |
| 1461 | gatePassRate: gateStats.passRate, | |
| 1462 | gateRunsTotal: gateStats.totalRuns, | |
| 1463 | gatePassed: gateStats.passed, | |
| 1464 | gateFailed: gateStats.failed, | |
| 1465 | }, | |
| 1466 | health: { | |
| 1467 | stuckPrs: health.stuckPrs, | |
| 1468 | staleRepos: health.staleRepos, | |
| 1469 | abandonedIssues: health.abandonedIssues, | |
| 1470 | }, | |
| 1471 | ai: { | |
| 1472 | prsMergedByAi: aiImpact.aiMergedPrs, | |
| 1473 | securityIssuesOpened: aiImpact.aiSecurityIssues, | |
| 1474 | reviewCommentsLeft: aiImpact.aiReviewComments, | |
| 1475 | estimatedHoursSaved: aiImpact.estimatedHoursSaved, | |
| 1476 | }, | |
| 1477 | topContributors: contributors.slice(0, 5).map((c) => ({ | |
| 1478 | username: c.username, | |
| 1479 | prsMerged: c.prsMerged, | |
| 1480 | prsOpened: c.prsOpened, | |
| 1481 | reviewsGiven: c.reviewsGiven, | |
| 1482 | avgMergeHours: c.avgMergeHours, | |
| 1483 | })), | |
| 1484 | }); | |
| 1485 | }); | |
| 1486 | ||
| 1487 | // ─── Per-repo engineering insights ─────────────────────────────────────────── | |
| 1488 | ||
| 1489 | engineeringInsights.get( | |
| 1490 | "/:owner/:repo/insights/engineering", | |
| 1491 | async (c) => { | |
| 1492 | const { owner, repo } = c.req.param(); | |
| 1493 | const user = c.get("user"); | |
| 1494 | const rangeParam = c.req.query("range") || "30"; | |
| 1495 | const days = | |
| 1496 | rangeParam === "7" ? 7 : rangeParam === "90" ? 90 : rangeParam === "365" ? 365 : 30; | |
| 1497 | const rangeLabel = days === 7 ? "7d" : days === 90 ? "90d" : days === 365 ? "12mo" : "30d"; | |
| 1498 | const notifCount = user ? await getUnreadCount(user.id) : 0; | |
| 1499 | ||
| 1500 | // Resolve repo | |
| 1501 | const repoRows = await db | |
| 1502 | .select({ id: repositories.id, ownerId: repositories.ownerId }) | |
| 1503 | .from(repositories) | |
| 1504 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 1505 | .where( | |
| 1506 | and( | |
| 1507 | eq(users.username, owner), | |
| 1508 | eq(repositories.name, repo) | |
| 1509 | ) | |
| 1510 | ) | |
| 1511 | .limit(1); | |
| 1512 | ||
| 1513 | if (repoRows.length === 0) return c.notFound(); | |
| 1514 | const repoId = repoRows[0].id; | |
| 1515 | const repoIds = [repoId]; | |
| 1516 | ||
| 1517 | const [prStats, gateStats, weeklyVelocity, contributors, health, aiImpact] = | |
| 1518 | await Promise.all([ | |
| 1519 | getPrStats(repoIds, days), | |
| 1520 | getGateStats(repoIds, days), | |
| 1521 | getWeeklyVelocity(repoIds, 8), | |
| 1522 | getTopContributors(repoIds, days), | |
| 1523 | getHealthIndicators(repoIds), | |
| 1524 | getAiImpact(repoIds, days), | |
| 1525 | ]); | |
| 1526 | ||
| 1527 | const prDeltaPct = | |
| 1528 | prStats.mergedLastPeriod > 0 | |
| 1529 | ? Math.round( | |
| 1530 | ((prStats.mergedThisPeriod - prStats.mergedLastPeriod) / | |
| 1531 | prStats.mergedLastPeriod) * | |
| 1532 | 100 | |
| 1533 | ) | |
| 1534 | : 0; | |
| 1535 | ||
| 1536 | return c.html( | |
| 1537 | <Layout | |
| 1538 | title={`Engineering Insights — ${owner}/${repo}`} | |
| 1539 | user={user} | |
| 1540 | notificationCount={notifCount} | |
| 1541 | > | |
| 1542 | <style dangerouslySetInnerHTML={{ __html: styles }} /> | |
| 1543 | <RepoHeader owner={owner} repo={repo} /> | |
| 1544 | <RepoNav owner={owner} repo={repo} active="insights" /> | |
| 1545 | ||
| 1546 | <div class="ei-wrap"> | |
| 1547 | {/* Hero */} | |
| 1548 | <section class="ei-hero"> | |
| 1549 | <div class="ei-hero-orb" aria-hidden="true" /> | |
| 1550 | <div class="ei-hero-inner"> | |
| 1551 | <div class="ei-hero-top"> | |
| 1552 | <div> | |
| 1553 | <div class="ei-eyebrow"> | |
| 1554 | <span class="ei-eyebrow-dot" aria-hidden="true" /> | |
| 1555 | Engineering Intelligence · {owner}/{repo} | |
| 1556 | </div> | |
| 1557 | <h1 class="ei-hero-title"> | |
| 1558 | <span class="ei-hero-title-grad">Velocity.</span>{" "} | |
| 1559 | Quality. AI Impact. | |
| 1560 | </h1> | |
| 1561 | <p class="ei-hero-sub"> | |
| 1562 | Shipping velocity, gate health, contributor rankings, and AI | |
| 1563 | impact — all scoped to this repository. | |
| 1564 | </p> | |
| 1565 | </div> | |
| 1566 | {/* Time range + sub-nav */} | |
| 1567 | <div> | |
| 1568 | <div class="ei-range-label">Time range</div> | |
| 1569 | <div class="ei-range-bar"> | |
| 1570 | {(["7", "30", "90", "365"] as const).map((r) => ( | |
| 1571 | <a | |
| 1572 | href={`/${owner}/${repo}/insights/engineering?range=${r}`} | |
| 1573 | class={`ei-range-btn${rangeParam === r ? " active" : ""}`} | |
| 1574 | > | |
| 1575 | {r === "365" ? "12mo" : `${r}d`} | |
| 1576 | </a> | |
| 1577 | ))} | |
| 1578 | </div> | |
| 1579 | </div> | |
| 1580 | </div> | |
| 1581 | </div> | |
| 1582 | </section> | |
| 1583 | ||
| 1584 | {/* Insights sub-nav */} | |
| 1585 | <div style="display:flex;gap:4px;margin-bottom:var(--space-5);border-bottom:1px solid var(--border);padding-bottom:0"> | |
| 1586 | <a href={`/${owner}/${repo}/insights`} style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--text-muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px"> | |
| 1587 | Code Intelligence | |
| 1588 | </a> | |
| 1589 | <a href={`/${owner}/${repo}/insights/velocity`} style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--text-muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px"> | |
| 1590 | Velocity | |
| 1591 | </a> | |
| 1592 | <a href={`/${owner}/${repo}/insights/dora`} style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--text-muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px"> | |
| 1593 | DORA | |
| 1594 | </a> | |
| 1595 | <a href={`/${owner}/${repo}/insights/health`} style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--text-muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px"> | |
| 1596 | Health | |
| 1597 | </a> | |
| 1598 | <a href={`/${owner}/${repo}/insights/engineering`} | |
| 1599 | style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--accent,#5865f2);text-decoration:none;border-bottom:2px solid var(--accent,#5865f2);margin-bottom:-1px"> | |
| 1600 | Engineering | |
| 1601 | </a> | |
| 1602 | </div> | |
| 1603 | ||
| 1604 | {/* KPI Row */} | |
| 1605 | <div class="ei-kpi-row"> | |
| 1606 | <div class="ei-kpi"> | |
| 1607 | <div class="ei-kpi-icon ei-kpi-icon-blue">{"⬆"}</div> | |
| 1608 | <div class="ei-kpi-label">PRs Merged</div> | |
| 1609 | <div class="ei-kpi-value">{prStats.mergedThisPeriod}</div> | |
| 1610 | {prDeltaPct !== 0 && ( | |
| 1611 | <div class={`ei-kpi-delta ${prDeltaPct > 0 ? "up" : "down"}`}> | |
| 1612 | <span>{prDeltaPct > 0 ? "▲" : "▼"}</span> | |
| 1613 | {Math.abs(prDeltaPct)}% vs last {rangeLabel} | |
| 1614 | </div> | |
| 1615 | )} | |
| 1616 | <div class="ei-kpi-sub">This {rangeLabel}</div> | |
| 1617 | </div> | |
| 1618 | ||
| 1619 | <div class="ei-kpi"> | |
| 1620 | <div class="ei-kpi-icon ei-kpi-icon-green">{"⏱"}</div> | |
| 1621 | <div class="ei-kpi-label">Avg Merge Time</div> | |
| 1622 | <div class={`ei-kpi-value${prStats.avgMergeHours === null ? " na" : ""}`}> | |
| 1623 | {fmtHours(prStats.avgMergeHours)} | |
| 1624 | </div> | |
| 1625 | <div class="ei-kpi-sub">Open → merge, merged PRs</div> | |
| 1626 | </div> | |
| 1627 | ||
| 1628 | <div class="ei-kpi"> | |
| 1629 | <div class="ei-kpi-icon ei-kpi-icon-amber">{"✓"}</div> | |
| 1630 | <div class="ei-kpi-label">Gate Pass Rate</div> | |
| 1631 | <div class={`ei-kpi-value${gateStats.passRate === null ? " na" : ""}`}> | |
| 1632 | {gateStats.passRate !== null | |
| 1633 | ? `${Math.round(gateStats.passRate)}%` | |
| 1634 | : "—"} | |
| 1635 | </div> | |
| 1636 | <div class="ei-kpi-sub"> | |
| 1637 | {gateStats.passed}/{gateStats.totalRuns} passed this {rangeLabel} | |
| 1638 | </div> | |
| 1639 | </div> | |
| 1640 | ||
| 1641 | <div class="ei-kpi"> | |
| 1642 | <div class="ei-kpi-icon ei-kpi-icon-purple">{"✨"}</div> | |
| 1643 | <div class="ei-kpi-label">AI Impact</div> | |
| 1644 | <div class="ei-kpi-value">{aiImpact.aiMergedPrs}</div> | |
| 1645 | <div class="ei-kpi-sub"> | |
| 1646 | PRs auto-merged · ~{aiImpact.estimatedHoursSaved}h saved | |
| 1647 | </div> | |
| 1648 | </div> | |
| 1649 | </div> | |
| 1650 | ||
| 1651 | {/* Alerts */} | |
| 1652 | {(health.stuckPrs > 0 || health.staleRepos > 0 || health.abandonedIssues > 0) && ( | |
| 1653 | <div class="ei-alerts"> | |
| 1654 | {health.stuckPrs > 0 && ( | |
| 1655 | <div class="ei-alert red"> | |
| 1656 | <span class="ei-alert-icon">{"⚠"}</span> | |
| 1657 | <div> | |
| 1658 | <strong> | |
| 1659 | {health.stuckPrs} PR{health.stuckPrs !== 1 ? "s" : ""} open | |
| 1660 | {" "}{">"} 7 days in this repo | |
| 1661 | </strong>{" "} | |
| 1662 | — unblock them to maintain team velocity. | |
| 1663 | </div> | |
| 1664 | </div> | |
| 1665 | )} | |
| 1666 | {health.abandonedIssues > 0 && ( | |
| 1667 | <div class="ei-alert amber"> | |
| 1668 | <span class="ei-alert-icon">{"!"}</span> | |
| 1669 | <div> | |
| 1670 | <strong> | |
| 1671 | {health.abandonedIssues} issue | |
| 1672 | {health.abandonedIssues !== 1 ? "s" : ""} open {">"} 30 days | |
| 1673 | without comments | |
| 1674 | </strong>{" "} | |
| 1675 | — consider triaging or closing these. | |
| 1676 | </div> | |
| 1677 | </div> | |
| 1678 | )} | |
| 1679 | </div> | |
| 1680 | )} | |
| 1681 | ||
| 1682 | {/* Velocity chart */} | |
| 1683 | <section class="ei-section"> | |
| 1684 | <div class="ei-section-head"> | |
| 1685 | <h2 class="ei-section-title">PR Velocity</h2> | |
| 1686 | <span class="ei-section-sub">PRs merged per week, last 8 weeks</span> | |
| 1687 | </div> | |
| 1688 | <VelocityChart buckets={weeklyVelocity} /> | |
| 1689 | </section> | |
| 1690 | ||
| 1691 | <div class="ei-two-col"> | |
| 1692 | {/* Contributors */} | |
| 1693 | <section class="ei-section"> | |
| 1694 | <div class="ei-section-head"> | |
| 1695 | <h2 class="ei-section-title">Top Contributors</h2> | |
| 1696 | <span class="ei-section-sub"> | |
| 1697 | <a href={`/${owner}/${repo}/contributors`} style="color:var(--accent);text-decoration:none"> | |
| 1698 | View all → | |
| 1699 | </a> | |
| 1700 | </span> | |
| 1701 | </div> | |
| 1702 | {contributors.length === 0 ? ( | |
| 1703 | <div class="ei-empty"> | |
| 1704 | <strong>No contributor data yet</strong> | |
| 1705 | Merge some PRs to see rankings. | |
| 1706 | </div> | |
| 1707 | ) : ( | |
| 1708 | <div class="ei-table-wrap"> | |
| 1709 | <table class="ei-table"> | |
| 1710 | <thead> | |
| 1711 | <tr> | |
| 1712 | <th>Developer</th> | |
| 1713 | <th>Merged</th> | |
| 1714 | <th>Reviews</th> | |
| 1715 | <th>Avg TTM</th> | |
| 1716 | </tr> | |
| 1717 | </thead> | |
| 1718 | <tbody> | |
| 1719 | {contributors.slice(0, 7).map((c, i) => ( | |
| 1720 | <tr> | |
| 1721 | <td> | |
| 1722 | <div class="ei-contributor-cell"> | |
| 1723 | <span | |
| 1724 | class={`ei-contributor-rank${i === 0 ? " top1" : i === 1 ? " top2" : i === 2 ? " top3" : ""}`} | |
| 1725 | > | |
| 1726 | {i + 1} | |
| 1727 | </span> | |
| 1728 | <a href={`/${c.username}`} style="color:var(--text);text-decoration:none;font-weight:500"> | |
| 1729 | {c.displayName || c.username} | |
| 1730 | </a> | |
| 1731 | </div> | |
| 1732 | </td> | |
| 1733 | <td>{c.prsMerged}</td> | |
| 1734 | <td>{c.reviewsGiven}</td> | |
| 1735 | <td>{fmtHours(c.avgMergeHours)}</td> | |
| 1736 | </tr> | |
| 1737 | ))} | |
| 1738 | </tbody> | |
| 1739 | </table> | |
| 1740 | </div> | |
| 1741 | )} | |
| 1742 | </section> | |
| 1743 | ||
| 1744 | {/* Health indicators */} | |
| 1745 | <section class="ei-section"> | |
| 1746 | <div class="ei-section-head"> | |
| 1747 | <h2 class="ei-section-title">Health Snapshot</h2> | |
| 1748 | <span class="ei-section-sub"> | |
| 1749 | <a href={`/${owner}/${repo}/insights/health`} style="color:var(--accent);text-decoration:none"> | |
| 1750 | Full report → | |
| 1751 | </a> | |
| 1752 | </span> | |
| 1753 | </div> | |
| 1754 | <div class="ei-health-grid"> | |
| 1755 | <div class={`ei-health-card ${health.stuckPrs > 0 ? "red" : "green"}`}> | |
| 1756 | <div class={`ei-health-badge ${health.stuckPrs > 0 ? "red" : "green"}`}> | |
| 1757 | {health.stuckPrs > 0 ? "⚠ Stuck" : "✓ Clear"} | |
| 1758 | </div> | |
| 1759 | <div class="ei-health-num">{health.stuckPrs}</div> | |
| 1760 | <div class="ei-health-label">PRs open {">"} 7 days</div> | |
| 1761 | </div> | |
| 1762 | <div class={`ei-health-card ${health.abandonedIssues > 0 ? "amber" : "green"}`}> | |
| 1763 | <div class={`ei-health-badge ${health.abandonedIssues > 0 ? "amber" : "green"}`}> | |
| 1764 | {health.abandonedIssues > 0 ? "! Backlog" : "✓ Active"} | |
| 1765 | </div> | |
| 1766 | <div class="ei-health-num">{health.abandonedIssues}</div> | |
| 1767 | <div class="ei-health-label">Abandoned issues</div> | |
| 1768 | </div> | |
| 1769 | </div> | |
| 1770 | </section> | |
| 1771 | </div> | |
| 1772 | ||
| 1773 | {/* AI Impact */} | |
| 1774 | <section class="ei-section"> | |
| 1775 | <div class="ei-section-head"> | |
| 1776 | <h2 class="ei-section-title">{"✨ "}AI Impact</h2> | |
| 1777 | <span class="ei-section-sub">Last {rangeLabel}</span> | |
| 1778 | </div> | |
| 1779 | <div class="ei-ai-box"> | |
| 1780 | <div class="ei-ai-grid"> | |
| 1781 | <div class="ei-ai-stat"> | |
| 1782 | <div class="ei-ai-stat-val">{aiImpact.aiMergedPrs}</div> | |
| 1783 | <div class="ei-ai-stat-label">PRs auto-merged by AI</div> | |
| 1784 | </div> | |
| 1785 | <div class="ei-ai-stat"> | |
| 1786 | <div class="ei-ai-stat-val">~{aiImpact.estimatedHoursSaved}h</div> | |
| 1787 | <div class="ei-ai-stat-label">Estimated hours saved</div> | |
| 1788 | </div> | |
| 1789 | <div class="ei-ai-stat"> | |
| 1790 | <div class="ei-ai-stat-val">{aiImpact.aiSecurityIssues}</div> | |
| 1791 | <div class="ei-ai-stat-label">Security issues opened by AI</div> | |
| 1792 | </div> | |
| 1793 | <div class="ei-ai-stat"> | |
| 1794 | <div class="ei-ai-stat-val">{aiImpact.aiReviewComments}</div> | |
| 1795 | <div class="ei-ai-stat-label">AI review comments</div> | |
| 1796 | </div> | |
| 1797 | </div> | |
| 1798 | {(aiImpact.aiMergedPrs > 0 || aiImpact.aiReviewComments > 0) && ( | |
| 1799 | <div class="ei-ai-summary-line"> | |
| 1800 | {aiImpact.aiMergedPrs > 0 && ( | |
| 1801 | <> | |
| 1802 | AI auto-merged{" "} | |
| 1803 | <strong>{aiImpact.aiMergedPrs} PRs</strong>, saving | |
| 1804 | ~<strong>{aiImpact.estimatedHoursSaved} hours</strong> of | |
| 1805 | review time.{" "} | |
| 1806 | </> | |
| 1807 | )} | |
| 1808 | {aiImpact.aiReviewComments > 0 && ( | |
| 1809 | <> | |
| 1810 | AI left{" "} | |
| 1811 | <strong>{aiImpact.aiReviewComments} review comments</strong>{" "} | |
| 1812 | and opened{" "} | |
| 1813 | <strong>{aiImpact.aiSecurityIssues} security issues</strong>. | |
| 1814 | </> | |
| 1815 | )} | |
| 1816 | </div> | |
| 1817 | )} | |
| 1818 | </div> | |
| 1819 | </section> | |
| 1820 | </div> | |
| 1821 | </Layout> | |
| 1822 | ); | |
| 1823 | } | |
| 1824 | ); | |
| 1825 | ||
| 1826 | export default engineeringInsights; |