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

stale-issues.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.

stale-issues.tsxBlame312 lines · 1 contributor
d6b4e67Claude1/**
2 * Block J20 — Stale issue detector.
3 *
4 * GET /:owner/:repo/issues/stale[?period=30d|60d|90d|180d]
5 *
6 * Lists all *open* issues whose `updated_at` is older than the selected
7 * threshold — "no activity in N days". Read-only; softAuth so public
8 * repos are visible to logged-out visitors. Private repos 404 for
9 * non-owner viewers.
10 *
11 * Filtering + bucketing logic lives in `src/lib/stale-issues.ts` (pure
12 * helper, exhaustively unit-tested). This route is thin glue.
13 */
14
15import { Hono } from "hono";
16import { and, eq, sql } from "drizzle-orm";
17import { db } from "../db";
18import { issueComments, issues, repositories, users } from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import {
24 STALE_PERIODS,
25 type StalePeriod,
26 parsePeriod,
27 buildStaleReport,
28 periodDays,
29 type StaleInputIssue,
30} from "../lib/stale-issues";
31
32const staleRoutes = new Hono<AuthEnv>();
33
34const PERIOD_LABEL: Record<StalePeriod, string> = {
35 "30d": "30 days",
36 "60d": "60 days",
37 "90d": "90 days",
38 "180d": "180 days",
39};
40
41async function resolveRepo(ownerName: string, repoName: string) {
42 try {
43 const [owner] = await db
44 .select()
45 .from(users)
46 .where(eq(users.username, ownerName))
47 .limit(1);
48 if (!owner) return null;
49 const [repo] = await db
50 .select()
51 .from(repositories)
52 .where(
53 and(
54 eq(repositories.ownerId, owner.id),
55 eq(repositories.name, repoName)
56 )
57 )
58 .limit(1);
59 if (!repo) return null;
60 return { owner, repo };
61 } catch {
62 return null;
63 }
64}
65
66staleRoutes.get("/:owner/:repo/issues/stale", softAuth, async (c) => {
67 const { owner: ownerName, repo: repoName } = c.req.param();
68 const user = c.get("user");
69 const period: StalePeriod = parsePeriod(c.req.query("period"));
70
71 const resolved = await resolveRepo(ownerName, repoName);
72 if (!resolved) {
73 return c.html(
74 <Layout title="Not Found" user={user}>
75 <div class="empty-state">
76 <h2>Repository not found</h2>
77 </div>
78 </Layout>,
79 404
80 );
81 }
82
83 const { repo } = resolved;
84 if (repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
85 return c.html(
86 <Layout title="Not Found" user={user}>
87 <div class="empty-state">
88 <h2>Repository not found</h2>
89 </div>
90 </Layout>,
91 404
92 );
93 }
94
95 // Fetch open issues + comment counts in parallel.
96 let rawIssues: StaleInputIssue[] = [];
97 try {
98 const rows = await db
99 .select({
100 number: issues.number,
101 title: issues.title,
102 state: issues.state,
103 authorName: users.username,
104 createdAt: issues.createdAt,
105 updatedAt: issues.updatedAt,
106 })
107 .from(issues)
108 .innerJoin(users, eq(issues.authorId, users.id))
109 .where(
110 and(eq(issues.repositoryId, repo.id), eq(issues.state, "open"))
111 )
112 .limit(2000);
113
114 // Comment counts: one subquery, map by issue number via join on
115 // issue_id — simpler to compute per-issue with a count query, but
116 // this route is bounded (max 2000 issues) so we do a single grouped
117 // query keyed by issue.number for display.
118 let commentCounts: Record<number, number> = {};
119 try {
120 const cc = await db
121 .select({
122 number: issues.number,
123 count: sql<number>`count(${issueComments.id})::int`,
124 })
125 .from(issues)
126 .leftJoin(issueComments, eq(issueComments.issueId, issues.id))
127 .where(
128 and(eq(issues.repositoryId, repo.id), eq(issues.state, "open"))
129 )
130 .groupBy(issues.number);
131 for (const row of cc) {
132 commentCounts[row.number] = Number(row.count) || 0;
133 }
134 } catch {
135 commentCounts = {};
136 }
137
138 rawIssues = rows.map((r) => ({
139 number: r.number,
140 title: r.title,
141 state: r.state,
142 authorName: r.authorName,
143 createdAt: r.createdAt,
144 updatedAt: r.updatedAt,
145 commentCount: commentCounts[r.number] ?? 0,
146 }));
147 } catch {
148 rawIssues = [];
149 }
150
151 const now = new Date();
152 const report = buildStaleReport({ period, now, issues: rawIssues });
153 const openTotal = rawIssues.length;
154 const label = PERIOD_LABEL[period];
155 const days = periodDays(period);
156
157 return c.html(
158 <Layout title={`Stale issues — ${ownerName}/${repoName}`} user={user}>
159 <RepoHeader owner={ownerName} repo={repoName} />
160 <RepoNav owner={ownerName} repo={repoName} active="issues" />
161 <div style="max-width: 960px; margin-top: 16px">
162 <div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px">
163 <h2 style="margin: 0">Stale issues</h2>
164 <div style="display: flex; gap: 6px; flex-wrap: wrap">
165 {STALE_PERIODS.map((p) => (
166 <a
167 href={`/${ownerName}/${repoName}/issues/stale?period=${p}`}
168 class={`btn ${p === period ? "btn-primary" : ""}`}
169 style="padding: 4px 10px; font-size: 12px"
170 >
171 {PERIOD_LABEL[p]}
172 </a>
173 ))}
174 </div>
175 </div>
176
177 <p style="color: var(--text-muted); margin-bottom: 20px">
178 Open issues with no activity in the last{" "}
179 <strong>{label}</strong>. Showing{" "}
180 <strong>{report.total}</strong> of{" "}
181 <strong>{openTotal}</strong> open issue
182 {openTotal === 1 ? "" : "s"}.
183 </p>
184
185 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 20px">
186 <BucketCard
187 label={`${days}–60d`}
188 count={report.buckets["30-60"].length}
189 visible={days <= 30}
190 />
191 <BucketCard
192 label="60–90d"
193 count={report.buckets["60-90"].length}
194 visible={days <= 60}
195 />
196 <BucketCard
197 label="90–180d"
198 count={report.buckets["90-180"].length}
199 visible={days <= 90}
200 />
201 <BucketCard
202 label="180d+"
203 count={report.buckets["180+"].length}
204 visible={true}
205 />
206 </div>
207
208 {report.issues.length === 0 ? (
209 <div
210 class="empty-state"
211 style="padding: 40px 16px; border: 1px dashed var(--border); border-radius: 6px"
212 >
213 <h3 style="margin: 0 0 6px 0">No stale issues</h3>
214 <p style="color: var(--text-muted); margin: 0">
215 Every open issue has had activity in the last {label}. Nice
216 work!
217 </p>
218 </div>
219 ) : (
220 <table
221 style="width: 100%; border-collapse: collapse; font-size: 13px"
222 >
223 <thead>
224 <tr style="text-align: left; color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em">
225 <th style="padding: 6px 8px; border-bottom: 1px solid var(--border)">
226 #
227 </th>
228 <th style="padding: 6px 8px; border-bottom: 1px solid var(--border)">
229 Title
230 </th>
231 <th style="padding: 6px 8px; border-bottom: 1px solid var(--border)">
232 Author
233 </th>
234 <th style="padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: right">
235 Comments
236 </th>
237 <th style="padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: right">
238 Last activity
239 </th>
240 </tr>
241 </thead>
242 <tbody>
243 {report.issues.map((i) => (
244 <tr>
245 <td style="padding: 8px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace">
246 #{i.number}
247 </td>
248 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
249 <a
250 href={`/${ownerName}/${repoName}/issues/${i.number}`}
251 style="font-weight: 500"
252 >
253 {i.title}
254 </a>
255 </td>
256 <td style="padding: 8px; border-bottom: 1px solid var(--border); color: var(--text-muted)">
257 {i.authorName}
258 </td>
259 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; color: var(--text-muted)">
260 {i.commentCount}
261 </td>
262 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right">
263 <span
264 style={
265 "color: " +
266 (i.daysSinceUpdate >= 180
267 ? "#f85149"
268 : i.daysSinceUpdate >= 90
269 ? "#f0883e"
270 : "var(--text-muted)")
271 }
272 title={i.updatedAt}
273 >
274 {i.daysSinceUpdate} day
275 {i.daysSinceUpdate === 1 ? "" : "s"} ago
276 </span>
277 </td>
278 </tr>
279 ))}
280 </tbody>
281 </table>
282 )}
283
284 <p style="color: var(--text-muted); font-size: 11px; margin-top: 18px">
285 Threshold: {days} day{days === 1 ? "" : "s"}. Activity is measured
286 by the issue's <code>updated_at</code> timestamp (comments + edits
287 refresh it).
288 </p>
289 </div>
290 </Layout>
291 );
292});
293
294function BucketCard(props: {
295 label: string;
296 count: number;
297 visible: boolean;
298}) {
299 if (!props.visible) return null as unknown as JSX.Element;
300 return (
301 <div style="border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; background: var(--bg-secondary)">
302 <div style="font-size: 20px; font-weight: 600; line-height: 1">
303 {props.count}
304 </div>
305 <div style="color: var(--text-muted); font-size: 11px; margin-top: 4px">
306 {props.label}
307 </div>
308 </div>
309 );
310}
311
312export default staleRoutes;