Commit26484ebunknown_key
feat(BLOCK-J): J19 atom feeds for commits/releases/issues
feat(BLOCK-J): J19 atom feeds for commits/releases/issues
Serves /:owner/:repo/{commits,releases,issues}.atom as Atom 1.0
documents. Pure renderer in src/lib/atom-feed.ts (no deps, 20 tests)
escapes XML metacharacters and auto-derives <updated> from newest
entry. Private repos return an empty feed so readers don't choke.5 files changed+669−026484eb143d89728e34f3ddb8a149aa179e3e519
5 changed files+669−0
ModifiedBUILD_BIBLE.md+4−0View fileUnifiedSplit
@@ -140,6 +140,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
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. |
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. |
143| 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. |
143144| 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 |
144145| 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`. |
145146| 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. |
@@ -304,6 +305,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
304305- **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.
305306- **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.
306307- **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.
308- **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.
307309- **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.
308310- **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.
309311- **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.
@@ -510,6 +512,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
510512- `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.
511513- `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.
512514- `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.
515- `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.
516- `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.
513517
514518### 4.7 Views (locked contracts)
515519- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Addedsrc/__tests__/atom-feed.test.ts+231−0View fileUnifiedSplit
@@ -0,0 +1,231 @@
1/**
2 * Block J19 — Atom feed renderer. Pure XML + route smokes.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import {
8 escapeXml,
9 toIsoUtc,
10 renderAtomFeed,
11 ATOM_CONTENT_TYPE,
12 __internal,
13 type AtomEntry,
14} from "../lib/atom-feed";
15
16describe("atom-feed — escapeXml", () => {
17 it("escapes the five XML metacharacters", () => {
18 expect(escapeXml("a < b & c > d")).toBe("a < b & c > d");
19 expect(escapeXml(`"single'`)).toBe(""single'");
20 });
21
22 it("returns '' for empty / falsy input", () => {
23 expect(escapeXml("")).toBe("");
24 });
25
26 it("idempotent escape of already-escaped text", () => {
27 // Double-escaping is expected — this is a serialiser, not a re-encoder.
28 // We just need it not to throw or drop content.
29 const once = escapeXml("&");
30 const twice = escapeXml(once);
31 expect(twice).toBe("&amp;");
32 });
33});
34
35describe("atom-feed — toIsoUtc", () => {
36 it("converts valid ISO strings to ISO-UTC", () => {
37 const out = toIsoUtc("2026-04-15T12:00:00Z");
38 expect(out).toBe("2026-04-15T12:00:00.000Z");
39 });
40
41 it("accepts Date instances", () => {
42 const out = toIsoUtc(new Date("2026-04-15T12:00:00Z"));
43 expect(out).toBe("2026-04-15T12:00:00.000Z");
44 });
45
46 it("falls back to epoch on garbage", () => {
47 expect(toIsoUtc("not-a-date")).toBe("1970-01-01T00:00:00.000Z");
48 expect(toIsoUtc(null)).toBe("1970-01-01T00:00:00.000Z");
49 expect(toIsoUtc(undefined)).toBe("1970-01-01T00:00:00.000Z");
50 expect(toIsoUtc("")).toBe("1970-01-01T00:00:00.000Z");
51 });
52});
53
54describe("atom-feed — renderAtomFeed", () => {
55 const entry: AtomEntry = {
56 id: "tag:gluecron,2026:alice/repo/commit/abc",
57 title: "Fix bug",
58 href: "https://gluecron.com/alice/repo/commit/abc",
59 updatedAt: "2026-04-15T12:00:00Z",
60 summary: "Fix a bug",
61 author: { name: "Alice", email: "a@x" },
62 };
63
64 it("prefixes with the XML declaration + feed root", () => {
65 const xml = renderAtomFeed({
66 id: "tag:feed",
67 title: "Test",
68 selfHref: "https://gluecron.com/feed",
69 entries: [],
70 });
71 expect(xml.startsWith('<?xml version="1.0" encoding="utf-8"?>\n')).toBe(
72 true
73 );
74 expect(xml).toContain('<feed xmlns="http://www.w3.org/2005/Atom">');
75 expect(xml.trim().endsWith("</feed>")).toBe(true);
76 });
77
78 it("emits required feed-level elements", () => {
79 const xml = renderAtomFeed({
80 id: "tag:feed",
81 title: "Test Feed",
82 subtitle: "Sub",
83 selfHref: "https://g.c/feed",
84 alternateHref: "https://g.c/",
85 entries: [],
86 });
87 expect(xml).toContain("<id>tag:feed</id>");
88 expect(xml).toContain("<title>Test Feed</title>");
89 expect(xml).toContain("<subtitle>Sub</subtitle>");
90 expect(xml).toContain('<link rel="self" href="https://g.c/feed"/>');
91 expect(xml).toContain('<link rel="alternate" href="https://g.c/"/>');
92 expect(xml).toMatch(/<updated>.+<\/updated>/);
93 });
94
95 it("renders entry title, id, link, updated, published, author, summary", () => {
96 const xml = renderAtomFeed({
97 id: "tag:feed",
98 title: "Test",
99 selfHref: "https://g.c/feed",
100 entries: [entry],
101 });
102 expect(xml).toContain("<entry>");
103 expect(xml).toContain("</entry>");
104 expect(xml).toContain(`<id>${entry.id}</id>`);
105 expect(xml).toContain(`<title>${entry.title}</title>`);
106 expect(xml).toContain(`<link rel="alternate" href="${entry.href}"/>`);
107 expect(xml).toContain("<updated>2026-04-15T12:00:00.000Z</updated>");
108 expect(xml).toContain("<published>2026-04-15T12:00:00.000Z</published>");
109 expect(xml).toContain("<name>Alice</name>");
110 expect(xml).toContain("<email>a@x</email>");
111 expect(xml).toContain('<summary type="text">Fix a bug</summary>');
112 });
113
114 it("escapes entry text fields", () => {
115 const xml = renderAtomFeed({
116 id: "tag:feed",
117 title: "Test & Ampersand",
118 selfHref: "https://g.c/feed?x=1&y=2",
119 entries: [
120 {
121 id: "tag:e",
122 title: "<script>bad</script>",
123 href: "https://g.c/x?a=1&b=2",
124 updatedAt: "2026-04-15T12:00:00Z",
125 summary: 'She said "hi"',
126 },
127 ],
128 });
129 expect(xml).toContain("Test & Ampersand");
130 expect(xml).toContain("x=1&y=2");
131 expect(xml).toContain("<script>bad</script>");
132 expect(xml).toContain(""hi"");
133 // Must not contain the raw unescaped forms.
134 expect(xml).not.toContain("<script>bad</script>");
135 });
136
137 it("picks feed updated from the newest entry when not set explicitly", () => {
138 const xml = renderAtomFeed({
139 id: "tag:feed",
140 title: "Test",
141 selfHref: "https://g.c/feed",
142 entries: [
143 { id: "a", title: "A", href: "h", updatedAt: "2026-04-10T00:00:00Z" },
144 { id: "b", title: "B", href: "h", updatedAt: "2026-04-14T00:00:00Z" },
145 { id: "c", title: "C", href: "h", updatedAt: "2026-04-12T00:00:00Z" },
146 ],
147 });
148 // The feed `<updated>` should pick the newest entry (Apr 14)
149 expect(xml).toContain("<updated>2026-04-14T00:00:00.000Z</updated>");
150 });
151
152 it("respects explicit feed updatedAt", () => {
153 const xml = renderAtomFeed({
154 id: "tag:feed",
155 title: "Test",
156 selfHref: "https://g.c/feed",
157 updatedAt: "2026-01-01T00:00:00Z",
158 entries: [
159 { id: "a", title: "A", href: "h", updatedAt: "2026-04-14T00:00:00Z" },
160 ],
161 });
162 expect(xml).toContain("<updated>2026-01-01T00:00:00.000Z</updated>");
163 });
164
165 it("produces a well-formed document even with zero entries", () => {
166 const xml = renderAtomFeed({
167 id: "tag:empty",
168 title: "Empty",
169 selfHref: "https://g.c/empty.atom",
170 entries: [],
171 });
172 expect(xml).toContain("<id>tag:empty</id>");
173 expect(xml).not.toContain("<entry>");
174 });
175
176 it("falls back to (untitled) when a title is empty", () => {
177 const xml = renderAtomFeed({
178 id: "tag:feed",
179 title: "Test",
180 selfHref: "https://g.c/feed",
181 entries: [
182 { id: "a", title: "", href: "h", updatedAt: "2026-04-14T00:00:00Z" },
183 ],
184 });
185 expect(xml).toContain("<title>(untitled)</title>");
186 });
187});
188
189describe("atom-feed — ATOM_CONTENT_TYPE", () => {
190 it("is the canonical Atom mime with charset", () => {
191 expect(ATOM_CONTENT_TYPE).toBe("application/atom+xml; charset=utf-8");
192 });
193});
194
195describe("atom-feed — __internal", () => {
196 it("re-exports the helpers for parity", () => {
197 expect(__internal.escapeXml).toBe(escapeXml);
198 expect(__internal.toIsoUtc).toBe(toIsoUtc);
199 expect(__internal.renderAtomFeed).toBe(renderAtomFeed);
200 expect(__internal.ATOM_CONTENT_TYPE).toBe(ATOM_CONTENT_TYPE);
201 });
202});
203
204describe("atom-feed — routes", () => {
205 it("GET /:o/:r/commits.atom returns 200 with Atom content-type", async () => {
206 const res = await app.request("/alice/nope/commits.atom");
207 expect(res.status).toBe(200);
208 expect(res.headers.get("content-type")).toBe(ATOM_CONTENT_TYPE);
209 const body = await res.text();
210 expect(body).toContain('<feed xmlns="http://www.w3.org/2005/Atom">');
211 });
212
213 it("GET /:o/:r/releases.atom returns 200 Atom", async () => {
214 const res = await app.request("/alice/nope/releases.atom");
215 expect(res.status).toBe(200);
216 expect(res.headers.get("content-type")).toBe(ATOM_CONTENT_TYPE);
217 });
218
219 it("GET /:o/:r/issues.atom returns 200 Atom", async () => {
220 const res = await app.request("/alice/nope/issues.atom");
221 expect(res.status).toBe(200);
222 expect(res.headers.get("content-type")).toBe(ATOM_CONTENT_TYPE);
223 });
224
225 it("cache headers set for feed reader friendliness", async () => {
226 const res = await app.request("/alice/nope/commits.atom");
227 const cc = res.headers.get("cache-control") || "";
228 expect(cc).toContain("max-age");
229 expect(cc).toContain("stale-while-revalidate");
230 });
231});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -79,6 +79,7 @@ import badgesRoutes from "./routes/badges";
7979import communityRoutes from "./routes/community";
8080import pinnedReposRoutes from "./routes/pinned-repos";
8181import pulseRoutes from "./routes/pulse";
82import feedRoutes from "./routes/feeds";
8283import webRoutes from "./routes/web";
8384
8485const app = new Hono();
@@ -278,6 +279,9 @@ app.route("/", pinnedReposRoutes);
278279// Repository pulse — /:owner/:repo/pulse (Block J18)
279280app.route("/", pulseRoutes);
280281
282// Atom feeds — /:owner/:repo/{commits,releases,issues}.atom (Block J19)
283app.route("/", feedRoutes);
284
281285// Insights + milestones
282286app.route("/", insightsRoutes);
283287
Addedsrc/lib/atom-feed.ts+173−0View fileUnifiedSplit
@@ -0,0 +1,173 @@
1/**
2 * Block J19 — Atom feed renderer.
3 *
4 * Produces a valid Atom 1.0 XML document from a typed description of a
5 * feed. Zero-IO, zero dependencies — routes fetch commits/releases/issues
6 * from Drizzle and git, shape them into `AtomEntry` rows, then call
7 * `renderAtomFeed` to produce the response body.
8 *
9 * We deliberately avoid pulling in a full XML library: Atom is a small
10 * enough format that a careful `escapeXml` + string template is sufficient
11 * and leaves no surface for supply-chain surprises.
12 */
13
14export interface AtomAuthor {
15 name: string;
16 email?: string;
17}
18
19export interface AtomEntry {
20 /** Stable globally-unique ID (e.g. `tag:host,2026:repo/owner/name/commit/<sha>`). */
21 id: string;
22 title: string;
23 /** Canonical permalink for the entry (becomes `<link rel="alternate">`). */
24 href: string;
25 /** ISO-8601 UTC timestamp. Used for both `<updated>` and `<published>`. */
26 updatedAt: string;
27 /** Short plaintext summary. Rendered inside `<summary type="text">`. */
28 summary?: string;
29 /** Optional long-form content. Rendered inside `<content type="html">`. */
30 contentHtml?: string;
31 author?: AtomAuthor;
32}
33
34export interface AtomFeedInput {
35 /** Feed-wide unique ID. */
36 id: string;
37 title: string;
38 subtitle?: string;
39 /** Absolute URL of the feed itself (becomes `<link rel="self">`). */
40 selfHref: string;
41 /** Absolute URL of the HTML page the feed represents. */
42 alternateHref?: string;
43 /** ISO-8601 UTC. Defaults to the newest entry's `updatedAt`, or "now". */
44 updatedAt?: string;
45 entries: AtomEntry[];
46}
47
48/**
49 * Escape the five XML special characters so they render safely inside
50 * element text + attribute values. Accepts arbitrary input.
51 */
52export function escapeXml(input: string): string {
53 if (!input) return "";
54 return input
55 .replace(/&/g, "&")
56 .replace(/</g, "<")
57 .replace(/>/g, ">")
58 .replace(/"/g, """)
59 .replace(/'/g, "'");
60}
61
62/**
63 * Coerce an input to a valid ISO-8601 UTC date string. Falls back to the
64 * unix epoch if the input can't be parsed — feeds stay valid even with
65 * junk inputs.
66 */
67export function toIsoUtc(input: string | Date | null | undefined): string {
68 if (input instanceof Date) {
69 const t = input.getTime();
70 return Number.isFinite(t) ? new Date(t).toISOString() : "1970-01-01T00:00:00.000Z";
71 }
72 if (typeof input === "string" && input) {
73 const t = Date.parse(input);
74 if (Number.isFinite(t)) return new Date(t).toISOString();
75 }
76 return "1970-01-01T00:00:00.000Z";
77}
78
79function pickFeedUpdated(input: AtomFeedInput): string {
80 if (input.updatedAt) return toIsoUtc(input.updatedAt);
81 if (input.entries.length > 0) {
82 // Pick the newest entry updatedAt.
83 let newest = -Infinity;
84 for (const e of input.entries) {
85 const t = Date.parse(e.updatedAt);
86 if (Number.isFinite(t) && t > newest) newest = t;
87 }
88 if (newest > -Infinity) return new Date(newest).toISOString();
89 }
90 return new Date().toISOString();
91}
92
93function renderAuthor(a: AtomAuthor): string {
94 const lines = [` <name>${escapeXml(a.name || "unknown")}</name>`];
95 if (a.email) lines.push(` <email>${escapeXml(a.email)}</email>`);
96 return ` <author>\n${lines.join("\n")}\n </author>`;
97}
98
99function renderEntry(e: AtomEntry): string {
100 const parts = [
101 " <entry>",
102 ` <id>${escapeXml(e.id)}</id>`,
103 ` <title>${escapeXml(e.title || "(untitled)")}</title>`,
104 ` <link rel="alternate" href="${escapeXml(e.href)}"/>`,
105 ` <updated>${toIsoUtc(e.updatedAt)}</updated>`,
106 ` <published>${toIsoUtc(e.updatedAt)}</published>`,
107 ];
108 if (e.author) {
109 parts.push(
110 ` <author>`,
111 ` <name>${escapeXml(e.author.name || "unknown")}</name>`,
112 ...(e.author.email
113 ? [` <email>${escapeXml(e.author.email)}</email>`]
114 : []),
115 ` </author>`
116 );
117 }
118 if (e.summary) {
119 parts.push(
120 ` <summary type="text">${escapeXml(e.summary)}</summary>`
121 );
122 }
123 if (e.contentHtml) {
124 parts.push(
125 ` <content type="html">${escapeXml(e.contentHtml)}</content>`
126 );
127 }
128 parts.push(" </entry>");
129 return parts.join("\n");
130}
131
132/**
133 * Render a full Atom 1.0 document. The output is UTF-8, XML-declaration-
134 * prefixed, and safe to return directly with
135 * `Content-Type: application/atom+xml; charset=utf-8`.
136 */
137export function renderAtomFeed(input: AtomFeedInput): string {
138 const updated = pickFeedUpdated(input);
139 const lines: string[] = [
140 '<?xml version="1.0" encoding="utf-8"?>',
141 '<feed xmlns="http://www.w3.org/2005/Atom">',
142 ` <id>${escapeXml(input.id)}</id>`,
143 ` <title>${escapeXml(input.title)}</title>`,
144 ];
145 if (input.subtitle) {
146 lines.push(` <subtitle>${escapeXml(input.subtitle)}</subtitle>`);
147 }
148 lines.push(` <updated>${updated}</updated>`);
149 lines.push(` <link rel="self" href="${escapeXml(input.selfHref)}"/>`);
150 if (input.alternateHref) {
151 lines.push(
152 ` <link rel="alternate" href="${escapeXml(input.alternateHref)}"/>`
153 );
154 }
155 for (const entry of input.entries) {
156 lines.push(renderEntry(entry));
157 }
158 lines.push("</feed>");
159 return lines.join("\n") + "\n";
160}
161
162/** Mime-type header for Atom responses. */
163export const ATOM_CONTENT_TYPE = "application/atom+xml; charset=utf-8";
164
165export const __internal = {
166 escapeXml,
167 toIsoUtc,
168 pickFeedUpdated,
169 renderAuthor,
170 renderEntry,
171 renderAtomFeed,
172 ATOM_CONTENT_TYPE,
173};
Addedsrc/routes/feeds.ts+257−0View fileUnifiedSplit
@@ -0,0 +1,257 @@
1/**
2 * Block J19 — Atom feeds for commits, releases, and issues.
3 *
4 * GET /:owner/:repo/commits.atom — newest-first 50 commits on default branch
5 * GET /:owner/:repo/releases.atom — published releases
6 * GET /:owner/:repo/issues.atom — newest 50 issues (open + closed)
7 *
8 * softAuth; private repos 404 for non-owner viewers. Rendering is done
9 * via the pure `renderAtomFeed` builder in `src/lib/atom-feed.ts`.
10 */
11
12import { Hono } from "hono";
13import { and, desc, eq } from "drizzle-orm";
14import { db } from "../db";
15import {
16 issues,
17 releases,
18 repositories,
19 users,
20} from "../db/schema";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { getDefaultBranch, listCommits } from "../git/repository";
24import {
25 ATOM_CONTENT_TYPE,
26 renderAtomFeed,
27 type AtomEntry,
28} from "../lib/atom-feed";
29import { config } from "../lib/config";
30
31const feeds = new Hono<AuthEnv>();
32
33const MAX_ENTRIES = 50;
34
35async function resolveRepo(ownerName: string, repoName: string) {
36 try {
37 const [owner] = await db
38 .select()
39 .from(users)
40 .where(eq(users.username, ownerName))
41 .limit(1);
42 if (!owner) return null;
43 const [repo] = await db
44 .select()
45 .from(repositories)
46 .where(
47 and(
48 eq(repositories.ownerId, owner.id),
49 eq(repositories.name, repoName)
50 )
51 )
52 .limit(1);
53 if (!repo) return null;
54 return { owner, repo };
55 } catch {
56 return null;
57 }
58}
59
60function baseUrl(): string {
61 try {
62 return (config.appBaseUrl || "").replace(/\/$/, "") || "";
63 } catch {
64 return "";
65 }
66}
67
68function respond(xml: string) {
69 return new Response(xml, {
70 status: 200,
71 headers: {
72 "Content-Type": ATOM_CONTENT_TYPE,
73 "Cache-Control": "public, max-age=60, stale-while-revalidate=300",
74 },
75 });
76}
77
78function notFoundFeed(selfHref: string) {
79 // Still return a valid Atom doc so feed readers don't choke; they just
80 // see zero entries.
81 return respond(
82 renderAtomFeed({
83 id: selfHref,
84 title: "Unknown repository",
85 selfHref,
86 entries: [],
87 })
88 );
89}
90
91// ---------------------------------------------------------------------------
92// Commits
93// ---------------------------------------------------------------------------
94feeds.get("/:owner/:repo/commits.atom", softAuth, async (c) => {
95 const { owner: ownerName, repo: repoName } = c.req.param();
96 const user = c.get("user");
97 const base = baseUrl();
98 const selfHref = `${base}/${ownerName}/${repoName}/commits.atom`;
99
100 const resolved = await resolveRepo(ownerName, repoName);
101 if (!resolved) return notFoundFeed(selfHref);
102 if (
103 resolved.repo.isPrivate &&
104 (!user || user.id !== resolved.owner.id)
105 ) {
106 return notFoundFeed(selfHref);
107 }
108
109 let entries: AtomEntry[] = [];
110 try {
111 const ref = (await getDefaultBranch(ownerName, repoName)) || "HEAD";
112 const commits = await listCommits(
113 ownerName,
114 repoName,
115 ref,
116 MAX_ENTRIES,
117 0
118 );
119 entries = commits.map((cmt) => ({
120 id: `tag:gluecron,2026:${ownerName}/${repoName}/commit/${cmt.sha}`,
121 title: cmt.message.split("\n")[0] || "(no commit message)",
122 href: `${base}/${ownerName}/${repoName}/commit/${cmt.sha}`,
123 updatedAt: cmt.date,
124 summary: cmt.message,
125 author: { name: cmt.author, email: cmt.authorEmail },
126 }));
127 } catch {
128 entries = [];
129 }
130
131 return respond(
132 renderAtomFeed({
133 id: `tag:gluecron,2026:${ownerName}/${repoName}/commits`,
134 title: `${ownerName}/${repoName} — Recent commits`,
135 subtitle: `Commits on the default branch of ${ownerName}/${repoName}`,
136 selfHref,
137 alternateHref: `${base}/${ownerName}/${repoName}/commits`,
138 entries,
139 })
140 );
141});
142
143// ---------------------------------------------------------------------------
144// Releases
145// ---------------------------------------------------------------------------
146feeds.get("/:owner/:repo/releases.atom", softAuth, async (c) => {
147 const { owner: ownerName, repo: repoName } = c.req.param();
148 const user = c.get("user");
149 const base = baseUrl();
150 const selfHref = `${base}/${ownerName}/${repoName}/releases.atom`;
151
152 const resolved = await resolveRepo(ownerName, repoName);
153 if (!resolved) return notFoundFeed(selfHref);
154 if (
155 resolved.repo.isPrivate &&
156 (!user || user.id !== resolved.owner.id)
157 ) {
158 return notFoundFeed(selfHref);
159 }
160
161 let entries: AtomEntry[] = [];
162 try {
163 const rows = await db
164 .select({
165 release: releases,
166 authorName: users.username,
167 })
168 .from(releases)
169 .innerJoin(users, eq(releases.authorId, users.id))
170 .where(eq(releases.repositoryId, resolved.repo.id))
171 .orderBy(desc(releases.createdAt))
172 .limit(MAX_ENTRIES);
173 entries = rows
174 .filter((r) => !r.release.isDraft)
175 .map((r) => ({
176 id: `tag:gluecron,2026:${ownerName}/${repoName}/release/${r.release.tag}`,
177 title: r.release.name || r.release.tag,
178 href: `${base}/${ownerName}/${repoName}/releases/tag/${encodeURIComponent(
179 r.release.tag
180 )}`,
181 updatedAt: (r.release.publishedAt || r.release.createdAt).toISOString(),
182 summary: r.release.body || `${r.release.tag} released`,
183 author: { name: r.authorName },
184 }));
185 } catch {
186 entries = [];
187 }
188
189 return respond(
190 renderAtomFeed({
191 id: `tag:gluecron,2026:${ownerName}/${repoName}/releases`,
192 title: `${ownerName}/${repoName} — Releases`,
193 subtitle: `Releases published by ${ownerName}/${repoName}`,
194 selfHref,
195 alternateHref: `${base}/${ownerName}/${repoName}/releases`,
196 entries,
197 })
198 );
199});
200
201// ---------------------------------------------------------------------------
202// Issues
203// ---------------------------------------------------------------------------
204feeds.get("/:owner/:repo/issues.atom", softAuth, async (c) => {
205 const { owner: ownerName, repo: repoName } = c.req.param();
206 const user = c.get("user");
207 const base = baseUrl();
208 const selfHref = `${base}/${ownerName}/${repoName}/issues.atom`;
209
210 const resolved = await resolveRepo(ownerName, repoName);
211 if (!resolved) return notFoundFeed(selfHref);
212 if (
213 resolved.repo.isPrivate &&
214 (!user || user.id !== resolved.owner.id)
215 ) {
216 return notFoundFeed(selfHref);
217 }
218
219 let entries: AtomEntry[] = [];
220 try {
221 const rows = await db
222 .select({
223 issue: issues,
224 authorName: users.username,
225 })
226 .from(issues)
227 .innerJoin(users, eq(issues.authorId, users.id))
228 .where(eq(issues.repositoryId, resolved.repo.id))
229 .orderBy(desc(issues.createdAt))
230 .limit(MAX_ENTRIES);
231 entries = rows.map((r) => ({
232 id: `tag:gluecron,2026:${ownerName}/${repoName}/issues/${r.issue.number}`,
233 title: `#${r.issue.number} ${r.issue.title}`,
234 href: `${base}/${ownerName}/${repoName}/issues/${r.issue.number}`,
235 updatedAt: (r.issue.updatedAt || r.issue.createdAt).toISOString(),
236 summary: r.issue.body
237 ? r.issue.body.slice(0, 500)
238 : `Issue #${r.issue.number}`,
239 author: { name: r.authorName },
240 }));
241 } catch {
242 entries = [];
243 }
244
245 return respond(
246 renderAtomFeed({
247 id: `tag:gluecron,2026:${ownerName}/${repoName}/issues`,
248 title: `${ownerName}/${repoName} — Issues`,
249 subtitle: `Issues tracked in ${ownerName}/${repoName}`,
250 selfHref,
251 alternateHref: `${base}/${ownerName}/${repoName}/issues`,
252 entries,
253 })
254 );
255});
256
257export default feeds;
0258