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.
| 26484eb | 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 | ||
| 12 | import { Hono } from "hono"; | |
| 13 | import { and, desc, eq } from "drizzle-orm"; | |
| 14 | import { db } from "../db"; | |
| 15 | import { | |
| 16 | issues, | |
| 17 | releases, | |
| 18 | repositories, | |
| 19 | users, | |
| 20 | } from "../db/schema"; | |
| 21 | import { softAuth } from "../middleware/auth"; | |
| 22 | import type { AuthEnv } from "../middleware/auth"; | |
| 23 | import { getDefaultBranch, listCommits } from "../git/repository"; | |
| 24 | import { | |
| 25 | ATOM_CONTENT_TYPE, | |
| 26 | renderAtomFeed, | |
| 27 | type AtomEntry, | |
| 28 | } from "../lib/atom-feed"; | |
| 29 | import { config } from "../lib/config"; | |
| 30 | ||
| 31 | const feeds = new Hono<AuthEnv>(); | |
| 32 | ||
| 33 | const MAX_ENTRIES = 50; | |
| 34 | ||
| 35 | async 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 | ||
| 60 | function baseUrl(): string { | |
| 61 | try { | |
| 62 | return (config.appBaseUrl || "").replace(/\/$/, "") || ""; | |
| 63 | } catch { | |
| 64 | return ""; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | function 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 | ||
| 78 | function 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 | // --------------------------------------------------------------------------- | |
| 94 | feeds.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 | // --------------------------------------------------------------------------- | |
| 146 | feeds.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 | // --------------------------------------------------------------------------- | |
| 204 | feeds.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 | ||
| 257 | export default feeds; |