Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

org-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.

org-insights.tsxBlame889 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F2 — Org-wide insights.
3 *
4 * GET /orgs/:slug/insights — rollup across every repo owned by the org:
5 * gate green-rate, open/merged PR counts, open
6 * issue count, recent gate activity, per-repo
7 * rows sorted by activity.
8 *
9 * No new tables — computed live from existing `repositories`, `gate_runs`,
10 * `pull_requests`, `issues`.
f0b5874Claude11 *
12 * 2026 polish: gradient-hairline hero + radial orb + aggregated stat-card
13 * grid + leaderboard cards for most-active repos and most-active
14 * contributors. Every class prefixed `.org-ins-` so this surface can't
15 * bleed into the wider polish. Route + `computeOrgInsights()` contract
16 * preserved exactly (extended with an optional `topContributors` field).
8f50ed0Claude17 */
18
19import { Hono } from "hono";
20import { and, desc, eq, gte, sql } from "drizzle-orm";
21import { db } from "../db";
22import {
23 gateRuns,
24 issues,
25 organizations,
26 orgMembers,
27 pullRequests,
28 repositories,
f0b5874Claude29 users,
8f50ed0Claude30} from "../db/schema";
31import { Layout } from "../views/layout";
32import { softAuth, requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34
35const orgInsights = new Hono<AuthEnv>();
36orgInsights.use("*", softAuth);
37
38export interface OrgInsightsSummary {
39 repoCount: number;
40 gateRunsTotal: number;
41 gatePassed: number;
42 gateFailed: number;
43 gateRepaired: number;
44 greenRate: number; // 0..1
45 openIssues: number;
46 openPrs: number;
47 mergedPrs30d: number;
48 perRepo: Array<{
49 id: string;
50 name: string;
51 runs: number;
52 greenRate: number;
53 openPrs: number;
54 openIssues: number;
55 }>;
f0b5874Claude56 /** Most-active contributors (by total PRs opened in this org). Optional so
57 * the contract stays backwards-compatible with existing callers/tests. */
58 topContributors?: Array<{ username: string; prs: number; merged: number }>;
8f50ed0Claude59}
60
61export async function computeOrgInsights(
62 orgId: string
63): Promise<OrgInsightsSummary> {
64 const empty: OrgInsightsSummary = {
65 repoCount: 0,
66 gateRunsTotal: 0,
67 gatePassed: 0,
68 gateFailed: 0,
69 gateRepaired: 0,
70 greenRate: 0,
71 openIssues: 0,
72 openPrs: 0,
73 mergedPrs30d: 0,
74 perRepo: [],
f0b5874Claude75 topContributors: [],
8f50ed0Claude76 };
77
78 try {
79 const repos = await db
80 .select({ id: repositories.id, name: repositories.name })
81 .from(repositories)
82 .where(eq(repositories.orgId, orgId));
83 if (repos.length === 0) return empty;
84
85 const repoIds = repos.map((r) => r.id);
86 const idList = sql.raw(
87 repoIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(",")
88 );
89
90 // Aggregate gate runs across repos
91 const gateRows = await db
92 .select({
93 repoId: gateRuns.repositoryId,
94 status: gateRuns.status,
95 n: sql<number>`count(*)::int`,
96 })
97 .from(gateRuns)
98 .where(sql`${gateRuns.repositoryId} IN (${idList})`)
99 .groupBy(gateRuns.repositoryId, gateRuns.status);
100
101 const totals = {
102 passed: 0,
103 failed: 0,
104 repaired: 0,
105 skipped: 0,
106 } as Record<string, number>;
107 const byRepo = new Map<
108 string,
109 { runs: number; passed: number; failed: number; repaired: number }
110 >();
111 for (const r of gateRows) {
112 const n = Number(r.n);
113 totals[r.status] = (totals[r.status] || 0) + n;
114 const b = byRepo.get(r.repoId) || {
115 runs: 0,
116 passed: 0,
117 failed: 0,
118 repaired: 0,
119 };
120 b.runs += n;
121 if (r.status === "passed") b.passed += n;
122 else if (r.status === "failed") b.failed += n;
123 else if (r.status === "repaired") b.repaired += n;
124 byRepo.set(r.repoId, b);
125 }
126 const gateRunsTotal = Object.values(totals).reduce((a, b) => a + b, 0);
127 const gatePassed = totals.passed || 0;
128 const gateFailed = totals.failed || 0;
129 const gateRepaired = totals.repaired || 0;
130 const greenRate = gateRunsTotal
131 ? (gatePassed + gateRepaired) / gateRunsTotal
132 : 0;
133
134 // Open issues/PRs across org repos
135 const issueRows = await db
136 .select({
137 repoId: issues.repositoryId,
138 state: issues.state,
139 n: sql<number>`count(*)::int`,
140 })
141 .from(issues)
142 .where(sql`${issues.repositoryId} IN (${idList})`)
143 .groupBy(issues.repositoryId, issues.state);
144
145 const openIssuesByRepo = new Map<string, number>();
146 let openIssues = 0;
147 for (const r of issueRows) {
148 if (r.state === "open") {
149 openIssuesByRepo.set(r.repoId, Number(r.n));
150 openIssues += Number(r.n);
151 }
152 }
153
154 const prRows = await db
155 .select({
156 repoId: pullRequests.repositoryId,
157 state: pullRequests.state,
158 n: sql<number>`count(*)::int`,
159 })
160 .from(pullRequests)
161 .where(sql`${pullRequests.repositoryId} IN (${idList})`)
162 .groupBy(pullRequests.repositoryId, pullRequests.state);
163
164 const openPrsByRepo = new Map<string, number>();
165 let openPrs = 0;
166 for (const r of prRows) {
167 if (r.state === "open") {
168 openPrsByRepo.set(r.repoId, Number(r.n));
169 openPrs += Number(r.n);
170 }
171 }
172
173 // Merged PRs in last 30d
174 const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
175 const [mergedRow] = await db
176 .select({ n: sql<number>`count(*)::int` })
177 .from(pullRequests)
178 .where(
179 and(
180 sql`${pullRequests.repositoryId} IN (${idList})`,
181 eq(pullRequests.state, "merged"),
182 gte(pullRequests.mergedAt, since)
183 )
184 );
185
f0b5874Claude186 // Top contributors — by PR count across org repos. Wrapped separately so
187 // a failure here doesn't take down the whole rollup.
188 let topContributors: NonNullable<
189 OrgInsightsSummary["topContributors"]
190 > = [];
191 try {
192 const contribRows = await db
193 .select({
194 username: users.username,
195 prs: sql<number>`count(*)::int`,
196 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
197 })
198 .from(pullRequests)
199 .innerJoin(users, eq(pullRequests.authorId, users.id))
200 .where(sql`${pullRequests.repositoryId} IN (${idList})`)
201 .groupBy(users.username)
202 .orderBy(sql`count(*) desc`)
203 .limit(8);
204 topContributors = contribRows.map((r) => ({
205 username: r.username,
206 prs: Number(r.prs || 0),
207 merged: Number(r.merged || 0),
208 }));
209 } catch {
210 topContributors = [];
211 }
212
8f50ed0Claude213 const perRepo = repos.map((r) => {
214 const b = byRepo.get(r.id) || {
215 runs: 0,
216 passed: 0,
217 failed: 0,
218 repaired: 0,
219 };
220 const green = b.runs
221 ? (b.passed + b.repaired) / b.runs
222 : 0;
223 return {
224 id: r.id,
225 name: r.name,
226 runs: b.runs,
227 greenRate: green,
228 openPrs: openPrsByRepo.get(r.id) || 0,
229 openIssues: openIssuesByRepo.get(r.id) || 0,
230 };
231 });
232 perRepo.sort((a, b) => b.runs - a.runs);
233
234 return {
235 repoCount: repos.length,
236 gateRunsTotal,
237 gatePassed,
238 gateFailed,
239 gateRepaired,
240 greenRate,
241 openIssues,
242 openPrs,
243 mergedPrs30d: Number(mergedRow?.n || 0),
244 perRepo,
f0b5874Claude245 topContributors,
8f50ed0Claude246 };
247 } catch {
248 return empty;
249 }
250}
251
252async function loadOrg(slug: string) {
253 try {
254 const [o] = await db
255 .select()
256 .from(organizations)
257 .where(eq(organizations.slug, slug))
258 .limit(1);
259 return o || null;
260 } catch {
261 return null;
262 }
263}
264
265async function isOrgMember(orgId: string, userId: string): Promise<boolean> {
266 try {
267 const [row] = await db
268 .select({ id: orgMembers.id })
269 .from(orgMembers)
270 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
271 .limit(1);
272 return !!row;
273 } catch {
274 return false;
275 }
276}
277
f0b5874Claude278/* ─────────────────────────────────────────────────────────────────────────
279 * Scoped CSS — every class prefixed `.org-ins-` so this surface can't
280 * bleed into the wider polish. Mirrors the gradient-hairline hero +
281 * stat-card grid + leaderboard pattern from `insights.tsx`.
282 * ───────────────────────────────────────────────────────────────────── */
283const styles = `
eed4684Claude284 .org-ins-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
f0b5874Claude285
286 .org-ins-hero {
287 position: relative;
288 margin-bottom: var(--space-5);
289 padding: var(--space-5) var(--space-6);
290 background: var(--bg-elevated);
291 border: 1px solid var(--border);
292 border-radius: 16px;
293 overflow: hidden;
294 }
295 .org-ins-hero::before {
296 content: '';
297 position: absolute;
298 top: 0; left: 0; right: 0;
299 height: 2px;
300 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
301 opacity: 0.75;
302 pointer-events: none;
303 }
304 .org-ins-hero-orb {
305 position: absolute;
306 inset: -30% -15% auto auto;
307 width: 460px; height: 460px;
308 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
309 filter: blur(80px);
310 opacity: 0.75;
311 pointer-events: none;
312 z-index: 0;
313 }
314 .org-ins-hero-inner {
315 position: relative;
316 z-index: 1;
317 display: flex;
318 align-items: flex-start;
319 justify-content: space-between;
320 gap: var(--space-4);
321 flex-wrap: wrap;
322 }
323 .org-ins-hero-text { max-width: 720px; }
324 .org-ins-eyebrow {
325 display: inline-flex;
326 align-items: center;
327 gap: 8px;
328 text-transform: uppercase;
329 font-family: var(--font-mono);
330 font-size: 11px;
331 letter-spacing: 0.18em;
332 color: var(--text-muted);
333 font-weight: 600;
334 margin-bottom: 14px;
335 }
336 .org-ins-eyebrow-dot {
337 width: 8px; height: 8px;
338 border-radius: 9999px;
339 background: linear-gradient(135deg, #8c6dff, #36c5d6);
340 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
341 }
342 .org-ins-title {
343 font-family: var(--font-display);
344 font-size: clamp(28px, 4vw, 40px);
345 font-weight: 800;
346 letter-spacing: -0.028em;
347 line-height: 1.05;
348 margin: 0 0 var(--space-2);
349 color: var(--text-strong);
350 }
351 .org-ins-title-grad {
352 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
353 -webkit-background-clip: text;
354 background-clip: text;
355 -webkit-text-fill-color: transparent;
356 color: transparent;
357 }
358 .org-ins-sub {
359 font-size: 15px;
360 color: var(--text-muted);
361 margin: 0;
362 line-height: 1.55;
363 }
364 .org-ins-back {
365 display: inline-flex;
366 align-items: center;
367 gap: 6px;
368 padding: 8px 14px;
369 border-radius: 10px;
370 border: 1px solid var(--border-strong, var(--border));
371 background: transparent;
372 color: var(--text);
373 font-size: 13px;
374 font-weight: 600;
375 text-decoration: none;
376 transition: background 120ms ease, border-color 120ms ease;
377 white-space: nowrap;
378 }
379 .org-ins-back:hover {
380 background: rgba(140,109,255,0.06);
381 border-color: rgba(140,109,255,0.45);
382 text-decoration: none;
383 }
384
385 /* Stat-card grid */
386 .org-ins-stats {
387 display: grid;
388 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
389 gap: var(--space-3);
390 margin-bottom: var(--space-5);
391 }
392 .org-ins-stat {
393 position: relative;
394 background: var(--bg-elevated);
395 border: 1px solid var(--border);
396 border-radius: 14px;
397 padding: var(--space-4);
398 transition: border-color 120ms ease, transform 120ms ease;
399 }
400 .org-ins-stat:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
401 .org-ins-stat-label {
402 font-size: 10.5px;
403 letter-spacing: 0.14em;
404 text-transform: uppercase;
405 color: var(--text-muted);
406 font-weight: 700;
407 margin-bottom: 6px;
408 }
409 .org-ins-stat-value {
410 font-family: var(--font-display);
411 font-size: 32px;
412 font-weight: 800;
413 letter-spacing: -0.022em;
414 color: var(--text-strong);
415 font-variant-numeric: tabular-nums;
416 line-height: 1;
417 }
418 .org-ins-stat-value.is-good { color: #6ee7b7; }
419 .org-ins-stat-value.is-warn { color: #fca5a5; }
420 .org-ins-stat-value.is-info { color: #93c5fd; }
421 .org-ins-stat-value.is-accent {
422 background-image: linear-gradient(135deg, #a48bff 0%, #36c5d6 100%);
423 -webkit-background-clip: text;
424 background-clip: text;
425 -webkit-text-fill-color: transparent;
426 color: transparent;
427 }
428 .org-ins-stat-hint {
429 margin-top: 6px;
430 font-size: 12px;
431 color: var(--text-muted);
432 }
433
434 /* Gate-status mini stat row */
435 .org-ins-gates {
436 display: grid;
437 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
438 gap: var(--space-3);
439 margin-bottom: var(--space-5);
440 }
441 .org-ins-gate {
442 background: var(--bg-elevated);
443 border: 1px solid var(--border);
444 border-radius: 12px;
445 padding: var(--space-3) var(--space-4);
446 text-align: center;
447 }
448 .org-ins-gate-value {
449 font-family: var(--font-display);
450 font-size: 22px;
451 font-weight: 700;
452 color: var(--text-strong);
453 font-variant-numeric: tabular-nums;
454 line-height: 1;
455 }
456 .org-ins-gate-value.is-good { color: #6ee7b7; }
457 .org-ins-gate-value.is-warn { color: #fca5a5; }
458 .org-ins-gate-value.is-soft { color: #c4b5fd; }
459 .org-ins-gate-label {
460 margin-top: 6px;
461 font-size: 10.5px;
462 letter-spacing: 0.14em;
463 text-transform: uppercase;
464 color: var(--text-muted);
465 font-weight: 700;
466 }
467
468 /* Section heading */
469 .org-ins-section-head {
470 margin: 0 0 var(--space-3);
471 display: flex;
472 align-items: baseline;
473 justify-content: space-between;
474 gap: var(--space-3);
475 flex-wrap: wrap;
476 }
477 .org-ins-section-title {
478 margin: 0;
479 font-family: var(--font-display);
480 font-size: 18px;
481 font-weight: 700;
482 letter-spacing: -0.018em;
483 color: var(--text-strong);
484 }
485 .org-ins-section-sub { font-size: 12.5px; color: var(--text-muted); }
486
487 /* Two-column leaderboard layout */
488 .org-ins-twocol {
489 display: grid;
490 grid-template-columns: 1.2fr 0.8fr;
491 gap: var(--space-4);
492 margin-bottom: var(--space-5);
493 }
494 @media (max-width: 820px) {
495 .org-ins-twocol { grid-template-columns: 1fr; }
496 }
497
498 /* Leaderboard cards */
499 .org-ins-list {
500 display: flex;
501 flex-direction: column;
502 gap: var(--space-2);
503 }
504 .org-ins-card {
505 position: relative;
506 background: var(--bg-elevated);
507 border: 1px solid var(--border);
508 border-radius: 12px;
509 padding: var(--space-3) var(--space-4);
510 transition: border-color 120ms ease, transform 120ms ease;
511 display: flex;
512 align-items: center;
513 gap: 14px;
514 }
515 .org-ins-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
516 .org-ins-rank {
517 flex: none;
518 width: 28px; height: 28px;
519 border-radius: 8px;
520 background: rgba(140,109,255,0.10);
521 color: #b69dff;
522 font-family: var(--font-mono);
523 font-size: 12px;
524 font-weight: 700;
525 display: flex;
526 align-items: center;
527 justify-content: center;
528 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
529 }
530 .org-ins-rank.is-1 {
531 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.30));
532 color: #fff;
533 }
534 .org-ins-card-main { flex: 1; min-width: 0; }
535 .org-ins-card-title {
536 font-family: var(--font-display);
537 font-size: 14.5px;
538 font-weight: 700;
539 color: var(--text-strong);
540 letter-spacing: -0.005em;
541 line-height: 1.3;
542 margin: 0 0 4px;
543 word-break: break-word;
544 }
545 .org-ins-card-title a { color: inherit; text-decoration: none; }
546 .org-ins-card-title a:hover { color: var(--accent); }
547 .org-ins-card-sub {
548 font-size: 12.5px;
549 color: var(--text-muted);
550 line-height: 1.5;
551 margin: 0;
552 font-variant-numeric: tabular-nums;
553 }
554 .org-ins-card-meta {
555 flex: none;
556 text-align: right;
557 font-family: var(--font-mono);
558 font-size: 13px;
559 font-variant-numeric: tabular-nums;
560 }
561 .org-ins-card-meta.is-good { color: #6ee7b7; }
562 .org-ins-card-meta.is-warn { color: #fca5a5; }
563 .org-ins-card-meta.is-mid { color: #fcd34d; }
564 .org-ins-card-meta.is-muted { color: var(--text-muted); }
565
566 .org-ins-avatar {
567 flex: none;
568 width: 32px; height: 32px;
569 border-radius: 9999px;
570 background: linear-gradient(135deg, #8c6dff, #36c5d6);
571 color: #fff;
572 display: flex;
573 align-items: center;
574 justify-content: center;
575 font-family: var(--font-display);
576 font-size: 12px;
577 font-weight: 700;
578 text-transform: uppercase;
579 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18);
580 }
581
582 /* Empty state — dashed orb card */
583 .org-ins-empty {
584 position: relative;
585 overflow: hidden;
586 text-align: center;
587 padding: var(--space-6) var(--space-4);
588 border: 1px dashed var(--border-strong, var(--border));
589 border-radius: 16px;
590 background: rgba(255,255,255,0.012);
591 color: var(--text-muted);
592 }
593 .org-ins-empty::before {
594 content: '';
595 position: absolute;
596 inset: -40% -20% auto auto;
597 width: 320px; height: 320px;
598 background: radial-gradient(circle, rgba(140,109,255,0.14), rgba(54,197,214,0.06) 45%, transparent 70%);
599 filter: blur(60px);
600 pointer-events: none;
601 }
602 .org-ins-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
603 .org-ins-empty strong {
604 font-family: var(--font-display);
605 font-size: 17px;
606 font-weight: 700;
607 color: var(--text-strong);
608 margin: 0;
609 }
610 .org-ins-empty p { font-size: 13px; margin: 0; max-width: 420px; }
611 .org-ins-empty .cta {
612 display: inline-flex;
613 align-items: center;
614 padding: 9px 16px;
615 border-radius: 10px;
616 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
617 color: #fff;
618 font-size: 13px;
619 font-weight: 600;
620 text-decoration: none;
621 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
622 }
623 .org-ins-empty .cta:hover { text-decoration: none; transform: translateY(-1px); }
624`;
625
626function initials(name: string): string {
627 if (!name) return "?";
628 return name.slice(0, 2);
629}
630
8f50ed0Claude631orgInsights.get("/orgs/:slug/insights", requireAuth, async (c) => {
632 const user = c.get("user")!;
633 const slug = c.req.param("slug");
634 const org = await loadOrg(slug);
635 if (!org) return c.notFound();
636 const member = await isOrgMember(org.id, user.id);
637 if (!member) return c.redirect(`/orgs/${slug}`);
638
639 const summary = await computeOrgInsights(org.id);
640 const pct = (n: number) => Math.round(n * 100);
f0b5874Claude641 const contributors = summary.topContributors || [];
642
643 // Total commit proxy — `gateRunsTotal` is the closest aggregated signal
644 // we already collect (each push triggers a gate run). Surfaced as
645 // "Tracked runs" rather than literal "commits" so the number is honest.
646 const totalRuns = summary.gateRunsTotal;
8f50ed0Claude647
648 return c.html(
649 <Layout title={`${org.name} — Insights`} user={user}>
f0b5874Claude650 <div class="org-ins-wrap">
651 <section class="org-ins-hero">
652 <div class="org-ins-hero-orb" aria-hidden="true" />
653 <div class="org-ins-hero-inner">
654 <div class="org-ins-hero-text">
655 <div class="org-ins-eyebrow">
656 <span class="org-ins-eyebrow-dot" aria-hidden="true" />
657 Org insights · {slug}
658 </div>
659 <h2 class="org-ins-title">
660 <span class="org-ins-title-grad">{org.name}</span>
661 </h2>
662 <p class="org-ins-sub">
663 Aggregated health across every repo in the org — gate runs,
664 pull-request flow, open issues, and the people moving things
665 forward.
666 </p>
667 </div>
668 <a href={`/orgs/${slug}`} class="org-ins-back">
669 ← Back to {slug}
670 </a>
671 </div>
672 </section>
8f50ed0Claude673
f0b5874Claude674 <div class="org-ins-stats">
675 <div class="org-ins-stat">
676 <div class="org-ins-stat-label">Repositories</div>
677 <div class="org-ins-stat-value">
678 {summary.repoCount.toLocaleString()}
679 </div>
680 <div class="org-ins-stat-hint">Owned by this org</div>
8f50ed0Claude681 </div>
f0b5874Claude682 <div class="org-ins-stat">
683 <div class="org-ins-stat-label">Contributors</div>
684 <div class="org-ins-stat-value is-info">
685 {contributors.length.toLocaleString()}
686 </div>
687 <div class="org-ins-stat-hint">Distinct PR authors</div>
8f50ed0Claude688 </div>
f0b5874Claude689 <div class="org-ins-stat">
690 <div class="org-ins-stat-label">Tracked runs</div>
691 <div class="org-ins-stat-value is-accent">
692 {totalRuns.toLocaleString()}
693 </div>
694 <div class="org-ins-stat-hint">All-time gate executions</div>
695 </div>
696 <div class="org-ins-stat">
697 <div class="org-ins-stat-label">Open issues</div>
698 <div
699 class={
700 "org-ins-stat-value" +
701 (summary.openIssues > 0 ? " is-warn" : "")
702 }
703 >
704 {summary.openIssues.toLocaleString()}
705 </div>
706 <div class="org-ins-stat-hint">Across all repos</div>
8f50ed0Claude707 </div>
708 </div>
f0b5874Claude709
710 <div class="org-ins-stats">
711 <div class="org-ins-stat">
712 <div class="org-ins-stat-label">Green rate</div>
713 <div
714 class={
715 "org-ins-stat-value" +
716 (summary.greenRate >= 0.9
717 ? " is-good"
718 : summary.greenRate >= 0.7
719 ? ""
720 : " is-warn")
721 }
722 >
723 {pct(summary.greenRate)}%
724 </div>
725 <div class="org-ins-stat-hint">Passed + repaired ÷ total</div>
8f50ed0Claude726 </div>
f0b5874Claude727 <div class="org-ins-stat">
728 <div class="org-ins-stat-label">Open PRs</div>
729 <div class="org-ins-stat-value is-info">
730 {summary.openPrs.toLocaleString()}
731 </div>
732 <div class="org-ins-stat-hint">Across all repos</div>
8f50ed0Claude733 </div>
f0b5874Claude734 <div class="org-ins-stat">
735 <div class="org-ins-stat-label">Merged 30d</div>
736 <div class="org-ins-stat-value is-accent">
737 {summary.mergedPrs30d.toLocaleString()}
738 </div>
739 <div class="org-ins-stat-hint">Pull requests merged</div>
8f50ed0Claude740 </div>
f0b5874Claude741 <div class="org-ins-stat">
742 <div class="org-ins-stat-label">Gate runs</div>
743 <div class="org-ins-stat-value">
744 {summary.gateRunsTotal.toLocaleString()}
745 </div>
746 <div class="org-ins-stat-hint">Total recorded</div>
8f50ed0Claude747 </div>
748 </div>
749
f0b5874Claude750 <div class="org-ins-gates">
751 <div class="org-ins-gate">
752 <div class="org-ins-gate-value is-good">
753 {summary.gatePassed.toLocaleString()}
754 </div>
755 <div class="org-ins-gate-label">Passed</div>
8f50ed0Claude756 </div>
f0b5874Claude757 <div class="org-ins-gate">
758 <div class="org-ins-gate-value is-soft">
759 {summary.gateRepaired.toLocaleString()}
760 </div>
761 <div class="org-ins-gate-label">Repaired</div>
8f50ed0Claude762 </div>
f0b5874Claude763 <div class="org-ins-gate">
764 <div class="org-ins-gate-value is-warn">
765 {summary.gateFailed.toLocaleString()}
766 </div>
767 <div class="org-ins-gate-label">Failed</div>
8f50ed0Claude768 </div>
769 </div>
770
f0b5874Claude771 <div class="org-ins-twocol">
772 <div>
773 <div class="org-ins-section-head">
774 <h3 class="org-ins-section-title">Most-active repos</h3>
775 <span class="org-ins-section-sub">
776 {summary.perRepo.length} total
777 </span>
778 </div>
779 {summary.perRepo.length === 0 ? (
780 <div class="org-ins-empty">
781 <div class="org-ins-empty-inner">
782 <strong>No repositories yet</strong>
783 <p>
784 Create the first repo in {org.name} to start collecting
785 insights across the org.
786 </p>
787 <a href="/new" class="cta">
788 + New repository
789 </a>
8f50ed0Claude790 </div>
791 </div>
f0b5874Claude792 ) : (
793 <div class="org-ins-list">
794 {summary.perRepo.map((r, i) => (
795 <div class="org-ins-card">
796 <div class={"org-ins-rank" + (i === 0 ? " is-1" : "")}>
797 {i + 1}
798 </div>
799 <div class="org-ins-card-main">
800 <h4 class="org-ins-card-title">
801 <a href={`/${slug}/${r.name}`}>
802 {slug}/{r.name}
803 </a>
804 </h4>
805 <p class="org-ins-card-sub">
806 {r.runs.toLocaleString()} run
807 {r.runs === 1 ? "" : "s"} ·{" "}
808 {r.openPrs.toLocaleString()} open PR
809 {r.openPrs === 1 ? "" : "s"} ·{" "}
810 {r.openIssues.toLocaleString()} open issue
811 {r.openIssues === 1 ? "" : "s"}
812 </p>
813 </div>
814 <div
815 class={
816 "org-ins-card-meta " +
817 (r.runs === 0
818 ? "is-muted"
819 : r.greenRate >= 0.9
820 ? "is-good"
821 : r.greenRate >= 0.7
822 ? "is-mid"
823 : "is-warn")
824 }
825 >
826 {r.runs > 0 ? `${pct(r.greenRate)}%` : "—"}
827 </div>
828 </div>
829 ))}
830 </div>
831 )}
832 </div>
833
834 <div>
835 <div class="org-ins-section-head">
836 <h3 class="org-ins-section-title">Top contributors</h3>
837 <span class="org-ins-section-sub">
838 {contributors.length} of recent
8f50ed0Claude839 </span>
840 </div>
f0b5874Claude841 {contributors.length === 0 ? (
842 <div class="org-ins-empty">
843 <div class="org-ins-empty-inner">
844 <strong>No contributors yet</strong>
845 <p>
846 Once people open pull requests across the org, the
847 leaderboard fills in here.
848 </p>
849 </div>
850 </div>
851 ) : (
852 <div class="org-ins-list">
853 {contributors.map((c, i) => (
854 <div class="org-ins-card">
855 <div class={"org-ins-rank" + (i === 0 ? " is-1" : "")}>
856 {i + 1}
857 </div>
858 <div
859 class="org-ins-avatar"
860 aria-label={`@${c.username}`}
861 >
862 {initials(c.username)}
863 </div>
864 <div class="org-ins-card-main">
865 <h4 class="org-ins-card-title">
866 <a href={`/${c.username}`}>@{c.username}</a>
867 </h4>
868 <p class="org-ins-card-sub">
869 {c.prs.toLocaleString()} PR
870 {c.prs === 1 ? "" : "s"} · {c.merged.toLocaleString()}{" "}
871 merged
872 </p>
873 </div>
874 <div class="org-ins-card-meta is-muted">
875 {c.prs.toLocaleString()}
876 </div>
877 </div>
878 ))}
879 </div>
880 )}
881 </div>
882 </div>
8f50ed0Claude883 </div>
f0b5874Claude884 <style dangerouslySetInnerHTML={{ __html: styles }} />
8f50ed0Claude885 </Layout>
886 );
887});
888
889export default orgInsights;