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