Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

feeds.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

feeds.tsBlame257 lines · 1 contributor
26484ebClaude1/**
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;