Commit7c975c6unknown_key
feat(BLOCK-J): J18 repository pulse / activity summary
feat(BLOCK-J): J18 repository pulse / activity summary GitHub-parity Pulse page at /:owner/:repo/pulse[?window=1d|7d|30d|90d] that rolls up commit activity, PR opened/merged/closed/active, issue opened/closed/active, top contributors, and recent merges + closes over a selectable time window. - `src/lib/repo-pulse.ts` — pure rollup layer. Exports `PULSE_WINDOWS`, `DEFAULT_WINDOW='7d'`, `parseWindow` (validates or falls back), `windowStart` + `windowDays`, `summariseCommits` (in-window filter, email-lowercased author-grouping, count-desc/name-asc sort, first/ last SHA), `summarisePrs` (opened/mergedCount/closed/active + openedList + mergedList), `summariseIssues`, `buildPulseReport` one- shot builder, `__internal` re-exports. Zero-IO, tolerant of bad dates + empty inputs. - `src/routes/pulse.tsx` — softAuth handler. Fetches up to 500 commits via `listCommits` + 1000 PRs + 1000 issues in parallel; renders 8 KPI cards, top-10 contributors, recently-merged PRs, newly opened + closed issues. `resolveRepo` wrapped in try/catch so DB failure falls through to 404 (mirrors the J12 never-500 guarantee). - `src/routes/insights.tsx` — Insights page header gets a "Pulse →" link to the new page. - `src/app.tsx` — mounts `pulseRoutes` alongside the other J blocks. - `src/__tests__/repo-pulse.test.ts` — 23 tests covering window parsing, date-edge filters, author grouping, PR/issue bucketing with Date + ISO inputs, bad-date tolerance, `buildPulseReport` + `__internal` parity + route smokes. Total suite 965/965.
6 files changed+1000−27c975c66e2ccffa5194fed323eec69fd5ee1e090
6 changed files+1000−2
ModifiedBUILD_BIBLE.md+5−1View fileUnifiedSplit
@@ -139,6 +139,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
139139| Issue dependencies (blocked-by / blocks) | ✅ | J14 — `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (CHECK no-self, unique on pair, both-side indexes). Pure `wouldCreateCycle` BFS + `summariseBlockers`. `addDependency` enforces same-repo, no-self, no-dup, no-cycle with `{ok, reason}` error taxonomy. Issue detail page gets a "Dependencies" panel with "Blocked by" / "Blocks" lists, state pills, `#number` add form + per-row dismiss. `src/lib/issue-dependencies.ts` + routes in `src/routes/issues.tsx`. |
140140| Deterministic release-notes generator | ✅ | J15 — `src/lib/release-notes.ts` classifies commits by conventional-commit prefix (feat/fix/perf/refactor/docs/chore/revert/style/build/ci/test + aliases + `!` breaking marker + trailing `(#N)` capture) into 13 ordered buckets and renders Markdown with a Breaking-changes section, per-bucket headings, Contributors list, and Full-Changelog compare link. "Generate from commits" button on the new-release form prefills the notes textarea without losing other field state; AI-disabled repos now fall through to the deterministic path instead of publishing blank notes. `src/routes/releases.tsx` adds `POST /:owner/:repo/releases/generate-notes`. |
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. |
142| 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. |
142143| 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 |
143144| 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`. |
144145| 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. |
@@ -303,6 +304,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
303304- **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.
304305- **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.
305306- **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.
307- **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.
306308- **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.
307309- **J12** — Community profile / health scorecard → ✅ shipped. `GET /:owner/:repo/community` renders a GitHub-parity "Community standards" page scoring the repo on 8 checklist items: description, README, LICENSE (all required), CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR template, topics (recommended). `src/lib/community.ts` exposes pure matchers (`isReadme`, `isLicense`, `isCodeOfConduct`, `isContributing`, `isPrTemplate`), pure `checklistFromInputs` (drives all the I/O-free unit tests), `buildReport` (→ percent + required breakdown), and `computeHealth` (reads default-branch root tree + `.github/` subtree + repo metadata + topics). Each missing item offers a one-click "Add <path>" or "Edit settings" link. `src/routes/community.tsx` degrades to a zero-score report on git/DB failure — never 500s. 21 new tests. Total suite 844/844.
308310- **J11** — PR auto-assign reviewers from CODEOWNERS + requested-reviewers tracking → ✅ shipped. `drizzle/0034_pr_review_requests.sql` adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum `codeowners|manual|ai`, state enum `pending|approved|changes_requested|dismissed`). `src/lib/review-requests.ts` exposes pure helpers (`isValidSource`, `isValidState`, `nextState`, `sanitiseCandidates`) and DB helpers (`requestReviewers` idempotent, `listForPr` with username join, `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners`, `countPendingForUser`). On PR creation, `src/routes/pulls.tsx` runs `git diff --numstat base...head` to extract changed paths, calls `reviewersForChangedFiles` (Block B3 CODEOWNERS parser), resolves usernames → user IDs, excludes the PR author, and fires `review_requested` notifications. PR detail page renders a `ReviewersPanel` with per-reviewer state pills, source labels, and dismiss + manual-add forms for owner/author. Auto-assign runs fire-and-forget — CODEOWNERS failures never block PR creation. 17 new tests. Total suite 823/823.
@@ -506,6 +508,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
506508- `src/lib/pr-auto-merge.ts` (Block J16) — PR auto-merge opt-in helpers. `MERGE_METHODS = ['merge','squash','rebase']`, pure `isValidMergeMethod` + `computeAutoMergeAction({autoMergeEnabled, prState, isDraft, combinedState, totalChecks})` returning `{action: 'wait'|'merge'|'skip', reason}`. DB: `enableAutoMerge` (delete-then-insert), `disableAutoMerge`, `getAutoMergeForPr`, `recordEvaluation` (stamps `last_status`+`notified_ready`), `listAutoMergePrsForRepo` (open + auto-merge-enabled). `__internal` re-exports for tests.
507509- `src/lib/pr-auto-merge-trigger.ts` (Block J16) — fire-and-forget trigger called from `POST /api/v1/repos/:o/:r/statuses/:sha`. Resolves each opted-in PR's head branch via `resolveRef`, matches against the incoming SHA, runs `combinedStatus` + `computeAutoMergeAction`, and on first transition to ready posts a PR comment + notifies the PR author. All catch blocks so it never breaks the status write.
508510- `src/lib/issue-templates.ts` (Block J17) — multi-template frontmatter parser + git loader. Exports `TEMPLATE_DIRS` (four standard locations), `MAX_TEMPLATE_BYTES=32KB`, `MAX_TEMPLATES=20`. Pure: `splitFrontmatter` (handles `---\n...\n---\n` with trailing whitespace on the closing fence), `parseFrontmatterMeta` (flat `key: value`, flow-list `labels: [a,"b",c]`, block-list `labels:\n - a\n - b`, single+double quote stripping, case-insensitive keys, comment/blank tolerant), `slugFromFilename` (lowercase, non-alnum collapse, 64-char clamp), `buildTemplateFromFile` (merges filename + meta + body). DB/git: `listIssueTemplates` (scans `TEMPLATE_DIRS` on default branch, filters `.md|.markdown`, excludes `config.*`, dedupes by slug, caps at `MAX_TEMPLATES`, silently returns `[]` on any failure). `findTemplateBySlug` + `__internal` re-exports.
511- `src/lib/repo-pulse.ts` (Block J18) — pure rollup builder for the Pulse page. Exports `PULSE_WINDOWS = ['1d','7d','30d','90d']`, `DEFAULT_WINDOW='7d'`, `parseWindow` (validates or falls back), `windowStart(now, w)` (subtracts days, doesn't mutate), `windowDays`. Pure: `summariseCommits` (in-window filter, email-lowercased author-grouping, count-desc/name-asc sort, tracks first/last SHA), `summarisePrs` (opened = createdAt in window, mergedCount = mergedAt in window, closed = closedAt in window but not merged, active = state=open AND updatedAt in window, returns openedList + mergedList for the UI), `summariseIssues` (same shape for issues), `buildPulseReport` one-shot builder. `__internal` re-exports for tests.
512- `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.
509513
510514### 4.7 Views (locked contracts)
511515- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -540,7 +544,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
540544```bash
541545bun install
542546bun dev # hot reload
543bun test # 942 tests currently pass
547bun test # 965 tests currently pass
544548bun run db:migrate
545549```
546550
Addedsrc/__tests__/repo-pulse.test.ts+329−0View fileUnifiedSplit
@@ -0,0 +1,329 @@
1/**
2 * Block J18 — Repository pulse. Pure rollup unit tests + a route-auth
3 * smoke to make sure the pulse page is wired up.
4 */
5
6import { describe, it, expect } from "bun:test";
7import app from "../app";
8import {
9 PULSE_WINDOWS,
10 DEFAULT_WINDOW,
11 parseWindow,
12 windowStart,
13 windowDays,
14 summariseCommits,
15 summarisePrs,
16 summariseIssues,
17 buildPulseReport,
18 __internal,
19 type PulseCommit,
20 type PulsePr,
21 type PulseIssue,
22} from "../lib/repo-pulse";
23
24const NOW = new Date("2026-04-15T12:00:00Z");
25
26describe("repo-pulse — parseWindow", () => {
27 it("accepts the four canonical windows", () => {
28 for (const w of PULSE_WINDOWS) expect(parseWindow(w)).toBe(w);
29 });
30
31 it("falls back to DEFAULT_WINDOW on garbage", () => {
32 expect(parseWindow("")).toBe(DEFAULT_WINDOW);
33 expect(parseWindow("forever")).toBe(DEFAULT_WINDOW);
34 expect(parseWindow(null)).toBe(DEFAULT_WINDOW);
35 expect(parseWindow(undefined)).toBe(DEFAULT_WINDOW);
36 expect(parseWindow(42)).toBe(DEFAULT_WINDOW);
37 });
38});
39
40describe("repo-pulse — windowStart + windowDays", () => {
41 it("subtracts the correct day count from `now`", () => {
42 expect(windowDays("1d")).toBe(1);
43 expect(windowDays("7d")).toBe(7);
44 expect(windowDays("30d")).toBe(30);
45 expect(windowDays("90d")).toBe(90);
46 const s = windowStart(NOW, "7d");
47 expect(NOW.getTime() - s.getTime()).toBe(7 * 24 * 3600 * 1000);
48 });
49
50 it("does not mutate `now`", () => {
51 const t0 = NOW.getTime();
52 windowStart(NOW, "30d");
53 expect(NOW.getTime()).toBe(t0);
54 });
55});
56
57describe("repo-pulse — summariseCommits", () => {
58 const commits: PulseCommit[] = [
59 // In window
60 { sha: "a", author: "Alice", authorEmail: "a@x", date: "2026-04-14T00:00:00Z" },
61 { sha: "b", author: "Bob", authorEmail: "b@x", date: "2026-04-13T00:00:00Z" },
62 { sha: "c", author: "Alice", authorEmail: "a@x", date: "2026-04-12T00:00:00Z" },
63 // Out of window (older than 7d)
64 { sha: "d", author: "Alice", authorEmail: "a@x", date: "2026-04-01T00:00:00Z" },
65 ];
66
67 it("counts only commits in [start, end]", () => {
68 const start = windowStart(NOW, "7d");
69 const r = summariseCommits(commits, start, NOW);
70 expect(r.total).toBe(3);
71 expect(r.byAuthor.length).toBe(2);
72 });
73
74 it("sorts contributors by count desc, name asc as tiebreak", () => {
75 const start = windowStart(NOW, "7d");
76 const r = summariseCommits(commits, start, NOW);
77 expect(r.byAuthor[0].author).toBe("Alice");
78 expect(r.byAuthor[0].count).toBe(2);
79 expect(r.byAuthor[1].author).toBe("Bob");
80 });
81
82 it("groups by lowercased email, falling back to name", () => {
83 const cs: PulseCommit[] = [
84 { sha: "1", author: "Alice", authorEmail: "A@X", date: "2026-04-14T00:00:00Z" },
85 { sha: "2", author: "Alice", authorEmail: "a@x", date: "2026-04-13T00:00:00Z" },
86 ];
87 const r = summariseCommits(cs, windowStart(NOW, "7d"), NOW);
88 expect(r.byAuthor.length).toBe(1);
89 expect(r.byAuthor[0].count).toBe(2);
90 });
91
92 it("tracks firstSha + lastSha", () => {
93 const start = windowStart(NOW, "7d");
94 const r = summariseCommits(commits, start, NOW);
95 // Commits are newest-first, so lastSha is the newest (first in array).
96 expect(r.lastSha).toBe("a");
97 expect(r.firstSha).toBe("c");
98 });
99
100 it("returns zero counts on empty input", () => {
101 const r = summariseCommits([], windowStart(NOW, "7d"), NOW);
102 expect(r.total).toBe(0);
103 expect(r.byAuthor).toEqual([]);
104 expect(r.firstSha).toBeNull();
105 expect(r.lastSha).toBeNull();
106 });
107
108 it("ignores commits with invalid dates", () => {
109 const cs: PulseCommit[] = [
110 { sha: "1", author: "X", authorEmail: "x@x", date: "not-a-date" },
111 { sha: "2", author: "Y", authorEmail: "y@x", date: "2026-04-14T00:00:00Z" },
112 ];
113 const r = summariseCommits(cs, windowStart(NOW, "7d"), NOW);
114 expect(r.total).toBe(1);
115 });
116
117 it("falls back to (unknown) when author + email are both empty", () => {
118 const cs: PulseCommit[] = [
119 { sha: "1", author: "", authorEmail: "", date: "2026-04-14T00:00:00Z" },
120 ];
121 const r = summariseCommits(cs, windowStart(NOW, "7d"), NOW);
122 expect(r.byAuthor[0].author).toBe("(unknown)");
123 });
124});
125
126describe("repo-pulse — summarisePrs", () => {
127 const iso = (s: string) => s;
128 const prs: PulsePr[] = [
129 // Opened + merged in window
130 {
131 number: 1,
132 title: "A",
133 state: "merged",
134 createdAt: iso("2026-04-14T00:00:00Z"),
135 updatedAt: iso("2026-04-14T00:00:00Z"),
136 closedAt: iso("2026-04-14T00:00:00Z"),
137 mergedAt: iso("2026-04-14T00:00:00Z"),
138 },
139 // Opened in window, still open
140 {
141 number: 2,
142 title: "B",
143 state: "open",
144 createdAt: iso("2026-04-13T00:00:00Z"),
145 updatedAt: iso("2026-04-14T00:00:00Z"),
146 closedAt: null,
147 mergedAt: null,
148 },
149 // Closed (not merged) in window
150 {
151 number: 3,
152 title: "C",
153 state: "closed",
154 createdAt: iso("2026-01-01T00:00:00Z"),
155 updatedAt: iso("2026-04-12T00:00:00Z"),
156 closedAt: iso("2026-04-12T00:00:00Z"),
157 mergedAt: null,
158 },
159 // Entirely outside window
160 {
161 number: 4,
162 title: "D",
163 state: "merged",
164 createdAt: iso("2026-01-01T00:00:00Z"),
165 updatedAt: iso("2026-01-02T00:00:00Z"),
166 closedAt: iso("2026-01-02T00:00:00Z"),
167 mergedAt: iso("2026-01-02T00:00:00Z"),
168 },
169 ];
170
171 const start = windowStart(NOW, "7d");
172
173 it("counts opened/merged/closed correctly", () => {
174 const r = summarisePrs(prs, start, NOW);
175 expect(r.opened).toBe(2);
176 expect(r.mergedCount).toBe(1);
177 expect(r.closed).toBe(1);
178 });
179
180 it("reports `active` only for state=open with updatedAt in window", () => {
181 const r = summarisePrs(prs, start, NOW);
182 expect(r.active).toBe(1);
183 });
184
185 it("includes the merged PR in mergedList + opened PRs in openedList", () => {
186 const r = summarisePrs(prs, start, NOW);
187 expect(r.mergedList.map((p) => p.number)).toEqual([1]);
188 expect(r.openedList.map((p) => p.number).sort()).toEqual([1, 2]);
189 });
190
191 it("accepts Date instances as well as ISO strings", () => {
192 const withDates: PulsePr[] = [
193 {
194 number: 5,
195 title: "E",
196 state: "merged",
197 createdAt: new Date("2026-04-14T00:00:00Z"),
198 updatedAt: new Date("2026-04-14T00:00:00Z"),
199 closedAt: new Date("2026-04-14T00:00:00Z"),
200 mergedAt: new Date("2026-04-14T00:00:00Z"),
201 },
202 ];
203 const r = summarisePrs(withDates, start, NOW);
204 expect(r.opened).toBe(1);
205 expect(r.mergedCount).toBe(1);
206 });
207
208 it("handles empty list", () => {
209 const r = summarisePrs([], start, NOW);
210 expect(r.opened).toBe(0);
211 expect(r.mergedCount).toBe(0);
212 expect(r.closed).toBe(0);
213 expect(r.active).toBe(0);
214 });
215});
216
217describe("repo-pulse — summariseIssues", () => {
218 const start = windowStart(NOW, "7d");
219 const issueList: PulseIssue[] = [
220 {
221 number: 1,
222 title: "x",
223 state: "open",
224 createdAt: "2026-04-14T00:00:00Z",
225 updatedAt: "2026-04-14T00:00:00Z",
226 closedAt: null,
227 },
228 {
229 number: 2,
230 title: "y",
231 state: "closed",
232 createdAt: "2026-04-01T00:00:00Z",
233 updatedAt: "2026-04-13T00:00:00Z",
234 closedAt: "2026-04-13T00:00:00Z",
235 },
236 // Out of window
237 {
238 number: 3,
239 title: "z",
240 state: "closed",
241 createdAt: "2026-01-01T00:00:00Z",
242 updatedAt: "2026-01-02T00:00:00Z",
243 closedAt: "2026-01-02T00:00:00Z",
244 },
245 ];
246
247 it("buckets opened / closed / active", () => {
248 const r = summariseIssues(issueList, start, NOW);
249 expect(r.opened).toBe(1);
250 expect(r.closed).toBe(1);
251 expect(r.active).toBe(1);
252 });
253
254 it("populates openedList + closedList", () => {
255 const r = summariseIssues(issueList, start, NOW);
256 expect(r.openedList.map((i) => i.number)).toEqual([1]);
257 expect(r.closedList.map((i) => i.number)).toEqual([2]);
258 });
259
260 it("tolerates null closedAt / weird dates", () => {
261 const bad: PulseIssue[] = [
262 {
263 number: 99,
264 title: "bad",
265 state: "open",
266 createdAt: "oops",
267 updatedAt: "also oops",
268 closedAt: null,
269 },
270 ];
271 const r = summariseIssues(bad, start, NOW);
272 expect(r.opened).toBe(0);
273 expect(r.closed).toBe(0);
274 expect(r.active).toBe(0);
275 });
276});
277
278describe("repo-pulse — buildPulseReport", () => {
279 it("bundles all three rollups with a resolved window", () => {
280 const r = buildPulseReport({
281 window: "7d",
282 now: NOW,
283 commits: [
284 {
285 sha: "a",
286 author: "A",
287 authorEmail: "a@x",
288 date: "2026-04-14T00:00:00Z",
289 },
290 ],
291 prs: [],
292 issues: [],
293 });
294 expect(r.window).toBe("7d");
295 expect(r.days).toBe(7);
296 expect(r.start < r.end).toBe(true);
297 expect(r.commits.total).toBe(1);
298 expect(r.prs.opened).toBe(0);
299 expect(r.issues.opened).toBe(0);
300 });
301});
302
303describe("repo-pulse — __internal", () => {
304 it("re-exports the pure helpers for parity", () => {
305 expect(__internal.parseWindow).toBe(parseWindow);
306 expect(__internal.windowStart).toBe(windowStart);
307 expect(__internal.windowDays).toBe(windowDays);
308 expect(__internal.summariseCommits).toBe(summariseCommits);
309 expect(__internal.summarisePrs).toBe(summarisePrs);
310 expect(__internal.summariseIssues).toBe(summariseIssues);
311 expect(__internal.buildPulseReport).toBe(buildPulseReport);
312 expect(__internal.PULSE_WINDOWS).toBe(PULSE_WINDOWS);
313 expect(__internal.DEFAULT_WINDOW as string).toBe(DEFAULT_WINDOW);
314 });
315});
316
317describe("repo-pulse — routes", () => {
318 it("GET /:o/:r/pulse returns 404 for unknown repo", async () => {
319 const res = await app.request("/alice/nope/pulse");
320 expect(res.status).toBe(404);
321 });
322
323 it("GET /:o/:r/pulse?window=bogus normalises to default", async () => {
324 // Route resolves repo first, so we still expect 404 for unknown — this
325 // asserts the route accepts the query without crashing.
326 const res = await app.request("/alice/nope/pulse?window=bogus");
327 expect(res.status).toBe(404);
328 });
329});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -78,6 +78,7 @@ import commitStatusesRoutes from "./routes/commit-statuses";
7878import badgesRoutes from "./routes/badges";
7979import communityRoutes from "./routes/community";
8080import pinnedReposRoutes from "./routes/pinned-repos";
81import pulseRoutes from "./routes/pulse";
8182import webRoutes from "./routes/web";
8283
8384const app = new Hono();
@@ -274,6 +275,9 @@ app.route("/", communityRoutes);
274275// Pinned repositories on user profile — /settings/pins (Block J13)
275276app.route("/", pinnedReposRoutes);
276277
278// Repository pulse — /:owner/:repo/pulse (Block J18)
279app.route("/", pulseRoutes);
280
277281// Insights + milestones
278282app.route("/", insightsRoutes);
279283
Addedsrc/lib/repo-pulse.ts+286−0View fileUnifiedSplit
@@ -0,0 +1,286 @@
1/**
2 * Block J18 — Repository pulse / activity summary.
3 *
4 * Pure rollups for the `/:owner/:repo/pulse` page. Takes already-fetched
5 * commits + PR + issue rows and buckets them into a time window. No I/O —
6 * the route handler is responsible for querying git + Drizzle.
7 *
8 * A "pulse" is a recent-activity snapshot over a rolling window (1d / 7d /
9 * 30d / 90d). It answers: "who's been pushing, what's moving, what's new
10 * and what's closing?"
11 */
12
13export const PULSE_WINDOWS = ["1d", "7d", "30d", "90d"] as const;
14export type PulseWindow = (typeof PULSE_WINDOWS)[number];
15export const DEFAULT_WINDOW: PulseWindow = "7d";
16
17const WINDOW_DAYS: Record<PulseWindow, number> = {
18 "1d": 1,
19 "7d": 7,
20 "30d": 30,
21 "90d": 90,
22};
23
24/** Pure: validate a raw string as a supported pulse window, else fall back. */
25export function parseWindow(raw: unknown): PulseWindow {
26 if (typeof raw === "string" && (PULSE_WINDOWS as readonly string[]).includes(raw)) {
27 return raw as PulseWindow;
28 }
29 return DEFAULT_WINDOW;
30}
31
32/** Pure: return the Date at the start of the window relative to `now`. */
33export function windowStart(now: Date, w: PulseWindow): Date {
34 const days = WINDOW_DAYS[w];
35 const d = new Date(now.getTime());
36 d.setUTCDate(d.getUTCDate() - days);
37 return d;
38}
39
40/** Pure: number of days represented by a given pulse window. */
41export function windowDays(w: PulseWindow): number {
42 return WINDOW_DAYS[w];
43}
44
45function toMs(d: string | Date | null | undefined): number | null {
46 if (d === null || d === undefined) return null;
47 if (d instanceof Date) {
48 const t = d.getTime();
49 return Number.isFinite(t) ? t : null;
50 }
51 const t = Date.parse(d);
52 return Number.isFinite(t) ? t : null;
53}
54
55function inWindow(t: number | null, start: Date, end: Date): boolean {
56 if (t === null) return false;
57 return t >= start.getTime() && t <= end.getTime();
58}
59
60// ---------------------------------------------------------------------------
61// Commits
62// ---------------------------------------------------------------------------
63
64export interface PulseCommit {
65 sha: string;
66 author: string;
67 authorEmail: string;
68 date: string;
69 message?: string;
70}
71
72export interface ContributorCount {
73 author: string;
74 email: string;
75 count: number;
76}
77
78export interface CommitPulse {
79 total: number;
80 byAuthor: ContributorCount[];
81 firstSha: string | null;
82 lastSha: string | null;
83}
84
85/**
86 * Pure: count commits inside [start, end] and group by author email.
87 * `commits` is the newest-first list returned by `listCommits`.
88 */
89export function summariseCommits(
90 commits: PulseCommit[],
91 start: Date,
92 end: Date
93): CommitPulse {
94 const inRange = commits.filter((c) => inWindow(toMs(c.date), start, end));
95 const counts = new Map<string, ContributorCount>();
96 for (const c of inRange) {
97 const emailKey = (c.authorEmail || "").toLowerCase().trim();
98 const nameKey = (c.author || "").toLowerCase().trim();
99 const key = emailKey || nameKey || "(unknown)";
100 const prev = counts.get(key);
101 if (prev) {
102 prev.count += 1;
103 } else {
104 counts.set(key, {
105 author: c.author || "(unknown)",
106 email: c.authorEmail || "",
107 count: 1,
108 });
109 }
110 }
111 const byAuthor = Array.from(counts.values()).sort(
112 (a, b) => b.count - a.count || a.author.localeCompare(b.author)
113 );
114 return {
115 total: inRange.length,
116 byAuthor,
117 firstSha: inRange.length ? inRange[inRange.length - 1].sha : null,
118 lastSha: inRange.length ? inRange[0].sha : null,
119 };
120}
121
122// ---------------------------------------------------------------------------
123// Pull requests
124// ---------------------------------------------------------------------------
125
126export interface PulsePr {
127 id?: string;
128 number: number;
129 title: string;
130 state: string; // "open" | "closed" | "merged"
131 isDraft?: boolean;
132 authorName?: string;
133 createdAt: string | Date;
134 updatedAt: string | Date;
135 closedAt: string | Date | null;
136 mergedAt: string | Date | null;
137}
138
139export interface PrPulse {
140 opened: number;
141 mergedCount: number;
142 closed: number;
143 active: number;
144 openedList: PulsePr[];
145 mergedList: PulsePr[];
146}
147
148/**
149 * Pure: bucket PRs by what changed in-window.
150 * - `opened`: createdAt in window
151 * - `mergedCount`: mergedAt in window (mutually exclusive with closed)
152 * - `closed`: closedAt in window AND not merged in window
153 * - `active`: state='open' and updatedAt in window
154 */
155export function summarisePrs(prs: PulsePr[], start: Date, end: Date): PrPulse {
156 let opened = 0,
157 mergedCount = 0,
158 closed = 0,
159 active = 0;
160 const openedList: PulsePr[] = [];
161 const mergedList: PulsePr[] = [];
162 for (const p of prs) {
163 const created = toMs(p.createdAt);
164 const closedMs = toMs(p.closedAt);
165 const mergedMs = toMs(p.mergedAt);
166 const updated = toMs(p.updatedAt);
167 const createdIn = inWindow(created, start, end);
168 const mergedIn = inWindow(mergedMs, start, end);
169 const closedIn = inWindow(closedMs, start, end);
170 if (createdIn) {
171 opened++;
172 openedList.push(p);
173 }
174 if (mergedIn) {
175 mergedCount++;
176 mergedList.push(p);
177 } else if (closedIn) {
178 closed++;
179 }
180 if (p.state === "open" && inWindow(updated, start, end)) active++;
181 }
182 return { opened, mergedCount, closed, active, openedList, mergedList };
183}
184
185// ---------------------------------------------------------------------------
186// Issues
187// ---------------------------------------------------------------------------
188
189export interface PulseIssue {
190 id?: string;
191 number: number;
192 title: string;
193 state: string; // "open" | "closed"
194 authorName?: string;
195 createdAt: string | Date;
196 updatedAt: string | Date;
197 closedAt: string | Date | null;
198}
199
200export interface IssuePulse {
201 opened: number;
202 closed: number;
203 active: number;
204 openedList: PulseIssue[];
205 closedList: PulseIssue[];
206}
207
208/**
209 * Pure: bucket issues into opened/closed/active counts over the window.
210 * - `opened`: createdAt in window
211 * - `closed`: closedAt in window
212 * - `active`: state='open' AND updatedAt in window
213 */
214export function summariseIssues(
215 issues: PulseIssue[],
216 start: Date,
217 end: Date
218): IssuePulse {
219 let opened = 0,
220 closed = 0,
221 active = 0;
222 const openedList: PulseIssue[] = [];
223 const closedList: PulseIssue[] = [];
224 for (const i of issues) {
225 const created = toMs(i.createdAt);
226 const closedMs = toMs(i.closedAt);
227 const updated = toMs(i.updatedAt);
228 if (inWindow(created, start, end)) {
229 opened++;
230 openedList.push(i);
231 }
232 if (inWindow(closedMs, start, end)) {
233 closed++;
234 closedList.push(i);
235 }
236 if (i.state === "open" && inWindow(updated, start, end)) active++;
237 }
238 return { opened, closed, active, openedList, closedList };
239}
240
241// ---------------------------------------------------------------------------
242// One-shot builder
243// ---------------------------------------------------------------------------
244
245export interface PulseReport {
246 window: PulseWindow;
247 days: number;
248 start: string;
249 end: string;
250 commits: CommitPulse;
251 prs: PrPulse;
252 issues: IssuePulse;
253}
254
255export function buildPulseReport(opts: {
256 window: PulseWindow;
257 now: Date;
258 commits: PulseCommit[];
259 prs: PulsePr[];
260 issues: PulseIssue[];
261}): PulseReport {
262 const start = windowStart(opts.now, opts.window);
263 const end = opts.now;
264 return {
265 window: opts.window,
266 days: windowDays(opts.window),
267 start: start.toISOString(),
268 end: end.toISOString(),
269 commits: summariseCommits(opts.commits, start, end),
270 prs: summarisePrs(opts.prs, start, end),
271 issues: summariseIssues(opts.issues, start, end),
272 };
273}
274
275export const __internal = {
276 PULSE_WINDOWS,
277 DEFAULT_WINDOW,
278 WINDOW_DAYS,
279 parseWindow,
280 windowStart,
281 windowDays,
282 summariseCommits,
283 summarisePrs,
284 summariseIssues,
285 buildPulseReport,
286};
Modifiedsrc/routes/insights.tsx+9−1View fileUnifiedSplit
@@ -149,7 +149,15 @@ insights.get("/:owner/:repo/insights", async (c) => {
149149 currentUser={user?.username || null}
150150 />
151151 <RepoNav owner={owner} repo={repo} active="insights" />
152 <h3 style="margin-bottom: 16px">Insights</h3>
152 <div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px">
153 <h3 style="margin: 0">Insights</h3>
154 <a
155 href={`/${owner}/${repo}/pulse`}
156 style="font-size: 12px; color: var(--accent)"
157 >
158 Pulse →
159 </a>
160 </div>
153161
154162 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px">
155163 <div class="panel" style="padding: 16px">
Addedsrc/routes/pulse.tsx+367−0View fileUnifiedSplit
@@ -0,0 +1,367 @@
1/**
2 * Block J18 — Repository pulse.
3 *
4 * GET /:owner/:repo/pulse[?window=1d|7d|30d|90d]
5 *
6 * Renders a GitHub-parity "Pulse" overview: commit activity, active PRs,
7 * recent merges/closes, issue throughput, top contributors — bucketed into
8 * a rolling window. Read-only. softAuth so public repos are accessible to
9 * logged-out visitors.
10 */
11
12import { Hono } from "hono";
13import { and, eq } from "drizzle-orm";
14import { db } from "../db";
15import { issues, pullRequests, repositories, users } from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader, RepoNav } from "../views/components";
18import { softAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import { listCommits, getDefaultBranch } from "../git/repository";
21import {
22 PULSE_WINDOWS,
23 type PulseWindow,
24 parseWindow,
25 buildPulseReport,
26 windowDays,
27 type PulseCommit,
28 type PulsePr,
29 type PulseIssue,
30} from "../lib/repo-pulse";
31
32const pulseRoutes = new Hono<AuthEnv>();
33
34async function resolveRepo(ownerName: string, repoName: string) {
35 try {
36 const [owner] = await db
37 .select()
38 .from(users)
39 .where(eq(users.username, ownerName))
40 .limit(1);
41 if (!owner) return null;
42 const [repo] = await db
43 .select()
44 .from(repositories)
45 .where(
46 and(
47 eq(repositories.ownerId, owner.id),
48 eq(repositories.name, repoName)
49 )
50 )
51 .limit(1);
52 if (!repo) return null;
53 return { owner, repo };
54 } catch {
55 return null;
56 }
57}
58
59const WINDOW_LABEL: Record<PulseWindow, string> = {
60 "1d": "24 hours",
61 "7d": "7 days",
62 "30d": "30 days",
63 "90d": "90 days",
64};
65
66pulseRoutes.get("/:owner/:repo/pulse", softAuth, async (c) => {
67 const { owner: ownerName, repo: repoName } = c.req.param();
68 const user = c.get("user");
69 const w: PulseWindow = parseWindow(c.req.query("window"));
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 inputs in parallel -------------------------------------------
96 const [commitsRaw, prRows, issueRows, defaultBranch] = await Promise.all([
97 (async (): Promise<PulseCommit[]> => {
98 try {
99 const ref =
100 (await getDefaultBranch(ownerName, repoName)) || "HEAD";
101 const commits = await listCommits(ownerName, repoName, ref, 500, 0);
102 return commits.map((c) => ({
103 sha: c.sha,
104 author: c.author,
105 authorEmail: c.authorEmail,
106 date: c.date,
107 message: c.message,
108 }));
109 } catch {
110 return [];
111 }
112 })(),
113 db
114 .select({
115 id: pullRequests.id,
116 number: pullRequests.number,
117 title: pullRequests.title,
118 state: pullRequests.state,
119 isDraft: pullRequests.isDraft,
120 authorName: users.username,
121 createdAt: pullRequests.createdAt,
122 updatedAt: pullRequests.updatedAt,
123 closedAt: pullRequests.closedAt,
124 mergedAt: pullRequests.mergedAt,
125 })
126 .from(pullRequests)
127 .innerJoin(users, eq(pullRequests.authorId, users.id))
128 .where(eq(pullRequests.repositoryId, repo.id))
129 .limit(1000),
130 db
131 .select({
132 id: issues.id,
133 number: issues.number,
134 title: issues.title,
135 state: issues.state,
136 authorName: users.username,
137 createdAt: issues.createdAt,
138 updatedAt: issues.updatedAt,
139 closedAt: issues.closedAt,
140 })
141 .from(issues)
142 .innerJoin(users, eq(issues.authorId, users.id))
143 .where(eq(issues.repositoryId, repo.id))
144 .limit(1000),
145 getDefaultBranch(ownerName, repoName).catch(() => "HEAD"),
146 ]);
147
148 const now = new Date();
149 const report = buildPulseReport({
150 window: w,
151 now,
152 commits: commitsRaw,
153 prs: prRows as PulsePr[],
154 issues: issueRows as PulseIssue[],
155 });
156
157 const label = WINDOW_LABEL[w];
158 const days = windowDays(w);
159
160 return c.html(
161 <Layout title={`Pulse — ${ownerName}/${repoName}`} user={user}>
162 <RepoHeader owner={ownerName} repo={repoName} />
163 <RepoNav owner={ownerName} repo={repoName} active="insights" />
164 <div style="max-width: 960px; margin-top: 16px">
165 <div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px">
166 <h2 style="margin: 0">Pulse</h2>
167 <div style="display: flex; gap: 6px">
168 {PULSE_WINDOWS.map((opt) => (
169 <a
170 href={`/${ownerName}/${repoName}/pulse?window=${opt}`}
171 class={`btn ${opt === w ? "btn-primary" : ""}`}
172 style="padding: 4px 10px; font-size: 12px"
173 >
174 {WINDOW_LABEL[opt]}
175 </a>
176 ))}
177 </div>
178 </div>
179
180 <p style="color: var(--text-muted); margin-bottom: 24px">
181 Activity in the last {label} on{" "}
182 <code>{defaultBranch || "HEAD"}</code> —{" "}
183 <strong>{report.commits.total}</strong> commit
184 {report.commits.total === 1 ? "" : "s"} from{" "}
185 <strong>{report.commits.byAuthor.length}</strong> contributor
186 {report.commits.byAuthor.length === 1 ? "" : "s"}.
187 </p>
188
189 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px">
190 <PulseCard label="Opened PRs" value={report.prs.opened} tone="blue" />
191 <PulseCard
192 label="Merged PRs"
193 value={report.prs.mergedCount}
194 tone="green"
195 />
196 <PulseCard
197 label="Closed PRs"
198 value={report.prs.closed}
199 tone="red"
200 />
201 <PulseCard
202 label="Active PRs"
203 value={report.prs.active}
204 tone="grey"
205 />
206 <PulseCard
207 label="Opened issues"
208 value={report.issues.opened}
209 tone="blue"
210 />
211 <PulseCard
212 label="Closed issues"
213 value={report.issues.closed}
214 tone="red"
215 />
216 <PulseCard
217 label="Active issues"
218 value={report.issues.active}
219 tone="grey"
220 />
221 <PulseCard
222 label="Commits"
223 value={report.commits.total}
224 tone="green"
225 />
226 </div>
227
228 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px">
229 <section>
230 <h3 style="font-size: 14px; margin-bottom: 8px">
231 Top contributors
232 </h3>
233 {report.commits.byAuthor.length === 0 ? (
234 <p style="color: var(--text-muted); font-size: 13px">
235 No commits in the last {label}.
236 </p>
237 ) : (
238 <ul style="list-style: none; padding: 0; margin: 0">
239 {report.commits.byAuthor.slice(0, 10).map((a) => (
240 <li style="display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border)">
241 <span style="font-weight: 500">{a.author}</span>
242 <span style="color: var(--text-muted); font-size: 12px">
243 {a.count} commit{a.count === 1 ? "" : "s"}
244 </span>
245 </li>
246 ))}
247 </ul>
248 )}
249 </section>
250
251 <section>
252 <h3 style="font-size: 14px; margin-bottom: 8px">
253 Recently merged PRs
254 </h3>
255 {report.prs.mergedList.length === 0 ? (
256 <p style="color: var(--text-muted); font-size: 13px">
257 No PRs merged in the last {label}.
258 </p>
259 ) : (
260 <ul style="list-style: none; padding: 0; margin: 0">
261 {report.prs.mergedList.slice(0, 10).map((p) => (
262 <li style="padding: 6px 0; border-bottom: 1px solid var(--border)">
263 <a
264 href={`/${ownerName}/${repoName}/pulls/${p.number}`}
265 style="font-weight: 500"
266 >
267 #{p.number}
268 </a>{" "}
269 {p.title}
270 {p.authorName && (
271 <span style="color: var(--text-muted); font-size: 12px">
272 {" "}
273 · {p.authorName}
274 </span>
275 )}
276 </li>
277 ))}
278 </ul>
279 )}
280 </section>
281 </div>
282
283 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 24px">
284 <section>
285 <h3 style="font-size: 14px; margin-bottom: 8px">
286 Newly opened issues
287 </h3>
288 {report.issues.openedList.length === 0 ? (
289 <p style="color: var(--text-muted); font-size: 13px">
290 No new issues in the last {label}.
291 </p>
292 ) : (
293 <ul style="list-style: none; padding: 0; margin: 0">
294 {report.issues.openedList.slice(0, 10).map((i) => (
295 <li style="padding: 6px 0; border-bottom: 1px solid var(--border)">
296 <a
297 href={`/${ownerName}/${repoName}/issues/${i.number}`}
298 style="font-weight: 500"
299 >
300 #{i.number}
301 </a>{" "}
302 {i.title}
303 </li>
304 ))}
305 </ul>
306 )}
307 </section>
308
309 <section>
310 <h3 style="font-size: 14px; margin-bottom: 8px">
311 Newly closed issues
312 </h3>
313 {report.issues.closedList.length === 0 ? (
314 <p style="color: var(--text-muted); font-size: 13px">
315 No issues closed in the last {label}.
316 </p>
317 ) : (
318 <ul style="list-style: none; padding: 0; margin: 0">
319 {report.issues.closedList.slice(0, 10).map((i) => (
320 <li style="padding: 6px 0; border-bottom: 1px solid var(--border)">
321 <a
322 href={`/${ownerName}/${repoName}/issues/${i.number}`}
323 style="font-weight: 500"
324 >
325 #{i.number}
326 </a>{" "}
327 {i.title}
328 </li>
329 ))}
330 </ul>
331 )}
332 </section>
333 </div>
334
335 <p style="color: var(--text-muted); font-size: 11px; margin-top: 24px">
336 Window: {days} day{days === 1 ? "" : "s"} · {report.start.slice(0, 10)}{" "}
337 → {report.end.slice(0, 10)}
338 </p>
339 </div>
340 </Layout>
341 );
342});
343
344const TONE_COLORS: Record<string, string> = {
345 green: "#2ea043",
346 red: "#f85149",
347 blue: "#58a6ff",
348 grey: "var(--text-muted)",
349};
350
351function PulseCard(props: { label: string; value: number; tone: string }) {
352 const colour = TONE_COLORS[props.tone] || TONE_COLORS.grey;
353 return (
354 <div style="border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; background: var(--bg-secondary)">
355 <div
356 style={`font-size: 22px; font-weight: 600; color: ${colour}; line-height: 1`}
357 >
358 {props.value}
359 </div>
360 <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px">
361 {props.label}
362 </div>
363 </div>
364 );
365}
366
367export default pulseRoutes;
0368