Commitd6b4e67unknown_key
feat(BLOCK-J): J20 stale issue detector
feat(BLOCK-J): J20 stale issue detector Serves /:owner/:repo/issues/stale with a period selector (30/60/90/180d) listing every open issue whose updated_at is older than the threshold. Pure filterStale + bucketByStaleness in src/lib/stale-issues.ts (22 tests). Age-coloured table, four age-bucket cards, empty-state when nothing is stale. Non-destructive — we only surface staleness, never auto-close. Mounted BEFORE issueRoutes so the static path wins over /issues/:number.
6 files changed+789−5d6b4e67bfd14f9c1378b75ab68b454881cd3f415
6 changed files+789−5
ModifiedBUILD_BIBLE.md+4−0View fileUnifiedSplit
@@ -141,6 +141,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
141141| PR auto-merge when checks pass | ✅ | J16 — `drizzle/0037_pr_auto_merge.sql` adds `pr_auto_merge` (unique on `pull_request_id`, merge-method text, commit-title/message overrides, last_status + notified_ready). Pure `computeAutoMergeAction` state machine returns `wait\|merge\|skip` with reason codes (`not_enabled\|pr_closed\|pr_draft\|no_checks\|checks_pending\|checks_failed\|checks_passed`). `src/lib/pr-auto-merge.ts` exposes enable/disable + record-evaluation helpers; `src/lib/pr-auto-merge-trigger.ts` is called fire-and-forget from the commit-status POST path — it resolves each opted-in PR's head branch, matches against the incoming SHA, and posts a one-shot readiness comment + PR-author notification on first transition to ready. PR detail page shows an `AutoMergePanel` with live status colour (green/yellow/red) and merge-method selector. |
142142| Repository pulse / activity summary | ✅ | J18 — `GET /:owner/:repo/pulse[?window=1d\|7d\|30d\|90d]` renders GitHub-parity Pulse: commit activity, PR opened/merged/closed/active, issue opened/closed/active, top contributors, recent merges + closes. `src/lib/repo-pulse.ts` is pure-first (`parseWindow`, `windowStart`, `summariseCommits` grouping by lowercased email with count-desc/name-asc sort, `summarisePrs`, `summariseIssues`, `buildPulseReport` one-shot). softAuth read-only; degrades to an empty-state report on DB/git failure. Linked from the Insights page header. |
143143| Atom / RSS feeds | ✅ | J19 — `GET /:owner/:repo/{commits,releases,issues}.atom` serve Atom 1.0 feeds (up to 50 entries each). Pure renderer in `src/lib/atom-feed.ts` — `renderAtomFeed` emits XML-declaration-prefixed `<feed>` with required `<id>/<title>/<updated>/<link rel=self>`, per-entry `<id>/<title>/<link>/<updated>/<published>/<author>/<summary>`. All five XML metacharacters escaped. `toIsoUtc` normalises Date + ISO inputs, falls back to epoch on junk. Cache headers `public, max-age=60, stale-while-revalidate=300`; `application/atom+xml; charset=utf-8` content-type. Private repos 404 (as empty feed doc) for non-owner viewers. |
144| Stale issue detector | ✅ | J20 — `GET /:owner/:repo/issues/stale[?period=30d\|60d\|90d\|180d]` lists every open issue whose `updated_at` is older than the selected threshold, sorted oldest-first with per-issue `daysSinceUpdate` + comment count + age-colour (>=90d amber, >=180d red). Four age-bucket cards show the distribution (`30-60`, `60-90`, `90-180`, `180+`). Pure `filterStale` / `bucketByStaleness` / `buildStaleReport` in `src/lib/stale-issues.ts` accept both `Date` and ISO inputs and gracefully drop unparseable timestamps. softAuth read-only; private repos 404 for non-owner viewers. Linked from the Issues toolbar via a "Stale" button. Non-destructive — we only surface staleness, never auto-close. |
144145| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
145146| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
146147| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
@@ -305,6 +306,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
305306- **J14** — Issue dependencies / blocked-by relationships → ✅ shipped. `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (`blocker_issue_id`, `blocked_issue_id`, CHECK `issue_dep_no_self`, unique on the pair, indexes on both sides). `src/lib/issue-dependencies.ts` exposes pure `wouldCreateCycle(edges, blocker, blocked)` (BFS following forward blocks edges) + `summariseBlockers` plus DB helpers: `addDependency` (`{ok, reason}` taxonomy rejecting self / cross_repo / exists / cycle / not_found / error), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Issue detail page in `src/routes/issues.tsx` now renders a "Dependencies" panel above the body showing Blocked-by + Blocks lists, per-row dismiss buttons (owner/author only), and a `#number`-form to add a new blocker. Two POST routes mirror the J11 pattern: `/:o/:r/issues/:n/dependencies` (add) and `/:o/:r/issues/:n/dependencies/:which/:otherId/remove`. Permission gated by `user.id === owner.id || user.id === issue.authorId`. 14 new tests covering cycle detection (direct + transitive + diamond + deep chains), summariseBlockers counts, and route-auth smokes. Total suite 867/867.
306307- **J15** — Deterministic release-notes generator → ✅ shipped. `src/lib/release-notes.ts` ships zero-IO helpers: `classifyCommit` (conventional prefix + scope + `!` breaking + trailing `(#N)` PR capture; aliases `feature`/`bugfix`/`doc`/`tests`; Merge-commit detection for pull requests and branches), `groupCommits`, `contributorsFrom`, `renderNotesMarkdown` (Breaking-changes section first, then 13 ordered buckets, then Contributors + Full-Changelog compare link). `src/routes/releases.tsx` adds `POST /:owner/:repo/releases/generate-notes` which re-renders the new-release form with notes pre-filled (preserves tag/name/target/draft/prerelease fields) plus a notice banner on missing commits or resolve failure. AI-disabled repos now fall through to the deterministic renderer on publish instead of writing empty notes. 30 new tests. Total suite 897/897.
307308- **J16** — PR auto-merge when checks pass → ✅ shipped. `drizzle/0037_pr_auto_merge.sql` adds `pr_auto_merge` (unique on `pull_request_id`, merge-method text, commit-title/message overrides, last_status + last_checked_at + notified_ready columns for the status-hook watcher). `src/lib/pr-auto-merge.ts` is the pure state machine + DB accessor: `computeAutoMergeAction({autoMergeEnabled, prState, isDraft, combinedState, totalChecks}) → {action: wait|merge|skip, reason}` covers all nine paths (not_enabled / pr_closed / pr_draft / no_checks / checks_pending / checks_failed / checks_passed). `src/lib/pr-auto-merge-trigger.ts` is called fire-and-forget from the commit-status POST — resolves each opted-in PR's head branch, matches against the incoming SHA, runs `combinedStatus` + the pure decider, and on first green transition posts a PR comment + PR-author notification. PR detail page gets an `AutoMergePanel` with live status colour (green/yellow/red), merge-method selector (merge/squash/rebase), and disable button. 16 new tests. Total suite 913/913.
309- **J20** — Stale issue detector → ✅ shipped. `src/lib/stale-issues.ts` is a pure filter/bucketer — `STALE_PERIODS = ['30d','60d','90d','180d']`, `parsePeriod` with default-on-garbage, `filterStale(issues, now, thresholdDays)` (open-only, parseable-dates-only, oldest-first), `bucketByStaleness` (30-60 / 60-90 / 90-180 / 180+ with inclusive lower bounds), `buildStaleReport` one-shot builder. Accepts both Date and ISO inputs; drops unparseable timestamps silently. `src/routes/stale-issues.tsx` serves `GET /:owner/:repo/issues/stale[?period=…]` (softAuth, read-only), fetches open issues (2000 cap) with a grouped comment count, and renders four bucket cards + an age-coloured table (amber >=90d, red >=180d). Route is mounted BEFORE `issueRoutes` so the static `/issues/stale` path wins over `/issues/:number`. `resolveRepo` wrapped in try/catch — DB failure 404s, never 500. Issues-list toolbar gets a new "Stale" button. Non-destructive: surfaces staleness only; no auto-close. 22 new tests covering period parsing, filter threshold semantics + boundary inclusion + oldest-first sort, bad-date tolerance, bucket boundaries, `buildStaleReport`, `__internal` parity, route smokes. Total suite 1007/1007.
308310- **J19** — Atom / RSS feeds for commits, releases, issues → ✅ shipped. `src/lib/atom-feed.ts` is a pure Atom 1.0 renderer — `escapeXml` (five XML metacharacters), `toIsoUtc` (Date + ISO, epoch fallback), `renderAtomFeed` (XML-decl + `<feed xmlns>` + feed metadata + entries with `<id>/<title>/<link>/<updated>/<published>/<author>/<summary>/<content>`), `ATOM_CONTENT_TYPE`. `pickFeedUpdated` auto-derives from newest entry when not set explicitly. `src/routes/feeds.ts` serves three feeds — `/:owner/:repo/{commits,releases,issues}.atom`, each capped at 50 entries. `softAuth`; private repos receive an empty-feed response so external readers stay non-crashing. Cache: `public, max-age=60, stale-while-revalidate=300`. Commits sourced from `listCommits` on the default branch; releases filter out drafts; issues span both open + closed, newest-first. 20 new tests covering escape edge cases, toIsoUtc, feed structure, well-formedness with zero entries, metacharacter safety, newest-entry auto-update, explicit updatedAt respect, route content-type + cache headers. Total suite 985/985.
309311- **J18** — Repository pulse / activity summary → ✅ shipped. `src/lib/repo-pulse.ts` rolls already-fetched commits + PR + issue rows into time-windowed buckets: `parseWindow` + `windowStart` (1d / 7d / 30d / 90d), `summariseCommits` (in-window filter, email-lowercased author-grouping, count-desc/name-asc sort, first/last SHA), `summarisePrs` (opened/mergedCount/closed/active + openedList + mergedList), `summariseIssues` (opened/closed/active + openedList + closedList), `buildPulseReport` one-shot. `src/routes/pulse.tsx` serves `GET /:owner/:repo/pulse[?window=…]` (softAuth, read-only); fetches up to 500 commits via `listCommits` + 1000 PRs + 1000 issues in parallel; renders eight KPI cards, top-10 contributors, recent merges + newly-opened/closed issues. `resolveRepo` wrapped in try/catch so DB failure falls through to 404, mirroring the J12 community page's never-500 guarantee. Insights page header links to Pulse. 23 new tests covering window parsing, date-edge filters, author grouping, PR/issue bucketing with Date + ISO inputs, bad-date tolerance, `buildPulseReport` + `__internal` re-exports + route smokes. Total suite 965/965.
310312- **J17** — Multi-template issue selector → ✅ shipped. Extends the single-file `ISSUE_TEMPLATE.md` loader with full GitHub-parity `.github/ISSUE_TEMPLATE/*.md` directory support (plus `.gluecron/ISSUE_TEMPLATE/` fallback, upper + lowercase variants). `src/lib/issue-templates.ts` is pure-first: `splitFrontmatter` + `parseFrontmatterMeta` parse the narrow YAML subset used by issue-template frontmatter (flat `key: value`, flow-lists `[a,"b",c]`, block-lists `- a`, single+double quote stripping, case-insensitive keys). `slugFromFilename` normalises the file basename into a URL-safe slug, clamped to 64 chars. `buildTemplateFromFile` merges filename + meta + body into a typed `IssueTemplate`. `listIssueTemplates(owner, repo)` scans the four standard dirs on the default branch, filters `.md|.markdown` (excludes `config.*`), caps at 20 templates, dedupes by slug, and swallows all git failures to `[]`. `findTemplateBySlug` finds the active pick. `src/routes/issues.tsx` new-issue GET handler renders a chooser page when 2+ templates exist; picked template prefills the title input (from frontmatter `title:`) + body textarea (from body) + label pills. Single template auto-picks; `?template=__blank` escape hatch skips prefill. Falls back to the legacy `ISSUE_TEMPLATE.md` loader when no frontmatter templates are found. 29 new tests covering frontmatter split (with/without fence, CRLF tolerance, trailing whitespace on closing fence), flat/flow-list/block-list parsing, slug normalisation + unicode, `buildTemplateFromFile` (with/without frontmatter, empty dir path), `findTemplateBySlug` null/unknown/exact, `__internal` re-exports, and route-auth smokes. Total suite 942/942.
@@ -514,6 +516,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
514516- `src/routes/pulse.tsx` (Block J18) — serves `GET /:owner/:repo/pulse[?window=…]`. softAuth. Fetches commits via `listCommits` (500 limit), PR + issue rows via Drizzle (1000 limit each) in parallel, passes all three into `buildPulseReport`. Renders eight KPI cards (opened/merged/closed/active PRs, opened/closed/active issues, commits), top-10 contributors, recently-merged PRs, newly-opened + closed issues. `resolveRepo` wrapped in try/catch so DB failure returns 404 instead of 500. Insights page header gets a "Pulse →" link.
515517- `src/lib/atom-feed.ts` (Block J19) — pure Atom 1.0 feed renderer. Exports `escapeXml` (five metacharacters), `toIsoUtc` (Date + ISO with epoch fallback), `renderAtomFeed(input)` (XML-declaration-prefixed, `<feed>` with `<id>/<title>/<updated>/<link rel=self>`, per-entry `<id>/<title>/<link>/<updated>/<published>/<author>/<summary>/<content>`), `ATOM_CONTENT_TYPE` constant, `__internal` re-exports. Zero deps; `pickFeedUpdated` auto-derives from newest entry when not set.
516518- `src/routes/feeds.ts` (Block J19) — serves `GET /:owner/:repo/{commits,releases,issues}.atom`. softAuth. Up to 50 entries per feed. `resolveRepo` wrapped in try/catch; private repos 404 (as empty-feed doc) for non-owner viewers. Every handler returns `application/atom+xml; charset=utf-8` with `Cache-Control: public, max-age=60, stale-while-revalidate=300`. Commits source: `listCommits` on default branch. Releases source: `releases` table minus drafts. Issues source: `issues` table newest-first.
519- `src/lib/stale-issues.ts` (Block J20) — pure stale-issue filter + bucketer. Exports `STALE_PERIODS = ['30d','60d','90d','180d']`, `DEFAULT_STALE_PERIOD='60d'`, `periodDays`, `parsePeriod`. Pure: `filterStale(issues, now, thresholdDays)` drops non-open states + unparseable `updatedAt`, returns oldest-first with `daysSinceUpdate` and carried-through `commentCount`; `bucketByStaleness` splits into `30-60` / `60-90` / `90-180` / `180+` (inclusive at lower bound); `buildStaleReport` one-shot builder emits `{period, thresholdDays, now: ISO, total, issues, buckets}`. Accepts Date + ISO; `__internal` re-exports.
520- `src/routes/stale-issues.tsx` (Block J20) — serves `GET /:owner/:repo/issues/stale[?period=…]`. softAuth. Fetches open issues only (2000 cap) plus a grouped `count(issue_comments.id)` per issue number, calls `buildStaleReport`. `resolveRepo` wrapped in try/catch so DB failure returns 404, not 500. **Must be mounted BEFORE `issueRoutes`** so the static `/issues/stale` path wins over `/issues/:number` (which would `parseInt("stale")` into NaN). Renders four bucket cards (auto-hides buckets below the threshold), an age-coloured table (>=90d amber, >=180d red), or an empty-state card. Non-destructive: we surface staleness, never auto-close.
517521
518522### 4.7 Views (locked contracts)
519523- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Addedsrc/__tests__/stale-issues.test.ts+278−0View fileUnifiedSplit
@@ -0,0 +1,278 @@
1/**
2 * Block J20 — Stale issue detector. Pure helpers + route smokes.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import {
8 STALE_PERIODS,
9 DEFAULT_STALE_PERIOD,
10 periodDays,
11 parsePeriod,
12 filterStale,
13 bucketByStaleness,
14 buildStaleReport,
15 __internal,
16 type StaleInputIssue,
17} from "../lib/stale-issues";
18
19function daysAgo(n: number): Date {
20 const now = new Date("2026-04-15T12:00:00Z");
21 return new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
22}
23
24const NOW = new Date("2026-04-15T12:00:00Z");
25
26const issueAt = (
27 number: number,
28 daysSince: number,
29 state = "open",
30 authorName = "alice"
31): StaleInputIssue => ({
32 number,
33 title: `Issue ${number}`,
34 state,
35 authorName,
36 createdAt: daysAgo(daysSince + 30),
37 updatedAt: daysAgo(daysSince),
38});
39
40describe("stale-issues — constants & parse", () => {
41 it("exports the canonical period list", () => {
42 expect(STALE_PERIODS).toEqual(["30d", "60d", "90d", "180d"]);
43 expect(DEFAULT_STALE_PERIOD).toBe("60d");
44 });
45
46 it("periodDays returns day counts", () => {
47 expect(periodDays("30d")).toBe(30);
48 expect(periodDays("60d")).toBe(60);
49 expect(periodDays("90d")).toBe(90);
50 expect(periodDays("180d")).toBe(180);
51 });
52
53 it("parsePeriod accepts valid values", () => {
54 expect(parsePeriod("30d")).toBe("30d");
55 expect(parsePeriod("60d")).toBe("60d");
56 expect(parsePeriod("90d")).toBe("90d");
57 expect(parsePeriod("180d")).toBe("180d");
58 });
59
60 it("parsePeriod falls back to default on garbage", () => {
61 expect(parsePeriod("")).toBe(DEFAULT_STALE_PERIOD);
62 expect(parsePeriod("ever")).toBe(DEFAULT_STALE_PERIOD);
63 expect(parsePeriod(null)).toBe(DEFAULT_STALE_PERIOD);
64 expect(parsePeriod(undefined)).toBe(DEFAULT_STALE_PERIOD);
65 expect(parsePeriod(123)).toBe(DEFAULT_STALE_PERIOD);
66 expect(parsePeriod("1y")).toBe(DEFAULT_STALE_PERIOD);
67 });
68});
69
70describe("stale-issues — filterStale", () => {
71 it("keeps only open issues beyond the threshold", () => {
72 const input = [
73 issueAt(1, 10), // too fresh
74 issueAt(2, 45),
75 issueAt(3, 100),
76 issueAt(4, 200, "closed"), // closed, dropped
77 ];
78 const out = filterStale(input, NOW, 30);
79 expect(out.map((i) => i.number)).toEqual([3, 2]);
80 });
81
82 it("is inclusive at the threshold boundary", () => {
83 const out = filterStale([issueAt(1, 30)], NOW, 30);
84 expect(out).toHaveLength(1);
85 });
86
87 it("sorts oldest-first then by number", () => {
88 const out = filterStale(
89 [issueAt(3, 40), issueAt(1, 40), issueAt(2, 100)],
90 NOW,
91 30
92 );
93 expect(out.map((i) => i.number)).toEqual([2, 1, 3]);
94 });
95
96 it("gracefully skips issues with unparseable updatedAt", () => {
97 const bad: StaleInputIssue = {
98 number: 9,
99 title: "broken",
100 state: "open",
101 authorName: "x",
102 createdAt: "2026-01-01",
103 updatedAt: "not-a-date",
104 };
105 const out = filterStale([bad, issueAt(1, 40)], NOW, 30);
106 expect(out.map((i) => i.number)).toEqual([1]);
107 });
108
109 it("accepts ISO strings for updatedAt + createdAt", () => {
110 const iso: StaleInputIssue = {
111 number: 1,
112 title: "iso",
113 state: "open",
114 authorName: "x",
115 createdAt: "2026-01-01T00:00:00Z",
116 updatedAt: "2026-02-01T00:00:00Z",
117 };
118 const out = filterStale([iso], NOW, 30);
119 expect(out).toHaveLength(1);
120 expect(out[0].daysSinceUpdate).toBeGreaterThan(60);
121 });
122
123 it("carries commentCount through when provided", () => {
124 const withComments: StaleInputIssue = {
125 ...issueAt(5, 50),
126 commentCount: 7,
127 };
128 const out = filterStale([withComments], NOW, 30);
129 expect(out[0].commentCount).toBe(7);
130 });
131
132 it("defaults commentCount to 0 when missing", () => {
133 const out = filterStale([issueAt(1, 50)], NOW, 30);
134 expect(out[0].commentCount).toBe(0);
135 });
136
137 it("ignores non-open states (draft, in_progress, etc)", () => {
138 const odd: StaleInputIssue = {
139 ...issueAt(1, 100),
140 state: "archived",
141 };
142 const out = filterStale([odd], NOW, 30);
143 expect(out).toHaveLength(0);
144 });
145});
146
147describe("stale-issues — bucketByStaleness", () => {
148 it("splits issues into the right buckets", () => {
149 const staleOnly = filterStale(
150 [
151 issueAt(1, 35), // 30-60
152 issueAt(2, 70), // 60-90
153 issueAt(3, 120), // 90-180
154 issueAt(4, 365), // 180+
155 ],
156 NOW,
157 30
158 );
159 const b = bucketByStaleness(staleOnly);
160 expect(b["30-60"].map((i) => i.number)).toEqual([1]);
161 expect(b["60-90"].map((i) => i.number)).toEqual([2]);
162 expect(b["90-180"].map((i) => i.number)).toEqual([3]);
163 expect(b["180+"].map((i) => i.number)).toEqual([4]);
164 });
165
166 it("drops issues with daysSinceUpdate < 30", () => {
167 const out = bucketByStaleness([
168 {
169 number: 1,
170 title: "recent",
171 authorName: "a",
172 createdAt: new Date().toISOString(),
173 updatedAt: new Date().toISOString(),
174 daysSinceUpdate: 10,
175 commentCount: 0,
176 },
177 ]);
178 expect(out["30-60"]).toHaveLength(0);
179 expect(out["60-90"]).toHaveLength(0);
180 expect(out["90-180"]).toHaveLength(0);
181 expect(out["180+"]).toHaveLength(0);
182 });
183
184 it("is inclusive at bucket boundaries (60, 90, 180)", () => {
185 const mk = (d: number) => ({
186 number: d,
187 title: `t${d}`,
188 authorName: "a",
189 createdAt: new Date().toISOString(),
190 updatedAt: new Date().toISOString(),
191 daysSinceUpdate: d,
192 commentCount: 0,
193 });
194 const b = bucketByStaleness([mk(60), mk(90), mk(180)]);
195 expect(b["60-90"].map((i) => i.number)).toEqual([60]);
196 expect(b["90-180"].map((i) => i.number)).toEqual([90]);
197 expect(b["180+"].map((i) => i.number)).toEqual([180]);
198 });
199});
200
201describe("stale-issues — buildStaleReport", () => {
202 it("assembles period + threshold + buckets + total", () => {
203 const report = buildStaleReport({
204 period: "30d",
205 now: NOW,
206 issues: [
207 issueAt(1, 10),
208 issueAt(2, 40),
209 issueAt(3, 100),
210 issueAt(4, 200),
211 ],
212 });
213 expect(report.period).toBe("30d");
214 expect(report.thresholdDays).toBe(30);
215 expect(report.total).toBe(3);
216 expect(report.issues.map((i) => i.number)).toEqual([4, 3, 2]);
217 expect(report.buckets["30-60"]).toHaveLength(1);
218 expect(report.buckets["90-180"]).toHaveLength(1);
219 expect(report.buckets["180+"]).toHaveLength(1);
220 });
221
222 it("respects the chosen period for the threshold cutoff", () => {
223 // With a 90d threshold, only issues older than 90 days qualify.
224 const report = buildStaleReport({
225 period: "90d",
226 now: NOW,
227 issues: [issueAt(1, 40), issueAt(2, 100), issueAt(3, 200)],
228 });
229 expect(report.total).toBe(2);
230 expect(report.issues.map((i) => i.number)).toEqual([3, 2]);
231 });
232
233 it("emits an ISO `now` for display", () => {
234 const report = buildStaleReport({
235 period: "60d",
236 now: NOW,
237 issues: [],
238 });
239 expect(report.now).toBe("2026-04-15T12:00:00.000Z");
240 expect(report.total).toBe(0);
241 });
242});
243
244describe("stale-issues — __internal parity", () => {
245 it("re-exports all helpers", () => {
246 expect(__internal.STALE_PERIODS).toBe(STALE_PERIODS);
247 expect(__internal.DEFAULT_STALE_PERIOD).toBe(DEFAULT_STALE_PERIOD);
248 expect(__internal.periodDays).toBe(periodDays);
249 expect(__internal.parsePeriod).toBe(parsePeriod);
250 expect(__internal.filterStale).toBe(filterStale);
251 expect(__internal.bucketByStaleness).toBe(bucketByStaleness);
252 expect(__internal.buildStaleReport).toBe(buildStaleReport);
253 });
254});
255
256describe("stale-issues — routes", () => {
257 it("GET /:o/:r/issues/stale returns 200 + page chrome for public view", async () => {
258 const res = await app.request("/alice/nope/issues/stale");
259 expect(res.status).toBe(404);
260 });
261
262 it("unknown period falls back to default without 500", async () => {
263 const res = await app.request(
264 "/alice/nope/issues/stale?period=9999x"
265 );
266 // Repo doesn't exist so we get the 404 path — important: no 500.
267 expect(res.status).toBe(404);
268 });
269
270 it("accepts each valid period without 500", async () => {
271 for (const p of STALE_PERIODS) {
272 const res = await app.request(
273 `/alice/nope/issues/stale?period=${p}`
274 );
275 expect([200, 404]).toContain(res.status);
276 }
277 });
278});
Modifiedsrc/app.tsx+5−0View fileUnifiedSplit
@@ -80,6 +80,7 @@ import communityRoutes from "./routes/community";
8080import pinnedReposRoutes from "./routes/pinned-repos";
8181import pulseRoutes from "./routes/pulse";
8282import feedRoutes from "./routes/feeds";
83import staleIssuesRoutes from "./routes/stale-issues";
8384import webRoutes from "./routes/web";
8485
8586const app = new Hono();
@@ -169,6 +170,10 @@ app.route("/", webhookRoutes);
169170// Compare view (branch diffs)
170171app.route("/", compareRoutes);
171172
173// Stale issue detector — must be BEFORE issueRoutes so the static
174// `/issues/stale` path wins over the dynamic `/issues/:number` (Block J20).
175app.route("/", staleIssuesRoutes);
176
172177// Issue tracker
173178app.route("/", issueRoutes);
174179
Addedsrc/lib/stale-issues.ts+176−0View fileUnifiedSplit
@@ -0,0 +1,176 @@
1/**
2 * Block J20 — Stale issue detector.
3 *
4 * Pure filtering/bucketing helper. Given a list of open issues and a
5 * threshold in days, it surfaces issues whose `updatedAt` is older than
6 * the threshold (i.e. "no activity in N days"). Route in
7 * `src/routes/stale-issues.tsx` does the DB + rendering; this module is
8 * IO-free so it can be exhaustively unit-tested.
9 *
10 * GitHub's "stale bot" uses a two-stage marking → closing flow — we only
11 * implement the *detection* half (non-destructive) because automatic
12 * closing demands per-repo opt-in + dry-run tooling we don't want to
13 * ship silently.
14 */
15export const STALE_PERIODS = ["30d", "60d", "90d", "180d"] as const;
16export type StalePeriod = (typeof STALE_PERIODS)[number];
17export const DEFAULT_STALE_PERIOD: StalePeriod = "60d";
18
19export function periodDays(p: StalePeriod): number {
20 switch (p) {
21 case "30d":
22 return 30;
23 case "60d":
24 return 60;
25 case "90d":
26 return 90;
27 case "180d":
28 return 180;
29 }
30}
31
32export function parsePeriod(raw: unknown): StalePeriod {
33 if (typeof raw !== "string") return DEFAULT_STALE_PERIOD;
34 const match = (STALE_PERIODS as readonly string[]).includes(raw);
35 return match ? (raw as StalePeriod) : DEFAULT_STALE_PERIOD;
36}
37
38export interface StaleInputIssue {
39 number: number;
40 title: string;
41 state: string;
42 authorName: string;
43 createdAt: Date | string;
44 updatedAt: Date | string;
45 commentCount?: number;
46}
47
48export interface StaleIssue {
49 number: number;
50 title: string;
51 authorName: string;
52 createdAt: string;
53 updatedAt: string;
54 daysSinceUpdate: number;
55 commentCount: number;
56}
57
58export interface StaleBuckets {
59 "30-60": StaleIssue[];
60 "60-90": StaleIssue[];
61 "90-180": StaleIssue[];
62 "180+": StaleIssue[];
63}
64
65export interface StaleReport {
66 period: StalePeriod;
67 thresholdDays: number;
68 now: string;
69 total: number;
70 issues: StaleIssue[];
71 buckets: StaleBuckets;
72}
73
74function toDate(v: Date | string | null | undefined): Date | null {
75 if (v instanceof Date) {
76 const t = v.getTime();
77 return Number.isFinite(t) ? v : null;
78 }
79 if (typeof v === "string" && v) {
80 const t = Date.parse(v);
81 if (Number.isFinite(t)) return new Date(t);
82 }
83 return null;
84}
85
86function daysBetween(a: Date, b: Date): number {
87 const ms = Math.abs(a.getTime() - b.getTime());
88 return Math.floor(ms / (24 * 60 * 60 * 1000));
89}
90
91/**
92 * Select only *open* issues whose most recent activity is older than
93 * `thresholdDays` relative to `now`. Output is sorted oldest-first.
94 */
95export function filterStale(
96 issues: StaleInputIssue[],
97 now: Date,
98 thresholdDays: number
99): StaleIssue[] {
100 const out: StaleIssue[] = [];
101 for (const i of issues) {
102 if (i.state !== "open") continue;
103 const updated = toDate(i.updatedAt);
104 const created = toDate(i.createdAt);
105 if (!updated) continue;
106 const days = daysBetween(now, updated);
107 if (days < thresholdDays) continue;
108 out.push({
109 number: i.number,
110 title: i.title,
111 authorName: i.authorName,
112 createdAt: (created ?? updated).toISOString(),
113 updatedAt: updated.toISOString(),
114 daysSinceUpdate: days,
115 commentCount: i.commentCount ?? 0,
116 });
117 }
118 // Oldest activity first (highest daysSinceUpdate first).
119 out.sort((a, b) => {
120 if (a.daysSinceUpdate !== b.daysSinceUpdate)
121 return b.daysSinceUpdate - a.daysSinceUpdate;
122 return a.number - b.number;
123 });
124 return out;
125}
126
127/**
128 * Put already-staleness-filtered issues into age buckets so the UI can
129 * surface a quick "how bad is it" breakdown.
130 */
131export function bucketByStaleness(issues: StaleIssue[]): StaleBuckets {
132 const out: StaleBuckets = {
133 "30-60": [],
134 "60-90": [],
135 "90-180": [],
136 "180+": [],
137 };
138 for (const i of issues) {
139 const d = i.daysSinceUpdate;
140 if (d >= 180) out["180+"].push(i);
141 else if (d >= 90) out["90-180"].push(i);
142 else if (d >= 60) out["60-90"].push(i);
143 else if (d >= 30) out["30-60"].push(i);
144 // Issues with d < 30 are dropped — they aren't "stale" by any bucket.
145 }
146 return out;
147}
148
149export function buildStaleReport(opts: {
150 period: StalePeriod;
151 now: Date;
152 issues: StaleInputIssue[];
153}): StaleReport {
154 const threshold = periodDays(opts.period);
155 const filtered = filterStale(opts.issues, opts.now, threshold);
156 return {
157 period: opts.period,
158 thresholdDays: threshold,
159 now: opts.now.toISOString(),
160 total: filtered.length,
161 issues: filtered,
162 buckets: bucketByStaleness(filtered),
163 };
164}
165
166export const __internal = {
167 STALE_PERIODS,
168 DEFAULT_STALE_PERIOD,
169 periodDays,
170 parsePeriod,
171 filterStale,
172 bucketByStaleness,
173 buildStaleReport,
174 toDate,
175 daysBetween,
176};
Modifiedsrc/routes/issues.tsx+14−5View fileUnifiedSplit
@@ -111,14 +111,23 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
111111 {counts?.closed ?? 0} Closed
112112 </a>
113113 </div>
114 {user && (
114 <div style="display: flex; gap: 8px; align-items: center">
115115 <a
116 href={`/${ownerName}/${repoName}/issues/new`}
117 class="btn btn-primary"
116 href={`/${ownerName}/${repoName}/issues/stale`}
117 class="btn"
118 style="padding: 4px 10px; font-size: 12px"
118119 >
119 New issue
120 Stale
120121 </a>
121 )}
122 {user && (
123 <a
124 href={`/${ownerName}/${repoName}/issues/new`}
125 class="btn btn-primary"
126 >
127 New issue
128 </a>
129 )}
130 </div>
122131 </div>
123132 {issueList.length === 0 ? (
124133 <div class="empty-state">
Addedsrc/routes/stale-issues.tsx+312−0View fileUnifiedSplit
@@ -0,0 +1,312 @@
1/**
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;
0313