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

branch-age.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.

branch-age.tsxBlame359 lines · 1 contributor
c02a55bClaude1/**
2 * Block J27 — Branch staleness / age report.
3 *
4 * GET /:owner/:repo/branches/age[?threshold=0|30|60|90|180][&sort=…]
5 *
6 * softAuth, read-only. Walks every branch in the repo, fetches the tip commit
7 * via `getCommit`, computes ahead/behind vs the default branch via
8 * `aheadBehind`, and renders a per-branch table + buckets + summary KPIs.
9 * Fail-soft: git errors degrade to empty report → page still renders 200.
10 */
11
12import { Hono } from "hono";
13import { eq, and } from "drizzle-orm";
14import { db } from "../db";
15import { repositories, users } from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader } from "../views/components";
18import { softAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import {
21 listBranches,
22 getDefaultBranch,
23 getCommit,
24 aheadBehind,
25} from "../git/repository";
26import {
27 buildBranchReport,
28 parseThreshold,
29 parseSort,
30 thresholdLabel,
31 sortLabel,
32 categoryLabel,
33 VALID_THRESHOLDS,
34 VALID_SORTS,
35 type BranchInputRow,
36 type BranchAgeCategory,
37} from "../lib/branch-age";
38
39const branchAgeRoutes = new Hono<AuthEnv>();
40
41branchAgeRoutes.use("*", softAuth);
42
43async function resolveRepo(ownerName: string, repoName: string) {
44 try {
45 const [owner] = await db
46 .select()
47 .from(users)
48 .where(eq(users.username, ownerName))
49 .limit(1);
50 if (!owner) return null;
51 const [repo] = await db
52 .select()
53 .from(repositories)
54 .where(
55 and(
56 eq(repositories.ownerId, owner.id),
57 eq(repositories.name, repoName)
58 )
59 )
60 .limit(1);
61 if (!repo) return null;
62 return { owner, repo };
63 } catch {
64 return null;
65 }
66}
67
68function ageColor(c: BranchAgeCategory): string {
69 switch (c) {
70 case "fresh":
71 return "var(--green, #3fb950)";
72 case "aging":
73 return "var(--yellow, #d29922)";
74 case "stale":
75 return "var(--orange, #db6d28)";
76 case "abandoned":
77 return "var(--red, #f85149)";
78 }
79}
80
81function formatDaysOld(days: number | null): string {
82 if (days === null) return "\u2014";
83 if (days === 0) return "today";
84 if (days === 1) return "1 day";
85 if (days < 30) return `${days} days`;
86 const months = Math.floor(days / 30);
87 if (months < 12) return `${months} mo`;
88 const years = (days / 365).toFixed(1);
89 return `${years} yr`;
90}
91
92branchAgeRoutes.get("/:owner/:repo/branches/age", async (c) => {
93 const { owner: ownerName, repo: repoName } = c.req.param();
94 const user = c.get("user");
95 const threshold = parseThreshold(c.req.query("threshold"));
96 const sort = parseSort(c.req.query("sort"));
97
98 const resolved = await resolveRepo(ownerName, repoName);
99 if (!resolved) {
100 return c.html(
101 <Layout title="Not Found" user={user}>
102 <div class="empty-state">
103 <h2>Repository not found</h2>
104 </div>
105 </Layout>,
106 404
107 );
108 }
109
110 // Private-repo visibility: only the owner can see the metric.
111 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
112 return c.html(
113 <Layout title="Not Found" user={user}>
114 <div class="empty-state">
115 <h2>Repository not found</h2>
116 </div>
117 </Layout>,
118 404
119 );
120 }
121
122 let branches: string[] = [];
123 let defaultBranch: string | null = null;
124 try {
125 [branches, defaultBranch] = await Promise.all([
126 listBranches(ownerName, repoName),
127 getDefaultBranch(ownerName, repoName),
128 ]);
129 } catch {
130 branches = [];
131 }
132
133 const inputs: BranchInputRow[] = [];
134 for (const b of branches) {
135 try {
136 const commit = await getCommit(ownerName, repoName, b);
137 let ahead = 0;
138 let behind = 0;
139 const isDefault = b === defaultBranch;
140 if (!isDefault && defaultBranch) {
141 const ab = await aheadBehind(ownerName, repoName, defaultBranch, b);
142 if (ab) {
143 ahead = ab.ahead;
144 behind = ab.behind;
145 }
146 }
147 inputs.push({
148 name: b,
149 tipSha: commit?.sha ?? "",
150 tipDate: commit?.date ?? null,
151 tipAuthor: commit?.author ?? null,
152 tipMessage: commit?.message ?? null,
153 ahead,
154 behind,
155 isDefault,
156 });
157 } catch {
158 inputs.push({
159 name: b,
160 tipSha: "",
161 tipDate: null,
162 tipAuthor: null,
163 tipMessage: null,
164 ahead: 0,
165 behind: 0,
166 isDefault: b === defaultBranch,
167 });
168 }
169 }
170
171 const report = buildBranchReport({
172 branches: inputs,
173 defaultBranch,
174 threshold,
175 sort,
176 });
177
178 const kpi = (label: string, value: string) => (
179 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)">
180 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
181 {label}
182 </div>
183 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
184 {value}
185 </div>
186 </div>
187 );
188
189 return c.html(
190 <Layout title={`Branch age — ${ownerName}/${repoName}`} user={user}>
191 <RepoHeader owner={ownerName} repo={repoName} />
192 <div style="max-width: 1000px">
193 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
194 <h2 style="margin: 0">Branch staleness</h2>
195 <form
196 method="GET"
197 action={`/${ownerName}/${repoName}/branches/age`}
198 style="display: flex; gap: 8px; align-items: center"
199 >
200 <label for="threshold" style="font-size: 12px; color: var(--text-muted)">
201 Threshold:
202 </label>
203 <select
204 id="threshold"
205 name="threshold"
206 onchange="this.form.submit()"
207 style="padding: 4px 8px; font-size: 12px"
208 >
209 {VALID_THRESHOLDS.map((t) => (
210 <option value={String(t)} selected={t === threshold}>
211 {thresholdLabel(t)}
212 </option>
213 ))}
214 </select>
215 <label for="sort" style="font-size: 12px; color: var(--text-muted)">
216 Sort:
217 </label>
218 <select
219 id="sort"
220 name="sort"
221 onchange="this.form.submit()"
222 style="padding: 4px 8px; font-size: 12px"
223 >
224 {VALID_SORTS.map((s) => (
225 <option value={s} selected={s === sort}>
226 {sortLabel(s)}
227 </option>
228 ))}
229 </select>
230 </form>
231 </div>
232
233 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
234 Branch age = days since the tip commit. "Merged" means no commits ahead
235 of <code>{report.defaultBranch ?? "the default branch"}</code>. Default
236 branch is excluded from bucket counts.
237 </p>
238
239 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px">
240 {kpi("Total branches", String(report.summary.total))}
241 {kpi("Non-default", String(report.summary.nonDefault))}
242 {kpi("Merged", String(report.summary.merged))}
243 {kpi("Unmerged", String(report.summary.unmerged))}
244 {kpi(
245 "Median age",
246 report.summary.medianAgeDays === null
247 ? "\u2014"
248 : formatDaysOld(report.summary.medianAgeDays)
249 )}
250 {kpi(
251 "Oldest",
252 report.summary.oldestDaysOld === null
253 ? "\u2014"
254 : formatDaysOld(report.summary.oldestDaysOld)
255 )}
256 </div>
257
258 <h3 style="margin-bottom: 10px">Distribution</h3>
259 <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 24px">
260 {(["fresh", "aging", "stale", "abandoned"] as BranchAgeCategory[]).map(
261 (cat) => (
262 <div
263 style={`border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center; border-left: 4px solid ${ageColor(cat)}`}
264 >
265 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
266 {categoryLabel(cat)}
267 </div>
268 <div style="font-size: 18px; font-weight: 600">
269 {report.buckets[cat]}
270 </div>
271 </div>
272 )
273 )}
274 </div>
275
276 <h3 style="margin-bottom: 10px">
277 Branches ({report.filtered.length}
278 {report.filtered.length !== report.rows.length
279 ? ` of ${report.rows.length}`
280 : ""}
281 )
282 </h3>
283 {report.filtered.length === 0 ? (
284 <div class="empty-state">
285 <p>No branches match this threshold.</p>
286 </div>
287 ) : (
288 <table style="width: 100%; border-collapse: collapse">
289 <thead>
290 <tr>
291 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
292 Branch
293 </th>
294 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
295 Ahead
296 </th>
297 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
298 Behind
299 </th>
300 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 160px">
301 Last commit
302 </th>
303 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
304 Status
305 </th>
306 </tr>
307 </thead>
308 <tbody>
309 {report.filtered.map((r) => (
310 <tr>
311 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
312 <a
313 href={`/${ownerName}/${repoName}/tree/${encodeURIComponent(r.name)}`}
314 style={`font-family: var(--font-mono); font-weight: ${r.isDefault ? 600 : 400}`}
315 >
316 {r.name}
317 </a>
318 {r.isDefault && (
319 <span style="margin-left: 8px; font-size: 10px; padding: 2px 6px; background: var(--accent); color: white; border-radius: 10px">
320 default
321 </span>
322 )}
323 {r.tipAuthor && (
324 <span style="margin-left: 8px; font-size: 11px; color: var(--text-muted)">
325 by {r.tipAuthor}
326 </span>
327 )}
328 </td>
329 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
330 {r.ahead}
331 </td>
332 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
333 {r.behind}
334 </td>
335 <td style="padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px">
336 <span style={`color: ${ageColor(r.category)}`}>
337 {formatDaysOld(r.daysOld)}
338 </span>
339 </td>
340 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-size: 11px">
341 {r.isDefault ? (
342 <span style="color: var(--text-muted)">default</span>
343 ) : r.merged ? (
344 <span style="color: var(--green, #3fb950)">merged</span>
345 ) : (
346 <span style="color: var(--text-muted)">open</span>
347 )}
348 </td>
349 </tr>
350 ))}
351 </tbody>
352 </table>
353 )}
354 </div>
355 </Layout>
356 );
357});
358
359export default branchAgeRoutes;