diff options
| author | Joe Mou <dev@mou.fo> | 2026-05-27 14:02:36 -0400 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-05-27 23:38:34 -0400 |
| commit | 15206d169c89b8a337b3f929a23ff3f71cdd5a89 (patch) | |
| tree | a6abab640b3b5e7e18edf2e70714261f8cf68fe0 | |
| parent | 96396c4ed853652ea311d006b4ebe6af15b40684 (diff) | |
Pull request index
| -rw-r--r-- | src/index.ts | 9 | ||||
| -rw-r--r-- | src/scraper.test.ts | 11 | ||||
| -rw-r--r-- | src/scraper.ts | 40 | ||||
| -rw-r--r-- | views/layout.eta | 2 | ||||
| -rw-r--r-- | views/pulls.eta | 15 |
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> |
