summaryrefslogtreecommitdiff
path: root/cgithub/src
diff options
context:
space:
mode:
authorJoe Mou <dev@mou.fo>2026-01-27 15:05:36 -0500
committerJoe Mou <dev@mou.fo>2026-01-27 17:21:45 -0500
commitd29bdad2f5c8326d111901373f0a56b3a09a2cd8 (patch)
treea34128312316849e257bde7750c2e01180a77f3c /cgithub/src
parent64c397e256b01a2e542e84529e947e237c07edd6 (diff)
List issues
Diffstat (limited to 'cgithub/src')
-rw-r--r--cgithub/src/index.ts13
-rw-r--r--cgithub/src/scraper.test.ts21
-rw-r--r--cgithub/src/scraper.ts85
3 files changed, 114 insertions, 5 deletions
diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts
index 51e8cf2..d4229fd 100644
--- a/cgithub/src/index.ts
+++ b/cgithub/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/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts
index adf70ae..e7e65ba 100644
--- a/cgithub/src/scraper.test.ts
+++ b/cgithub/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/cgithub/src/scraper.ts b/cgithub/src/scraper.ts
index b76f8c8..bbf7511 100644
--- a/cgithub/src/scraper.ts
+++ b/cgithub/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<string, string>;
}
@@ -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<string> {
// 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<string, string> | unde
return Object.keys(result).length > 0 ? result : undefined;
}
-function extractGitHub<T>(payload: RawPayload, extra: T): GitHubCommon & T {
+function extractGitHub<T>(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<GitHubIssues> {
+ 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,
+ };
+}