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-health.ts

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-health.tsBlame255 lines · 1 contributor
53299fbClaude1/**
2 * Org-level team health computation.
3 *
4 * Aggregates per-repo health scores across every active repo in an org,
5 * produces a worst-first ranked list, and generates an AI summary.
6 *
7 * Cache: in-memory, 1h TTL per orgId. Call invalidateOrgHealth(orgId) to
8 * force a fresh computation on the next request.
9 */
10
11import { eq } from "drizzle-orm";
12import { db } from "../db";
13import { repositories, repoHealthCache } from "../db/schema";
14import { getHealthScore, invalidateHealthScore, type HealthScoreBreakdown } from "./repo-health";
15import { getAnthropic, isAiAvailable, extractText, MODEL_SONNET } from "./ai-client";
16import { sql } from "drizzle-orm";
17
18// ---------------------------------------------------------------------------
19// Public interface
20// ---------------------------------------------------------------------------
21
22export interface OrgRepoHealth {
23 repoId: string;
24 repoName: string;
25 ownerName: string;
26 score: number; // 0-100
27 trend: "up" | "down" | "stable"; // compare to last week's cached score
28 breakdown: HealthScoreBreakdown;
29}
30
31export interface OrgHealthReport {
32 orgSlug: string;
33 orgName: string;
34 avgScore: number;
35 repos: OrgRepoHealth[]; // sorted by score asc (worst first)
36 aiSummary: string; // Claude paragraph: org health state + top 3 actions
37 generatedAt: Date;
38}
39
40// ---------------------------------------------------------------------------
41// In-memory cache
42// ---------------------------------------------------------------------------
43
44interface OrgCacheEntry {
45 report: OrgHealthReport;
46 expiresAt: number; // Date.now() ms
47}
48
49const ORG_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
50const orgCache = new Map<string, OrgCacheEntry>();
51
52export function invalidateOrgHealth(orgId: string): void {
53 orgCache.delete(orgId);
54}
55
56// ---------------------------------------------------------------------------
57// Trend detection: compare current score to last week's DB-cached score
58// ---------------------------------------------------------------------------
59
60async function getTrendForRepo(
61 repoId: string,
62 currentScore: number
63): Promise<"up" | "down" | "stable"> {
64 try {
65 const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
66 // Look for a cached entry that was computed more than 7 days ago
67 const rows = await db
68 .select({ score: repoHealthCache.score, computedAt: repoHealthCache.computedAt })
69 .from(repoHealthCache)
70 .where(eq(repoHealthCache.repoId, repoId))
71 .limit(1);
72
73 if (rows.length === 0) return "stable";
74
75 const cached = rows[0];
76 // If the cached entry is recent (less than 7 days old), we have no prior-week baseline
77 if (!cached.computedAt || new Date(cached.computedAt) > oneWeekAgo) {
78 return "stable";
79 }
80
81 const priorScore = cached.score;
82 if (currentScore > priorScore + 2) return "up";
83 if (currentScore < priorScore - 2) return "down";
84 return "stable";
85 } catch {
86 return "stable";
87 }
88}
89
90// ---------------------------------------------------------------------------
91// AI summary generation
92// ---------------------------------------------------------------------------
93
94async function generateAiSummary(
95 orgName: string,
96 repos: OrgRepoHealth[]
97): Promise<string> {
98 if (!isAiAvailable() || repos.length === 0) return "";
99
100 try {
101 const repoLines = repos
102 .map((r) => {
103 const bd = r.breakdown;
104 return (
105 `${r.repoName}: ${r.score}/100 ` +
106 `(CI:${bd.ciGreenRate.score}, BusFactor:${bd.busFactor.score}, ` +
107 `CVEs:${bd.openCves.score}, ReviewSpeed:${bd.reviewVelocity.score}, Debt:${bd.techDebt.score})`
108 );
109 })
110 .join("\n");
111
112 const prompt =
113 `You are an engineering manager. Given these repository health scores for org ${orgName}, ` +
114 `write 2-3 sentences summarising the overall health and exactly 3 concrete action items ` +
115 `numbered 1-3. Be direct. No fluff.\n\nRepos (worst first):\n${repoLines}`;
116
117 const anthropic = getAnthropic();
118 const message = await anthropic.messages.create({
119 model: MODEL_SONNET,
120 max_tokens: 512,
121 messages: [{ role: "user", content: prompt }],
122 });
123
124 return extractText(message);
125 } catch {
126 return "";
127 }
128}
129
130// ---------------------------------------------------------------------------
131// Core computation
132// ---------------------------------------------------------------------------
133
134export async function computeOrgHealth(
135 orgId: string,
136 orgSlug: string
137): Promise<OrgHealthReport> {
138 // Check in-memory cache first
139 const now = Date.now();
140 const cached = orgCache.get(orgId);
141 if (cached && cached.expiresAt > now) {
142 return cached.report;
143 }
144
145 const emptyReport: OrgHealthReport = {
146 orgSlug,
147 orgName: orgSlug,
148 avgScore: 0,
149 repos: [],
150 aiSummary: "",
151 generatedAt: new Date(),
152 };
153
154 try {
155 // 1. Load org name + all active repos
156 const orgRows = await db
157 .select({ name: repositories.orgId })
158 .from(repositories)
159 .where(eq(repositories.orgId, orgId))
160 .limit(1);
161
162 // Load org display name from organizations table — we do this via the
163 // repositories query caller already has the org name from the route handler,
164 // so we accept orgSlug as the display fallback and the caller passes orgName separately.
165 // For now, use orgSlug as name placeholder; the route passes real name.
166
167 const repos = await db
168 .select({ id: repositories.id, name: repositories.name, ownerId: repositories.ownerId })
169 .from(repositories)
170 .where(
171 sql`${repositories.orgId} = ${orgId} AND ${repositories.isArchived} = false`
172 )
173 .orderBy(repositories.name);
174
175 if (repos.length === 0) {
176 const report = { ...emptyReport };
177 orgCache.set(orgId, { report, expiresAt: now + ORG_CACHE_TTL_MS });
178 return report;
179 }
180
181 // 2. Compute health scores in parallel (cap at 20 repos)
182 const capped = repos.slice(0, 20);
183 const breakdowns = await Promise.all(
184 capped.map((r) => getHealthScore(r.id))
185 );
186
187 // 3. Get trends in parallel
188 const trends = await Promise.all(
189 capped.map((r, i) => getTrendForRepo(r.id, breakdowns[i].total))
190 );
191
192 // 4. Build OrgRepoHealth array
193 const repoHealthList: OrgRepoHealth[] = capped.map((r, i) => ({
194 repoId: r.id,
195 repoName: r.name,
196 ownerName: orgSlug,
197 score: breakdowns[i].total,
198 trend: trends[i],
199 breakdown: breakdowns[i],
200 }));
201
202 // 5. Sort by score ascending (worst first — action list)
203 repoHealthList.sort((a, b) => a.score - b.score);
204
205 // 6. Compute average score
206 const sum = repoHealthList.reduce((acc, r) => acc + r.score, 0);
207 const avgScore = Math.round(sum / repoHealthList.length);
208
209 // 7. Generate AI summary
210 const aiSummary = await generateAiSummary(orgSlug, repoHealthList);
211
212 const report: OrgHealthReport = {
213 orgSlug,
214 orgName: orgSlug,
215 avgScore,
216 repos: repoHealthList,
217 aiSummary,
218 generatedAt: new Date(),
219 };
220
221 orgCache.set(orgId, { report, expiresAt: now + ORG_CACHE_TTL_MS });
222 return report;
223 } catch (err) {
224 const errorSummary =
225 err instanceof Error ? `Error computing org health: ${err.message}` : "Error computing org health.";
226 const report: OrgHealthReport = {
227 ...emptyReport,
228 aiSummary: errorSummary,
229 };
230 return report;
231 }
232}
233
234/**
235 * Invalidate health caches for all repos in an org and clear the org cache.
236 * Called from the POST /orgs/:slug/health/recompute endpoint.
237 */
238export async function invalidateOrgHealthAndRepos(
239 orgId: string
240): Promise<void> {
241 invalidateOrgHealth(orgId);
242 try {
243 const repos = await db
244 .select({ id: repositories.id })
245 .from(repositories)
246 .where(
247 sql`${repositories.orgId} = ${orgId} AND ${repositories.isArchived} = false`
248 );
249 for (const r of repos) {
250 invalidateHealthScore(r.id);
251 }
252 } catch {
253 // best effort
254 }
255}