Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame346 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`.
11 */
12
13import { Hono } from "hono";
14import { and, desc, eq, gte, sql } from "drizzle-orm";
15import { db } from "../db";
16import {
17 gateRuns,
18 issues,
19 organizations,
20 orgMembers,
21 pullRequests,
22 repositories,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27
28const orgInsights = new Hono<AuthEnv>();
29orgInsights.use("*", softAuth);
30
31export interface OrgInsightsSummary {
32 repoCount: number;
33 gateRunsTotal: number;
34 gatePassed: number;
35 gateFailed: number;
36 gateRepaired: number;
37 greenRate: number; // 0..1
38 openIssues: number;
39 openPrs: number;
40 mergedPrs30d: number;
41 perRepo: Array<{
42 id: string;
43 name: string;
44 runs: number;
45 greenRate: number;
46 openPrs: number;
47 openIssues: number;
48 }>;
49}
50
51export async function computeOrgInsights(
52 orgId: string
53): Promise<OrgInsightsSummary> {
54 const empty: OrgInsightsSummary = {
55 repoCount: 0,
56 gateRunsTotal: 0,
57 gatePassed: 0,
58 gateFailed: 0,
59 gateRepaired: 0,
60 greenRate: 0,
61 openIssues: 0,
62 openPrs: 0,
63 mergedPrs30d: 0,
64 perRepo: [],
65 };
66
67 try {
68 const repos = await db
69 .select({ id: repositories.id, name: repositories.name })
70 .from(repositories)
71 .where(eq(repositories.orgId, orgId));
72 if (repos.length === 0) return empty;
73
74 const repoIds = repos.map((r) => r.id);
75 const idList = sql.raw(
76 repoIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(",")
77 );
78
79 // Aggregate gate runs across repos
80 const gateRows = await db
81 .select({
82 repoId: gateRuns.repositoryId,
83 status: gateRuns.status,
84 n: sql<number>`count(*)::int`,
85 })
86 .from(gateRuns)
87 .where(sql`${gateRuns.repositoryId} IN (${idList})`)
88 .groupBy(gateRuns.repositoryId, gateRuns.status);
89
90 const totals = {
91 passed: 0,
92 failed: 0,
93 repaired: 0,
94 skipped: 0,
95 } as Record<string, number>;
96 const byRepo = new Map<
97 string,
98 { runs: number; passed: number; failed: number; repaired: number }
99 >();
100 for (const r of gateRows) {
101 const n = Number(r.n);
102 totals[r.status] = (totals[r.status] || 0) + n;
103 const b = byRepo.get(r.repoId) || {
104 runs: 0,
105 passed: 0,
106 failed: 0,
107 repaired: 0,
108 };
109 b.runs += n;
110 if (r.status === "passed") b.passed += n;
111 else if (r.status === "failed") b.failed += n;
112 else if (r.status === "repaired") b.repaired += n;
113 byRepo.set(r.repoId, b);
114 }
115 const gateRunsTotal = Object.values(totals).reduce((a, b) => a + b, 0);
116 const gatePassed = totals.passed || 0;
117 const gateFailed = totals.failed || 0;
118 const gateRepaired = totals.repaired || 0;
119 const greenRate = gateRunsTotal
120 ? (gatePassed + gateRepaired) / gateRunsTotal
121 : 0;
122
123 // Open issues/PRs across org repos
124 const issueRows = await db
125 .select({
126 repoId: issues.repositoryId,
127 state: issues.state,
128 n: sql<number>`count(*)::int`,
129 })
130 .from(issues)
131 .where(sql`${issues.repositoryId} IN (${idList})`)
132 .groupBy(issues.repositoryId, issues.state);
133
134 const openIssuesByRepo = new Map<string, number>();
135 let openIssues = 0;
136 for (const r of issueRows) {
137 if (r.state === "open") {
138 openIssuesByRepo.set(r.repoId, Number(r.n));
139 openIssues += Number(r.n);
140 }
141 }
142
143 const prRows = await db
144 .select({
145 repoId: pullRequests.repositoryId,
146 state: pullRequests.state,
147 n: sql<number>`count(*)::int`,
148 })
149 .from(pullRequests)
150 .where(sql`${pullRequests.repositoryId} IN (${idList})`)
151 .groupBy(pullRequests.repositoryId, pullRequests.state);
152
153 const openPrsByRepo = new Map<string, number>();
154 let openPrs = 0;
155 for (const r of prRows) {
156 if (r.state === "open") {
157 openPrsByRepo.set(r.repoId, Number(r.n));
158 openPrs += Number(r.n);
159 }
160 }
161
162 // Merged PRs in last 30d
163 const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
164 const [mergedRow] = await db
165 .select({ n: sql<number>`count(*)::int` })
166 .from(pullRequests)
167 .where(
168 and(
169 sql`${pullRequests.repositoryId} IN (${idList})`,
170 eq(pullRequests.state, "merged"),
171 gte(pullRequests.mergedAt, since)
172 )
173 );
174
175 const perRepo = repos.map((r) => {
176 const b = byRepo.get(r.id) || {
177 runs: 0,
178 passed: 0,
179 failed: 0,
180 repaired: 0,
181 };
182 const green = b.runs
183 ? (b.passed + b.repaired) / b.runs
184 : 0;
185 return {
186 id: r.id,
187 name: r.name,
188 runs: b.runs,
189 greenRate: green,
190 openPrs: openPrsByRepo.get(r.id) || 0,
191 openIssues: openIssuesByRepo.get(r.id) || 0,
192 };
193 });
194 perRepo.sort((a, b) => b.runs - a.runs);
195
196 return {
197 repoCount: repos.length,
198 gateRunsTotal,
199 gatePassed,
200 gateFailed,
201 gateRepaired,
202 greenRate,
203 openIssues,
204 openPrs,
205 mergedPrs30d: Number(mergedRow?.n || 0),
206 perRepo,
207 };
208 } catch {
209 return empty;
210 }
211}
212
213async function loadOrg(slug: string) {
214 try {
215 const [o] = await db
216 .select()
217 .from(organizations)
218 .where(eq(organizations.slug, slug))
219 .limit(1);
220 return o || null;
221 } catch {
222 return null;
223 }
224}
225
226async function isOrgMember(orgId: string, userId: string): Promise<boolean> {
227 try {
228 const [row] = await db
229 .select({ id: orgMembers.id })
230 .from(orgMembers)
231 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
232 .limit(1);
233 return !!row;
234 } catch {
235 return false;
236 }
237}
238
239orgInsights.get("/orgs/:slug/insights", requireAuth, async (c) => {
240 const user = c.get("user")!;
241 const slug = c.req.param("slug");
242 const org = await loadOrg(slug);
243 if (!org) return c.notFound();
244 const member = await isOrgMember(org.id, user.id);
245 if (!member) return c.redirect(`/orgs/${slug}`);
246
247 const summary = await computeOrgInsights(org.id);
248 const pct = (n: number) => Math.round(n * 100);
249
250 return c.html(
251 <Layout title={`${org.name} — Insights`} user={user}>
252 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
253 <h2>{org.name} · Insights</h2>
254 <a href={`/orgs/${slug}`} class="btn btn-sm">
255 Back to {slug}
256 </a>
257 </div>
258
259 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
260 <div class="panel" style="padding:12px;text-align:center">
261 <div style="font-size:22px;font-weight:700">{summary.repoCount}</div>
262 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
263 Repos
264 </div>
265 </div>
266 <div class="panel" style="padding:12px;text-align:center">
267 <div style="font-size:22px;font-weight:700;color:var(--green)">
268 {pct(summary.greenRate)}%
269 </div>
270 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
271 Green rate
272 </div>
273 </div>
274 <div class="panel" style="padding:12px;text-align:center">
275 <div style="font-size:22px;font-weight:700;color:#79c0ff">
276 {summary.openPrs}
277 </div>
278 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
279 Open PRs
280 </div>
281 </div>
282 <div class="panel" style="padding:12px;text-align:center">
283 <div style="font-size:22px;font-weight:700;color:#d2a8ff">
284 {summary.mergedPrs30d}
285 </div>
286 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
287 Merged 30d
288 </div>
289 </div>
290 </div>
291
292 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
293 <div class="panel" style="padding:12px;text-align:center">
294 <div style="font-size:18px;font-weight:600">{summary.gateRunsTotal}</div>
295 <div style="font-size:11px;color:var(--text-muted)">Total gate runs</div>
296 </div>
297 <div class="panel" style="padding:12px;text-align:center">
298 <div style="font-size:18px;font-weight:600;color:var(--green)">
299 {summary.gatePassed}
300 </div>
301 <div style="font-size:11px;color:var(--text-muted)">Passed</div>
302 </div>
303 <div class="panel" style="padding:12px;text-align:center">
304 <div style="font-size:18px;font-weight:600;color:#bc8cff">
305 {summary.gateRepaired}
306 </div>
307 <div style="font-size:11px;color:var(--text-muted)">Repaired</div>
308 </div>
309 <div class="panel" style="padding:12px;text-align:center">
310 <div style="font-size:18px;font-weight:600;color:var(--red)">
311 {summary.gateFailed}
312 </div>
313 <div style="font-size:11px;color:var(--text-muted)">Failed</div>
314 </div>
315 </div>
316
317 <h3>Per-repo breakdown</h3>
318 <div class="panel" style="margin-bottom:20px">
319 {summary.perRepo.length === 0 ? (
320 <div class="panel-empty">This org has no repositories yet.</div>
321 ) : (
322 summary.perRepo.map((r) => (
323 <div class="panel-item" style="justify-content:space-between">
324 <div style="flex:1;min-width:0">
325 <a href={`/${slug}/${r.name}`} style="font-weight:600">
326 {slug}/{r.name}
327 </a>
328 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
329 {r.runs} runs · {r.openPrs} open PRs · {r.openIssues} open
330 issues
331 </div>
332 </div>
333 <span
334 style={`font-family:var(--font-mono);color:${r.greenRate >= 0.9 ? "var(--green)" : r.greenRate >= 0.7 ? "#f0b72f" : "var(--red)"}`}
335 >
336 {r.runs > 0 ? `${pct(r.greenRate)}%` : "—"}
337 </span>
338 </div>
339 ))
340 )}
341 </div>
342 </Layout>
343 );
344});
345
346export default orgInsights;