From 200defaa5c769b4603f9256e943821344cadbe82 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 27 Jan 2026 15:05:36 -0500 Subject: List issues --- src/index.ts | 13 +++++++- src/scraper.test.ts | 21 ++++++++++++- src/scraper.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 114 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/index.ts b/src/index.ts index 51e8cf2..d4229fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,13 @@ import { type Context, Hono } from "hono"; import type { StatusCode } from "hono/utils/http-status"; import path from "node:path"; import url from "node:url"; -import { getGitHubBlob, getGitHubRepo, getGitHubTree, GitHubHTTPError } from "./scraper.ts"; +import { + getGitHubBlob, + getGitHubIssues, + getGitHubRepo, + getGitHubTree, + GitHubHTTPError, +} from "./scraper.ts"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); @@ -56,6 +62,11 @@ app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => { return c.redirect(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`); }); +app.get("/:owner/:repo/issues", async (c) => { + const { owner, repo } = c.req.param(); + return tryRender(c, "issues.eta", getGitHubIssues(owner, repo)); +}); + app.all("*", async (c) => { const path = c.req.path; c.header("Referrer-Policy", "no-referrer"); diff --git a/src/scraper.test.ts b/src/scraper.test.ts index adf70ae..e7e65ba 100644 --- a/src/scraper.test.ts +++ b/src/scraper.test.ts @@ -1,6 +1,12 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { getGitHubBlob, getGitHubRepo, getGitHubTree, GitHubHTTPError } from "./scraper.ts"; +import { + getGitHubBlob, + getGitHubIssues, + getGitHubRepo, + getGitHubTree, + GitHubHTTPError, +} from "./scraper.ts"; describe("GitHub scraper", () => { describe("actions/deploy-pages repository", () => { @@ -66,6 +72,19 @@ describe("GitHub scraper", () => { assert.match(data.rawContent, /^MIT License\n\n/); assert.match(data.content, /^MIT License\n\n/); }); + + it("should fetch issues", async () => { + const data = await getGitHubIssues("actions", "deploy-pages"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + + const issue402 = data.issues.find((issue) => issue.number === 402); + assert.ok(issue402); + assert.strictEqual(issue402.title, "Dry Run"); + assert.strictEqual(issue402.state, "OPEN"); + assert.strictEqual(issue402.createdAt, "2025-07-03T19:07:08Z"); + }); }); describe("error handling", () => { diff --git a/src/scraper.ts b/src/scraper.ts index b76f8c8..bbf7511 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -21,6 +21,38 @@ interface OverviewFile { loaded: boolean; } +interface IssueNode { + number: number; + title: string; + author?: { + login: string; + }; + createdAt: string; + state: string; + labels?: { + edges: { + node: { + name: string; + }; + }[]; + }; +} + +interface IssueIndexPageQuery { + queryName: "IssueIndexPageQuery"; + result: { + data: { + repository: { + search: { + edges: { + node: IssueNode; + }[]; + }; + }; + }; + }; +} + interface RawPayload { repo: { ownerLogin: string; @@ -42,6 +74,8 @@ interface RawPayload { overview?: { overviewFiles?: OverviewFile[]; }; + // Actually there may be other query types. + preloadedQueries?: IssueIndexPageQuery[]; } interface GitHubCommon { @@ -49,11 +83,14 @@ interface GitHubCommon { owner: string; name: string; }; +} + +interface GitHubNav extends GitHubCommon { branch: string; path: string; } -export interface GitHubTree extends GitHubCommon { +export interface GitHubTree extends GitHubNav { items: TreeItem[]; overviewHtml?: Record; } @@ -71,13 +108,26 @@ export interface GitHubRepo extends GitHubTree { info: RepoInfo; } -export interface GitHubBlob extends GitHubCommon { +export interface GitHubBlob extends GitHubNav { language: string | null; size: number | null; content: string; rawContent: string; } +interface Issue { + number: number; + title: string; + state: string; + createdAt: string; + numReplies?: number; + numPRs?: number; +} + +export interface GitHubIssues extends GitHubCommon { + issues: Issue[]; +} + 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. @@ -150,7 +200,7 @@ function extractOverviewHtml(payload: RawPayload): Record | unde return Object.keys(result).length > 0 ? result : undefined; } -function extractGitHub(payload: RawPayload, extra: T): GitHubCommon & T { +function extractGitHub(payload: RawPayload, extra: T): GitHubNav & T { return { repo: { owner: payload.repo.ownerLogin, @@ -231,3 +281,32 @@ export async function getGitHubBlob( size, }); } + +export async function getGitHubIssues(owner: string, repo: string): Promise { + const html = await fetchGitHubPage(`${owner}/${repo}/issues`); + + const payload = parsePayload(html, "react-app.embeddedData", ["payload"]); + + let issuesQuery: IssueIndexPageQuery | undefined; + for (const query of payload?.preloadedQueries ?? []) { + if (query.queryName === "IssueIndexPageQuery") { + issuesQuery = query; + } + } + if (issuesQuery === undefined) { + throw new Error("Could not find IssueIndexPageQuery in embedded JSON"); + } + + const issues: Issue[] = issuesQuery.result.data.repository.search.edges.map((edge) => { + const { number, title, state, createdAt } = edge.node; + return { number, title, state, createdAt }; + }); + + return { + repo: { + owner, + name: repo, + }, + issues, + }; +} -- cgit v1.3.1