From 34eea157db19c276fa4a5578707d0f8af2eeffd3 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 11 Feb 2026 01:56:37 -0500 Subject: Add GitHub releases support Scrapes releases page HTML to display version tags, publish dates, and changelog content. Co-Authored-By: Claude Sonnet 4.5 --- cgithub/src/index.ts | 6 +++++ cgithub/src/scraper.test.ts | 14 ++++++++++ cgithub/src/scraper.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++ cgithub/static/style.css | 42 ++++++++++++++++++++++++++++++ cgithub/views/releases.eta | 25 ++++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 cgithub/views/releases.eta (limited to 'cgithub') diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index 4486dff..2f8efef 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -9,6 +9,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubReleases, getGitHubRepo, getGitHubTree, GitHubHTTPError, @@ -73,6 +74,11 @@ app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => { return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); }); +app.get("/:owner/:repo/releases", async (c) => { + const { owner, repo } = c.req.param(); + return tryRender(c, "releases.eta", getGitHubReleases(owner, repo)); +}); + app.all("*", async (c) => { const path = c.req.path; c.header("Referrer-Policy", "no-referrer"); diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 7cafd9c..8c11317 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,6 +4,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubReleases, getGitHubRepo, getGitHubTree, GitHubHTTPError, @@ -159,6 +160,19 @@ describe("GitHub scraper", () => { assert.ok(data.commitGroups.length > 0); }); + + it("should fetch releases", async () => { + const data = await getGitHubReleases("actions", "deploy-pages"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + + assert.ok(data.releases.length > 0); + const firstRelease = data.releases[0]; + assert.ok(firstRelease.tagName.length > 0); + assert.ok(firstRelease.title.match(/^v\d/)); + assert.ok(firstRelease.publishedAt.length > 0); + }); }); describe("rich file types", () => { diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 71d0e3e..e52ddac 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -184,6 +184,17 @@ export interface GitHubCommits extends GitHubNav { commitGroups: CommitGroup[]; } +interface Release { + tagName: string; + title: string; + publishedAt: string; + bodyHtml: string; +} + +export interface GitHubReleases extends GitHubCommon { + releases: Release[]; +} + async function fetchGitHubPage(path: string): Promise { // GitHub throttles/blocks requests without realistic browser headers. // These headers make the request appear as a standard browser visit. @@ -403,3 +414,55 @@ export async function getGitHubCommits( commitGroups: payload.commitGroups, }; } + +export async function getGitHubReleases(owner: string, repo: string): Promise { + const html = await fetchGitHubPage(`${owner}/${repo}/releases`); + + // Releases page doesn't have embedded JSON, so we parse HTML directly + const releases: Release[] = []; + + // Find all h2 tags with version numbers + const h2Regex = /]*id="([^"]*)"[^>]*>(v[^<]*)<\/h2>/g; + + for (const h2Match of html.matchAll(h2Regex)) { + const id = h2Match[1]; + const title = h2Match[2].trim(); + + // Find the section containing this h2 + const sectionRegex = new RegExp( + `]*>\\s*]*id="${id}"[^>]*>[^<]*<\\/h2>([\\s\\S]*?)<\\/section>`, + "s", + ); + const sectionMatch = html.match(sectionRegex); + if (!sectionMatch) continue; + + const sectionHtml = sectionMatch[1]; + + // Extract tag name from link + const tagMatch = sectionHtml.match(/]*href="[^"]*\/tree\/([^"]+)"[^>]*>/); + const tagName = tagMatch?.[1] || title; + + // Extract published date + const dateMatch = sectionHtml.match(/]*datetime="([^"]*)"[^>]*>/); + const publishedAt = dateMatch?.[1] || ""; + + // Extract body HTML (markdown content) + const bodyMatch = sectionHtml.match(/]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>/); + const bodyHtml = bodyMatch?.[1]?.trim() || ""; + + releases.push({ + tagName, + title, + publishedAt, + bodyHtml, + }); + } + + return { + repo: { + owner, + name: repo, + }, + releases, + }; +} diff --git a/cgithub/static/style.css b/cgithub/static/style.css index 8709dc2..fdf3285 100644 --- a/cgithub/static/style.css +++ b/cgithub/static/style.css @@ -232,6 +232,48 @@ pre.box { font-size: 0.8rem; } +.release { + margin-bottom: 2rem; +} + +.release-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 1rem; + padding-bottom: .5rem; + border-bottom: 1px solid var(--border-color); + + h3 { + margin: 0; + } +} + +.release-body { + overflow-wrap: break-word; + + a { + text-decoration: underline; + } + + h1, h2 { + border-bottom: 1px solid var(--border-color); + } + + img { + max-width: 100%; + } + + code, pre { + background: var(--shade-background); + } + + pre { + overflow-x: auto; + scrollbar-color: var(--border-color) var(--shade-background); + } +} + /* TODO Strip permalinks? */ svg.octicon-link { display: none; diff --git a/cgithub/views/releases.eta b/cgithub/views/releases.eta new file mode 100644 index 0000000..39850d8 --- /dev/null +++ b/cgithub/views/releases.eta @@ -0,0 +1,25 @@ +<% layout("./layout.eta") %> + +

Releases

+ +<% for (const release of it.releases) { %> +
+
+

+ + <%= release.title %> + +

+ <% if (release.publishedAt) { %> + + <% } %> +
+ <% if (release.bodyHtml) { %> +
+ <%~ release.bodyHtml %> +
+ <% } %> +
+<% } %> -- cgit v1.3.1