CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
issues-dashboard.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.
| e9aa4d8 | 1 | /** |
| 2 | * `/issues` — Global issues dashboard. | |
| 3 | * | |
| 4 | * Mirrors `/pulls`: hero with gradient hairline + orb, four tabular-nums | |
| 5 | * stat counters, pill tab strip, and a list of issues. The differentiator | |
| 6 | * over a GitHub-style "issues I authored" page is Gluecron-native AI | |
| 7 | * signal on every row: | |
| 8 | * | |
| 9 | * - AI-TRIAGED — any label starting with `ai:` (autopilot has | |
| 10 | * classified it) | |
| 11 | * - SUGGESTED-FIX — an `issue_comments` row carries the | |
| 12 | * ISSUE_TRIAGE_MARKER (Claude has posted a | |
| 13 | * triage / suggested patch comment) | |
| 14 | * - BUILD-IN-PROGRESS — labelled `ai:build` or `ai:in-progress` | |
| 15 | * (autopilot is actively working on it) | |
| 16 | * - COMMENT-COUNT — tabular-nums per row | |
| 17 | * | |
| 18 | * Filters: | |
| 19 | * - mine — issues you opened | |
| 20 | * - assigned — issues in repos you own / collaborate on | |
| 21 | * - ai-working — autopilot is currently processing this one | |
| 22 | * - needs-triage — open issues with NO ai:* label yet | |
| 23 | * | |
| 24 | * Scoped CSS under `.idash-*`. Does not touch shared layout / components. | |
| 25 | */ | |
| 26 | ||
| 27 | import { Hono } from "hono"; | |
| 28 | import { | |
| 29 | and, | |
| 30 | desc, | |
| 31 | eq, | |
| 32 | inArray, | |
| 33 | isNotNull, | |
| 34 | like, | |
| 35 | sql, | |
| 36 | } from "drizzle-orm"; | |
| 37 | import { db } from "../db"; | |
| 38 | import { | |
| 39 | issues, | |
| 40 | issueComments, | |
| 41 | issueLabels, | |
| 42 | labels, | |
| 43 | repositories, | |
| 44 | repoCollaborators, | |
| 45 | users, | |
| 46 | } from "../db/schema"; | |
| 47 | import { Layout } from "../views/layout"; | |
| 48 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 49 | import type { AuthEnv } from "../middleware/auth"; | |
| 50 | import { ISSUE_TRIAGE_MARKER } from "../lib/issue-triage"; | |
| 51 | ||
| 52 | const issuesDashboard = new Hono<AuthEnv>(); | |
| 53 | issuesDashboard.use("*", softAuth); | |
| 54 | ||
| 55 | const styles = ` | |
| eed4684 | 56 | .idash-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); } |
| e9aa4d8 | 57 | |
| 58 | .idash-hero { | |
| 59 | position: relative; | |
| 60 | margin-bottom: var(--space-5); | |
| 61 | padding: var(--space-5) var(--space-6); | |
| 62 | background: var(--bg-elevated); | |
| 63 | border: 1px solid var(--border); | |
| 64 | border-radius: 16px; | |
| 65 | overflow: hidden; | |
| 66 | } | |
| 67 | .idash-hero::before { | |
| 68 | content: ''; | |
| 69 | position: absolute; | |
| 70 | top: 0; left: 0; right: 0; | |
| 71 | height: 2px; | |
| 72 | background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%); | |
| 73 | opacity: 0.7; | |
| 74 | pointer-events: none; | |
| 75 | } | |
| 76 | .idash-orb { | |
| 77 | position: absolute; | |
| 78 | inset: -30% -10% auto auto; | |
| 79 | width: 380px; height: 380px; | |
| 80 | background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%); | |
| 81 | filter: blur(80px); | |
| 82 | opacity: 0.7; | |
| 83 | pointer-events: none; | |
| 84 | } | |
| 85 | .idash-hero-inner { position: relative; z-index: 1; max-width: 760px; } | |
| 86 | .idash-eyebrow { | |
| 87 | font-size: 12.5px; | |
| 88 | color: var(--text-muted); | |
| 89 | margin-bottom: 8px; | |
| 90 | letter-spacing: 0.04em; | |
| 91 | text-transform: uppercase; | |
| 92 | font-weight: 600; | |
| 93 | } | |
| 94 | .idash-title { | |
| 95 | font-family: var(--font-display); | |
| 96 | font-size: clamp(28px, 4vw, 40px); | |
| 97 | font-weight: 800; | |
| 98 | letter-spacing: -0.028em; | |
| 99 | line-height: 1.05; | |
| 100 | margin: 0 0 10px; | |
| 101 | color: var(--text-strong); | |
| 102 | } | |
| 103 | .idash-title-grad { | |
| 104 | background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%); | |
| 105 | -webkit-background-clip: text; | |
| 106 | background-clip: text; | |
| 107 | -webkit-text-fill-color: transparent; | |
| 108 | color: transparent; | |
| 109 | } | |
| 110 | .idash-sub { | |
| 111 | font-size: 15px; | |
| 112 | color: var(--text-muted); | |
| 113 | margin: 0; | |
| 114 | line-height: 1.5; | |
| 115 | } | |
| 116 | ||
| 117 | .idash-stats { | |
| 118 | display: flex; | |
| 119 | gap: 24px; | |
| 120 | margin-top: 16px; | |
| 121 | flex-wrap: wrap; | |
| 122 | } | |
| 123 | .idash-stat { | |
| 124 | display: flex; | |
| 125 | flex-direction: column; | |
| 126 | gap: 2px; | |
| 127 | } | |
| 128 | .idash-stat-n { | |
| 129 | font-family: var(--font-display); | |
| 130 | font-size: 22px; | |
| 131 | font-weight: 700; | |
| 132 | color: var(--text-strong); | |
| 133 | font-variant-numeric: tabular-nums; | |
| 134 | } | |
| 135 | .idash-stat-l { | |
| 136 | font-size: 11.5px; | |
| 137 | color: var(--text-muted); | |
| 138 | letter-spacing: 0.02em; | |
| 139 | text-transform: uppercase; | |
| 140 | } | |
| 141 | ||
| 142 | .idash-tabs { | |
| 143 | display: inline-flex; | |
| 144 | background: var(--bg-elevated); | |
| 145 | border: 1px solid var(--border); | |
| 146 | border-radius: 9999px; | |
| 147 | padding: 4px; | |
| 148 | gap: 2px; | |
| 149 | margin-bottom: var(--space-4); | |
| 150 | flex-wrap: wrap; | |
| 151 | } | |
| 152 | .idash-tab { | |
| 153 | display: inline-flex; | |
| 154 | align-items: center; | |
| 155 | gap: 6px; | |
| 156 | padding: 7px 14px; | |
| 157 | border-radius: 9999px; | |
| 158 | font-size: 13.5px; | |
| 159 | font-weight: 500; | |
| 160 | color: var(--text-muted); | |
| 161 | text-decoration: none; | |
| 162 | transition: color 120ms ease, background 120ms ease; | |
| 163 | } | |
| 164 | .idash-tab:hover { color: var(--text-strong); text-decoration: none; } | |
| 165 | .idash-tab.is-active { | |
| 166 | background: rgba(140,109,255,0.14); | |
| 167 | color: var(--text-strong); | |
| 168 | } | |
| 169 | .idash-tab-count { | |
| 170 | font-variant-numeric: tabular-nums; | |
| 171 | font-size: 11.5px; | |
| 172 | background: rgba(255,255,255,0.04); | |
| 173 | padding: 1px 7px; | |
| 174 | border-radius: 9999px; | |
| 175 | } | |
| 176 | .idash-tab.is-active .idash-tab-count { | |
| 177 | background: rgba(140,109,255,0.22); | |
| 178 | color: var(--text); | |
| 179 | } | |
| 180 | ||
| 181 | .idash-list { | |
| 182 | list-style: none; | |
| 183 | margin: 0; | |
| 184 | padding: 0; | |
| 185 | border: 1px solid var(--border); | |
| 186 | border-radius: 12px; | |
| 187 | overflow: hidden; | |
| 188 | background: var(--bg-elevated); | |
| 189 | } | |
| 190 | .idash-row { | |
| 191 | display: flex; | |
| 192 | align-items: flex-start; | |
| 193 | gap: 14px; | |
| 194 | padding: 14px 18px; | |
| 195 | border-bottom: 1px solid var(--border); | |
| 196 | transition: background 120ms ease; | |
| 197 | } | |
| 198 | .idash-row:last-child { border-bottom: none; } | |
| 199 | .idash-row:hover { background: rgba(140,109,255,0.04); } | |
| 200 | .idash-row-icon { | |
| 201 | width: 18px; height: 18px; | |
| 202 | flex-shrink: 0; | |
| 203 | margin-top: 3px; | |
| 204 | display: inline-flex; | |
| 205 | align-items: center; | |
| 206 | justify-content: center; | |
| 207 | } | |
| 208 | .idash-row-icon.is-open { color: #34d399; } | |
| 209 | .idash-row-icon.is-closed { color: #b69dff; } | |
| 210 | .idash-row-main { flex: 1; min-width: 0; } | |
| 211 | .idash-row-title { | |
| 212 | font-family: var(--font-display); | |
| 213 | font-size: 15.5px; | |
| 214 | font-weight: 600; | |
| 215 | line-height: 1.35; | |
| 216 | letter-spacing: -0.012em; | |
| 217 | margin: 0 0 6px; | |
| 218 | display: flex; | |
| 219 | flex-wrap: wrap; | |
| 220 | align-items: center; | |
| 221 | gap: 8px; | |
| 222 | } | |
| 223 | .idash-row-title a { | |
| 224 | color: var(--text-strong); | |
| 225 | text-decoration: none; | |
| 226 | } | |
| 227 | .idash-row-title a:hover { color: var(--accent); } | |
| 228 | .idash-row-repo { | |
| 229 | font-family: var(--font-mono); | |
| 230 | font-size: 12px; | |
| 231 | color: var(--text-muted); | |
| 232 | } | |
| 233 | .idash-row-meta { | |
| 234 | font-size: 12.5px; | |
| 235 | color: var(--text-muted); | |
| 236 | font-variant-numeric: tabular-nums; | |
| 237 | display: flex; | |
| 238 | gap: 10px; | |
| 239 | flex-wrap: wrap; | |
| 240 | align-items: center; | |
| 241 | } | |
| 242 | .idash-row-meta .sep { opacity: 0.45; } | |
| 243 | .idash-row-comments { | |
| 244 | display: inline-flex; | |
| 245 | align-items: center; | |
| 246 | gap: 4px; | |
| 247 | font-variant-numeric: tabular-nums; | |
| 248 | } | |
| 249 | ||
| 250 | /* Gluecron-native badges: AI triage, suggested fix, in-progress */ | |
| 251 | .idash-badges { | |
| 252 | display: inline-flex; | |
| 253 | align-items: center; | |
| 254 | gap: 6px; | |
| 255 | flex-wrap: wrap; | |
| 256 | margin-left: auto; | |
| 257 | } | |
| 258 | .idash-badge { | |
| 259 | display: inline-flex; | |
| 260 | align-items: center; | |
| 261 | gap: 4px; | |
| 262 | padding: 2px 8px; | |
| 263 | font-size: 11px; | |
| 264 | font-weight: 600; | |
| 265 | border-radius: 9999px; | |
| 266 | letter-spacing: 0.02em; | |
| 267 | font-variant-numeric: tabular-nums; | |
| 268 | } | |
| 269 | .idash-badge .dot { | |
| 270 | width: 6px; height: 6px; | |
| 271 | border-radius: 9999px; | |
| 272 | background: currentColor; | |
| 273 | } | |
| 274 | .idash-badge.ai-triaged { color: #93c5fd; background: rgba(96,165,250,0.12); box-shadow: inset 0 0 0 1px rgba(96,165,250,0.32); } | |
| 275 | .idash-badge.ai-fix { color: #6ee7b7; background: rgba(52,211,153,0.13); box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); } | |
| 276 | .idash-badge.ai-progress { color: #b69dff; background: rgba(140,109,255,0.18); box-shadow: inset 0 0 0 1px rgba(140,109,255,0.36); } | |
| 277 | .idash-badge.ai-progress .dot { animation: idashPulse 1.6s ease-in-out infinite; } | |
| 278 | ||
| 279 | @keyframes idashPulse { | |
| 280 | 0%, 100% { opacity: 1; transform: scale(1); } | |
| 281 | 50% { opacity: 0.4; transform: scale(0.7); } | |
| 282 | } | |
| 283 | ||
| 284 | .idash-state-pill { | |
| 285 | display: inline-flex; | |
| 286 | align-items: center; | |
| 287 | gap: 5px; | |
| 288 | padding: 2px 9px; | |
| 289 | font-size: 11px; | |
| 290 | font-weight: 600; | |
| 291 | border-radius: 9999px; | |
| 292 | letter-spacing: 0.02em; | |
| 293 | text-transform: uppercase; | |
| 294 | } | |
| 295 | .idash-state-pill.is-open { background: rgba(52,211,153,0.13); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); } | |
| 296 | .idash-state-pill.is-closed { background: rgba(140,109,255,0.16); color: #b69dff; box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32); } | |
| 297 | ||
| 298 | .idash-empty { | |
| 299 | padding: 60px 20px; | |
| 300 | text-align: center; | |
| 301 | border: 1px dashed var(--border-strong); | |
| 302 | border-radius: 14px; | |
| 303 | background: var(--bg-elevated); | |
| 304 | position: relative; | |
| 305 | overflow: hidden; | |
| 306 | } | |
| 307 | .idash-empty::before { | |
| 308 | content: ''; | |
| 309 | position: absolute; | |
| 310 | inset: -20% -10% auto auto; | |
| 311 | width: 320px; height: 320px; | |
| 312 | background: radial-gradient(circle, rgba(140,109,255,0.10), transparent 70%); | |
| 313 | filter: blur(60px); | |
| 314 | pointer-events: none; | |
| 315 | } | |
| 316 | .idash-empty-title { | |
| 317 | font-family: var(--font-display); | |
| 318 | font-size: 22px; | |
| 319 | font-weight: 700; | |
| 320 | color: var(--text-strong); | |
| 321 | margin: 0 0 6px; | |
| 322 | position: relative; | |
| 323 | } | |
| 324 | .idash-empty-sub { | |
| 325 | color: var(--text-muted); | |
| 326 | font-size: 14px; | |
| 327 | margin: 0 0 18px; | |
| 328 | position: relative; | |
| 329 | } | |
| 330 | .idash-empty .btn { position: relative; } | |
| 331 | ||
| 332 | @media (max-width: 720px) { | |
| 333 | .idash-hero { padding: 24px 20px; } | |
| 334 | .idash-row { padding: 12px 14px; flex-direction: column; } | |
| 335 | .idash-badges { margin-left: 0; } | |
| 336 | } | |
| 337 | `; | |
| 338 | ||
| 339 | type IssueFilter = "mine" | "assigned" | "ai-working" | "needs-triage"; | |
| 340 | ||
| 341 | const VALID_FILTERS: IssueFilter[] = [ | |
| 342 | "mine", | |
| 343 | "assigned", | |
| 344 | "ai-working", | |
| 345 | "needs-triage", | |
| 346 | ]; | |
| 347 | ||
| 348 | // Label-name prefixes that indicate autopilot has touched this issue. | |
| 349 | // `ai:build` and `ai:in-progress` specifically mean "currently working". | |
| 350 | const AI_LABEL_PREFIX = "ai:"; | |
| 351 | const AI_WORKING_LABELS = ["ai:build", "ai:in-progress"]; | |
| 352 | ||
| 353 | function relTime(d: Date): string { | |
| 354 | const diff = Date.now() - d.getTime(); | |
| 355 | const s = Math.floor(diff / 1000); | |
| 356 | if (s < 60) return `${s}s ago`; | |
| 357 | const m = Math.floor(s / 60); | |
| 358 | if (m < 60) return `${m}m ago`; | |
| 359 | const h = Math.floor(m / 60); | |
| 360 | if (h < 24) return `${h}h ago`; | |
| 361 | const days = Math.floor(h / 24); | |
| 362 | if (days < 30) return `${days}d ago`; | |
| 363 | const months = Math.floor(days / 30); | |
| 364 | if (months < 12) return `${months}mo ago`; | |
| 365 | return `${Math.floor(months / 12)}y ago`; | |
| 366 | } | |
| 367 | ||
| 368 | function StateIcon({ state }: { state: string }) { | |
| 369 | if (state === "closed") { | |
| 370 | return ( | |
| 371 | <span class="idash-row-icon is-closed" aria-label="Closed"> | |
| 372 | <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| 373 | <path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5z" /> | |
| 374 | <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0z" /> | |
| 375 | </svg> | |
| 376 | </span> | |
| 377 | ); | |
| 378 | } | |
| 379 | return ( | |
| 380 | <span class="idash-row-icon is-open" aria-label="Open"> | |
| 381 | <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| 382 | <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" /> | |
| 383 | <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0z" /> | |
| 384 | </svg> | |
| 385 | </span> | |
| 386 | ); | |
| 387 | } | |
| 388 | ||
| 389 | function CommentIcon() { | |
| 390 | return ( | |
| 391 | <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| 392 | <path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25z" /> | |
| 393 | </svg> | |
| 394 | ); | |
| 395 | } | |
| 396 | ||
| 397 | interface IssueRow { | |
| 398 | issue: typeof issues.$inferSelect; | |
| 399 | repo: typeof repositories.$inferSelect; | |
| 400 | authorUsername: string; | |
| 401 | ownerUsername: string; | |
| 402 | aiTriaged: boolean; | |
| 403 | suggestedFix: boolean; | |
| 404 | buildInProgress: boolean; | |
| 405 | commentCount: number; | |
| 406 | } | |
| 407 | ||
| 408 | /** | |
| 409 | * Aggregate the Gluecron-native signal for one issue: AI-triaged? | |
| 410 | * Suggested-fix comment? Build in progress? Comment count? All | |
| 411 | * best-effort — any individual lookup failing returns the safest | |
| 412 | * default (false / 0) rather than throwing. | |
| 413 | */ | |
| 414 | async function enrichIssue(issueId: string): Promise<{ | |
| 415 | aiTriaged: boolean; | |
| 416 | suggestedFix: boolean; | |
| 417 | buildInProgress: boolean; | |
| 418 | commentCount: number; | |
| 419 | }> { | |
| 420 | let aiTriaged = false; | |
| 421 | let suggestedFix = false; | |
| 422 | let buildInProgress = false; | |
| 423 | let commentCount = 0; | |
| 424 | ||
| 425 | try { | |
| 426 | // One pass over the issue's labels — pulls names so we can check | |
| 427 | // both the generic `ai:` prefix (triaged) and the specific | |
| 428 | // "actively-working" labels in the same trip. | |
| 429 | const labelRows = await db | |
| 430 | .select({ name: labels.name }) | |
| 431 | .from(issueLabels) | |
| 432 | .innerJoin(labels, eq(labels.id, issueLabels.labelId)) | |
| 433 | .where(eq(issueLabels.issueId, issueId)); | |
| 434 | for (const row of labelRows) { | |
| 435 | const n = row.name.toLowerCase(); | |
| 436 | if (n.startsWith(AI_LABEL_PREFIX)) aiTriaged = true; | |
| 437 | if (AI_WORKING_LABELS.includes(n)) buildInProgress = true; | |
| 438 | } | |
| 439 | } catch { | |
| 440 | /* skip */ | |
| 441 | } | |
| 442 | ||
| 443 | try { | |
| 444 | // ISSUE_TRIAGE_MARKER lives in the rendered AI triage comment body. | |
| 445 | // Presence == Claude has posted a suggested-fix / triage payload. | |
| 446 | const [marker] = await db | |
| 447 | .select({ id: issueComments.id }) | |
| 448 | .from(issueComments) | |
| 449 | .where( | |
| 450 | and( | |
| 451 | eq(issueComments.issueId, issueId), | |
| 452 | like(issueComments.body, `%${ISSUE_TRIAGE_MARKER}%`) | |
| 453 | ) | |
| 454 | ) | |
| 455 | .limit(1); | |
| 456 | if (marker) suggestedFix = true; | |
| 457 | } catch { | |
| 458 | /* skip */ | |
| 459 | } | |
| 460 | ||
| 461 | try { | |
| cb5a796 | 462 | // Only approved comments count toward the public-facing dashboard |
| 463 | // tally — see drizzle/0066 and src/lib/comment-moderation.ts. | |
| e9aa4d8 | 464 | const countRows = await db |
| 465 | .select({ c: sql<number>`count(*)::int` }) | |
| 466 | .from(issueComments) | |
| cb5a796 | 467 | .where( |
| 468 | and( | |
| 469 | eq(issueComments.issueId, issueId), | |
| 470 | eq(issueComments.moderationStatus, "approved") | |
| 471 | ) | |
| 472 | ); | |
| e9aa4d8 | 473 | commentCount = Number(countRows[0]?.c ?? 0); |
| 474 | } catch { | |
| 475 | /* skip */ | |
| 476 | } | |
| 477 | ||
| 478 | return { aiTriaged, suggestedFix, buildInProgress, commentCount }; | |
| 479 | } | |
| 480 | ||
| 481 | issuesDashboard.get("/issues", requireAuth, async (c) => { | |
| 482 | const user = c.get("user")!; | |
| 483 | const raw = c.req.query("filter") || "mine"; | |
| 484 | const filter: IssueFilter = (VALID_FILTERS as string[]).includes(raw) | |
| 485 | ? (raw as IssueFilter) | |
| 486 | : "mine"; | |
| 487 | ||
| 488 | // Resolve the candidate repo set up-front for the "assigned" / | |
| 489 | // "ai-working" / "needs-triage" tabs. "mine" walks issues.authorId | |
| 490 | // directly and skips this lookup. | |
| 491 | let repoIds: string[] = []; | |
| 492 | if (filter !== "mine") { | |
| 493 | try { | |
| 494 | const ownedRepos = await db | |
| 495 | .select({ id: repositories.id }) | |
| 496 | .from(repositories) | |
| 497 | .where(eq(repositories.ownerId, user.id)); | |
| 498 | const collabRepos = await db | |
| 499 | .select({ id: repoCollaborators.repositoryId }) | |
| 500 | .from(repoCollaborators) | |
| 501 | .where( | |
| 502 | and( | |
| 503 | eq(repoCollaborators.userId, user.id), | |
| 504 | isNotNull(repoCollaborators.acceptedAt) | |
| 505 | ) | |
| 506 | ); | |
| 507 | repoIds = [ | |
| 508 | ...ownedRepos.map((r) => r.id), | |
| 509 | ...collabRepos.map((r) => r.id), | |
| 510 | ]; | |
| 511 | } catch (err) { | |
| 512 | console.error("[issues-dashboard] repo set lookup failed:", err); | |
| 513 | } | |
| 514 | } | |
| 515 | ||
| 516 | // Broad candidate fetch; Gluecron-native predicates (ai-working, | |
| 517 | // needs-triage) are applied post-enrichment so we don't push them | |
| 518 | // into SQL. | |
| 519 | let candidates: Array<{ | |
| 520 | issue: typeof issues.$inferSelect; | |
| 521 | repo: typeof repositories.$inferSelect; | |
| 522 | }> = []; | |
| 523 | try { | |
| 524 | if (filter === "mine") { | |
| 525 | candidates = await db | |
| 526 | .select({ issue: issues, repo: repositories }) | |
| 527 | .from(issues) | |
| 528 | .innerJoin(repositories, eq(repositories.id, issues.repositoryId)) | |
| 529 | .where(eq(issues.authorId, user.id)) | |
| 530 | .orderBy(desc(issues.updatedAt)) | |
| 531 | .limit(100); | |
| 532 | } else if (repoIds.length > 0) { | |
| 533 | candidates = await db | |
| 534 | .select({ issue: issues, repo: repositories }) | |
| 535 | .from(issues) | |
| 536 | .innerJoin(repositories, eq(repositories.id, issues.repositoryId)) | |
| 537 | .where( | |
| 538 | and( | |
| 539 | inArray(issues.repositoryId, repoIds), | |
| 540 | eq(issues.state, "open") | |
| 541 | ) | |
| 542 | ) | |
| 543 | .orderBy(desc(issues.updatedAt)) | |
| 544 | .limit(200); | |
| 545 | } | |
| 546 | } catch (err) { | |
| 547 | console.error("[issues-dashboard] candidate query failed:", err); | |
| 548 | } | |
| 549 | ||
| 550 | // Resolve author + owner usernames in one batched lookup. | |
| 551 | const userIds = new Set<string>(); | |
| 552 | for (const cand of candidates) { | |
| 553 | userIds.add(cand.issue.authorId); | |
| 554 | userIds.add(cand.repo.ownerId); | |
| 555 | } | |
| 556 | const usersById = new Map<string, string>(); | |
| 557 | if (userIds.size > 0) { | |
| 558 | try { | |
| 559 | const rows = await db | |
| 560 | .select({ id: users.id, username: users.username }) | |
| 561 | .from(users) | |
| 562 | .where(inArray(users.id, Array.from(userIds))); | |
| 563 | for (const r of rows) usersById.set(r.id, r.username); | |
| 564 | } catch (err) { | |
| 565 | console.error("[issues-dashboard] username lookup failed:", err); | |
| 566 | } | |
| 567 | } | |
| 568 | ||
| 569 | // Enrich + filter to the active tab. The two AI-shaped filters | |
| 570 | // (ai-working, needs-triage) only make sense after we know the | |
| 571 | // per-issue label set, so the SQL stays generic. | |
| 572 | const rows: IssueRow[] = []; | |
| 573 | for (const cand of candidates) { | |
| 574 | const enriched = await enrichIssue(cand.issue.id); | |
| 575 | const matches = | |
| 576 | filter === "mine" || | |
| 577 | filter === "assigned" || | |
| 578 | (filter === "ai-working" && enriched.buildInProgress) || | |
| 579 | (filter === "needs-triage" && | |
| 580 | cand.issue.state === "open" && | |
| 581 | !enriched.aiTriaged); | |
| 582 | if (!matches) continue; | |
| 583 | rows.push({ | |
| 584 | issue: cand.issue, | |
| 585 | repo: cand.repo, | |
| 586 | authorUsername: usersById.get(cand.issue.authorId) || "unknown", | |
| 587 | ownerUsername: usersById.get(cand.repo.ownerId) || "unknown", | |
| 588 | ...enriched, | |
| 589 | }); | |
| 590 | } | |
| 591 | ||
| 592 | // Hero counters — best-effort. SQL-side for the cheap ones; derived | |
| 593 | // from `rows` for the AI-shaped ones (which depend on enrichment | |
| 594 | // and are scoped to the candidates we already pulled). | |
| 595 | const counts = { mine: 0, assigned: 0, aiWorking: 0, needsTriage: 0 }; | |
| 596 | try { | |
| 597 | const mineRows = await db | |
| 598 | .select({ id: issues.id }) | |
| 599 | .from(issues) | |
| 600 | .where(eq(issues.authorId, user.id)); | |
| 601 | counts.mine = mineRows.length; | |
| 602 | if (repoIds.length > 0) { | |
| 603 | const assigned = await db | |
| 604 | .select({ id: issues.id }) | |
| 605 | .from(issues) | |
| 606 | .where( | |
| 607 | and( | |
| 608 | inArray(issues.repositoryId, repoIds), | |
| 609 | eq(issues.state, "open") | |
| 610 | ) | |
| 611 | ); | |
| 612 | counts.assigned = assigned.length; | |
| 613 | } | |
| 614 | } catch { | |
| 615 | /* keep 0s */ | |
| 616 | } | |
| 617 | ||
| 618 | if (filter === "mine" || filter === "assigned") { | |
| 619 | // For the AI-shaped counts we need enrichment. When the active | |
| 620 | // tab IS one of those, `rows` is filtered down already — so do a | |
| 621 | // cheap secondary pass over candidates for the headline. | |
| 622 | let working = 0; | |
| 623 | let triage = 0; | |
| 624 | for (const cand of candidates) { | |
| 625 | const enriched = await enrichIssue(cand.issue.id); | |
| 626 | if (enriched.buildInProgress) working++; | |
| 627 | if (cand.issue.state === "open" && !enriched.aiTriaged) triage++; | |
| 628 | } | |
| 629 | counts.aiWorking = working; | |
| 630 | counts.needsTriage = triage; | |
| 631 | } else { | |
| 632 | counts.aiWorking = rows.filter((r) => r.buildInProgress).length; | |
| 633 | counts.needsTriage = rows.filter( | |
| 634 | (r) => r.issue.state === "open" && !r.aiTriaged | |
| 635 | ).length; | |
| 636 | } | |
| 637 | ||
| 638 | return c.html( | |
| 639 | <Layout title="Issues · Gluecron" user={user}> | |
| 640 | <div class="idash-wrap"> | |
| 641 | <section class="idash-hero"> | |
| 642 | <div class="idash-orb" aria-hidden="true" /> | |
| 643 | <div class="idash-hero-inner"> | |
| 644 | <div class="idash-eyebrow"> | |
| 645 | Issue command center · live ·{" "} | |
| 646 | <span style="color:var(--accent);font-weight:600">{user.username}</span> | |
| 647 | </div> | |
| 648 | <h1 class="idash-title"> | |
| 649 | <span class="idash-title-grad">Your issues.</span> | |
| 650 | </h1> | |
| 651 | <p class="idash-sub"> | |
| 652 | Every issue across every repo you touch — annotated with | |
| 653 | the things only Gluecron knows. AI triage status, suggested | |
| 654 | fixes, and whether autopilot is currently building one, | |
| 655 | all live. | |
| 656 | </p> | |
| 657 | <div class="idash-stats"> | |
| 658 | <div class="idash-stat"> | |
| 659 | <div class="idash-stat-n">{counts.mine}</div> | |
| 660 | <div class="idash-stat-l">Authored</div> | |
| 661 | </div> | |
| 662 | <div class="idash-stat"> | |
| 663 | <div class="idash-stat-n">{counts.assigned}</div> | |
| 664 | <div class="idash-stat-l">In your repos</div> | |
| 665 | </div> | |
| 666 | <div class="idash-stat"> | |
| 667 | <div class="idash-stat-n">{counts.aiWorking}</div> | |
| 668 | <div class="idash-stat-l">AI working</div> | |
| 669 | </div> | |
| 670 | <div class="idash-stat"> | |
| 671 | <div class="idash-stat-n">{counts.needsTriage}</div> | |
| 672 | <div class="idash-stat-l">Needs triage</div> | |
| 673 | </div> | |
| 674 | </div> | |
| 675 | </div> | |
| 676 | </section> | |
| 677 | ||
| 678 | <nav class="idash-tabs" aria-label="Issue filters"> | |
| 679 | <a href="/issues?filter=mine" class={"idash-tab " + (filter === "mine" ? "is-active" : "")}> | |
| 680 | Mine <span class="idash-tab-count">{counts.mine}</span> | |
| 681 | </a> | |
| 682 | <a href="/issues?filter=assigned" class={"idash-tab " + (filter === "assigned" ? "is-active" : "")}> | |
| 683 | Assigned <span class="idash-tab-count">{counts.assigned}</span> | |
| 684 | </a> | |
| 685 | <a href="/issues?filter=ai-working" class={"idash-tab " + (filter === "ai-working" ? "is-active" : "")}> | |
| 686 | AI working <span class="idash-tab-count">{counts.aiWorking}</span> | |
| 687 | </a> | |
| 688 | <a href="/issues?filter=needs-triage" class={"idash-tab " + (filter === "needs-triage" ? "is-active" : "")}> | |
| 689 | Needs triage <span class="idash-tab-count">{counts.needsTriage}</span> | |
| 690 | </a> | |
| 691 | </nav> | |
| 692 | ||
| 693 | {rows.length === 0 ? ( | |
| 694 | <div class="idash-empty"> | |
| 695 | <h2 class="idash-empty-title"> | |
| 696 | {filter === "mine" | |
| 697 | ? "You haven't opened any issues yet." | |
| 698 | : filter === "assigned" | |
| 699 | ? "No open issues in your repos." | |
| 700 | : filter === "ai-working" | |
| 701 | ? "Autopilot isn't building anything right now." | |
| 702 | : "Triage queue is clear."} | |
| 703 | </h2> | |
| 704 | <p class="idash-empty-sub"> | |
| 705 | {filter === "mine" | |
| 706 | ? "File one and it'll show up here with its full Gluecron signal — AI triage, suggested fix, build progress." | |
| 707 | : filter === "assigned" | |
| 708 | ? "When someone files an issue in a repo you own or collaborate on, it'll appear here already annotated by Claude." | |
| 709 | : filter === "ai-working" | |
| 710 | ? "Label any issue `ai:build` and Claude will pick it up on the next autopilot sweep — it'll show up here while the build runs." | |
| 711 | : "Every open issue has an AI label. Gluecron's autopilot has classified the entire backlog."} | |
| 712 | </p> | |
| 713 | {filter !== "mine" && ( | |
| 714 | <a href="/issues?filter=mine" class="btn">View your authored</a> | |
| 715 | )} | |
| 716 | {filter === "mine" && ( | |
| 717 | <a href="/explore" class="btn btn-primary">Browse repos</a> | |
| 718 | )} | |
| 719 | </div> | |
| 720 | ) : ( | |
| 721 | <ul class="idash-list"> | |
| 722 | {rows.map((r) => { | |
| 723 | const stateClass = r.issue.state === "closed" ? "is-closed" : "is-open"; | |
| 724 | return ( | |
| 725 | <li class="idash-row"> | |
| 726 | <StateIcon state={r.issue.state} /> | |
| 727 | <div class="idash-row-main"> | |
| 728 | <h3 class="idash-row-title"> | |
| 729 | <a href={`/${r.ownerUsername}/${r.repo.name}/issues/${r.issue.number}`}> | |
| 730 | {r.issue.title} | |
| 731 | </a> | |
| 732 | <span class={"idash-state-pill " + stateClass}> | |
| 733 | {r.issue.state} | |
| 734 | </span> | |
| 735 | <span class="idash-badges"> | |
| 736 | {r.buildInProgress && ( | |
| 737 | <span class="idash-badge ai-progress" title="Autopilot is building a fix for this issue"> | |
| 738 | <span class="dot" aria-hidden="true" /> Build in progress | |
| 739 | </span> | |
| 740 | )} | |
| 741 | {r.suggestedFix && ( | |
| 742 | <span class="idash-badge ai-fix" title="Claude has posted a suggested fix"> | |
| 743 | <span class="dot" aria-hidden="true" /> Suggested fix | |
| 744 | </span> | |
| 745 | )} | |
| 746 | {r.aiTriaged && ( | |
| 747 | <span class="idash-badge ai-triaged" title="Autopilot has classified this issue"> | |
| 748 | <span class="dot" aria-hidden="true" /> AI-triaged | |
| 749 | </span> | |
| 750 | )} | |
| 751 | </span> | |
| 752 | </h3> | |
| 753 | <div class="idash-row-meta"> | |
| 754 | <span class="idash-row-repo"> | |
| 755 | {r.ownerUsername}/{r.repo.name} #{r.issue.number} | |
| 756 | </span> | |
| 757 | <span class="sep">·</span> | |
| 758 | <span> | |
| 759 | by <strong style="color:var(--text)">{r.authorUsername}</strong> | |
| 760 | </span> | |
| 761 | <span class="sep">·</span> | |
| 762 | <span>updated {relTime(r.issue.updatedAt)}</span> | |
| 763 | {r.commentCount > 0 && ( | |
| 764 | <> | |
| 765 | <span class="sep">·</span> | |
| 766 | <span class="idash-row-comments" title={`${r.commentCount} comment${r.commentCount === 1 ? "" : "s"}`}> | |
| 767 | <CommentIcon /> {r.commentCount} | |
| 768 | </span> | |
| 769 | </> | |
| 770 | )} | |
| 771 | </div> | |
| 772 | </div> | |
| 773 | </li> | |
| 774 | ); | |
| 775 | })} | |
| 776 | </ul> | |
| 777 | )} | |
| 778 | </div> | |
| 779 | <style dangerouslySetInnerHTML={{ __html: styles }} /> | |
| 780 | </Layout> | |
| 781 | ); | |
| 782 | }); | |
| 783 | ||
| 784 | export default issuesDashboard; |