aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoe Mou <dev@mou.fo>2026-05-27 14:02:36 -0400
committerJoe Mou <dev@mou.fo>2026-05-27 23:38:34 -0400
commit15206d169c89b8a337b3f929a23ff3f71cdd5a89 (patch)
treea6abab640b3b5e7e18edf2e70714261f8cf68fe0
parent96396c4ed853652ea311d006b4ebe6af15b40684 (diff)
Pull request index
-rw-r--r--src/index.ts9
-rw-r--r--src/scraper.test.ts11
-rw-r--r--src/scraper.ts40
-rw-r--r--views/layout.eta2
-rw-r--r--views/pulls.eta15
5 files changed, 77 insertions, 0 deletions
diff --git a/src/index.ts b/src/index.ts
index db9915f..514f2e2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,6 +9,7 @@ import {
getGitHubBlob,
getGitHubCommits,
getGitHubIssues,
+ getGitHubPulls,
getGitHubRelease,
getGitHubReleases,
getGitHubRepo,
@@ -81,6 +82,12 @@ app.get("/:owner/:repo/issues", async (c) => {
return tryRender(c, "issues.eta", getGitHubIssues(owner, repo, { q }));
});
+app.get("/:owner/:repo/pulls", async (c) => {
+ const { owner, repo } = c.req.param();
+ const q = c.req.query("q");
+ return tryRender(c, "pulls.eta", getGitHubPulls(owner, repo, { q }));
+});
+
app.get("/:owner/:repo/search", async (c) => {
const { owner, repo } = c.req.param();
const q = c.req.query("q") ?? "";
@@ -88,6 +95,8 @@ app.get("/:owner/:repo/search", async (c) => {
if (type === "issues") {
return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`);
+ } else if (type === "pullrequests") {
+ return c.redirect(`/${owner}/${repo}/pulls?q=${encodeURIComponent(q)}`);
}
// Redirect unhandled search types (like code, which requires sign in anyway).
diff --git a/src/scraper.test.ts b/src/scraper.test.ts
index 236b0f5..a4b834e 100644
--- a/src/scraper.test.ts
+++ b/src/scraper.test.ts
@@ -4,6 +4,7 @@ import {
getGitHubBlob,
getGitHubCommits,
getGitHubIssues,
+ getGitHubPulls,
getGitHubRelease,
getGitHubReleases,
getGitHubRepo,
@@ -150,6 +151,16 @@ describe("GitHub scraper", () => {
assert.ok(data.issues.every((issue) => issue.title !== ""));
});
+ it("should fetch pull requests", async () => {
+ const data = await getGitHubPulls("actions", "deploy-pages", { q: "state:closed" });
+
+ assert.strictEqual(data.repo.owner, "actions");
+ assert.strictEqual(data.repo.name, "deploy-pages");
+ const pr411 = data.pulls.find((pull) => pull.number === 411);
+ assert.ok(pr411);
+ assert.strictEqual(pr411.title, "update node version");
+ });
+
it("should fetch commits", async () => {
const data = await getGitHubCommits("actions", "deploy-pages", "main", "");
diff --git a/src/scraper.ts b/src/scraper.ts
index 2412e49..f32a8da 100644
--- a/src/scraper.ts
+++ b/src/scraper.ts
@@ -261,6 +261,11 @@ export interface GitHubIssues extends GitHubCommon {
q?: string;
}
+export interface GitHubPulls extends GitHubCommon {
+ pulls: Issue[];
+ q?: string;
+}
+
export interface GitHubCommits extends GitHubNav {
commitGroups: CommitGroup[];
}
@@ -543,6 +548,41 @@ export async function getGitHubIssues(
return { repo: { owner, name: repo }, issues, q };
}
+export async function getGitHubPulls(
+ owner: string,
+ repo: string,
+ { q }: { q?: string } = {},
+): Promise<GitHubPulls> {
+ const suffix = q ? `?q=${encodeURIComponent(q)}` : "";
+ const html = await fetchGitHubPage(`${owner}/${repo}/pulls${suffix}`);
+ const document = parseDocument(html);
+
+ const pulls: Issue[] = [];
+ for (const row of cssSelect.selectAll(".js-issue-row", document) as unknown as Element[]) {
+ const id = getAttributeValue(row, "id"); // "issue_NNN"
+ const number = id ? parseInt(id.replace("issue_", ""), 10) : 0;
+ if (!number) continue;
+
+ const titleLink = cssSelect.selectOne("a.markdown-title", row);
+ const title = titleLink ? textContent(titleLink).trim() : "";
+
+ const timeElem = cssSelect.selectOne("relative-time", row);
+ const createdAt = timeElem ? getAttributeValue(timeElem, "datetime") || "" : "";
+
+ const stateSpan = cssSelect.selectOne(".tooltipped[aria-label]", row) as Element | null;
+ const ariaLabel = stateSpan ? (getAttributeValue(stateSpan, "aria-label") ?? "") : "";
+ const state = ariaLabel.includes("Merged")
+ ? "MERGED"
+ : ariaLabel.includes("Closed")
+ ? "CLOSED"
+ : "OPEN";
+
+ pulls.push({ number, title, state, createdAt });
+ }
+
+ return { repo: { owner, name: repo }, pulls, q };
+}
+
export async function getGitHubCommits(
owner: string,
repo: string,
diff --git a/views/layout.eta b/views/layout.eta
index f31c6e0..85f431c 100644
--- a/views/layout.eta
+++ b/views/layout.eta
@@ -14,6 +14,7 @@ git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git</code></pr
<nav>
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>">Code</a>
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/issues">Issues</a>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/pulls">Pull requests</a>
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases">Releases</a>
<% if (it.branch) { %>
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/commits/<%= it.branch %><%= it.path && it.path !== '/' ? '/' + it.path : '' %>">Commits</a>
@@ -23,6 +24,7 @@ git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git</code></pr
<input type="text" name="q" placeholder="Search…" value="<%= it.q ?? "" %>">
<select name="type">
<option value="issues" <%= it.searchType === "issues" ? "selected" : "" %>>Issues</option>
+ <option value="pullrequests" <%= it.searchType === "pullrequests" ? "selected" : "" %>>Pull requests</option>
<option value="code" <%= it.searchType === "code" || it.searchType == null ? "selected" : "" %>>Code</option>
</select>
<button type="submit">Search</button>
diff --git a/views/pulls.eta b/views/pulls.eta
new file mode 100644
index 0000000..9ec5ca1
--- /dev/null
+++ b/views/pulls.eta
@@ -0,0 +1,15 @@
+<% layout("./layout"); it.searchType = "pullrequests" %>
+
+<table>
+ <% for (const pull of it.pulls) { %>
+ <tr>
+ <td>#<%= pull.number %></td>
+ <td>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/pull/<%= pull.number %>">
+ <%= pull.title %>
+ </a>
+ </td>
+ <td class="timestamp"><time title="<%= pull.createdAt %>"><%= pull.createdAt.split("T")[0] %></time></td>
+ </tr>
+ <% } %>
+</table>