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:37:23 -0400
commit96396c4ed853652ea311d006b4ebe6af15b40684 (patch)
tree84ed30e17bc2232ac755f9d488a30d8aec233577
parent12a072ded4d8528296bcf9fde6ae8324588da97e (diff)
Add repo-scoped search support for issues and PRs
We do not support hybrid issue/PR search. Any searches on the issues endpoint will redirect to an explicit issue-only search.
-rw-r--r--src/index.ts26
-rw-r--r--src/scraper.test.ts15
-rw-r--r--src/scraper.ts20
-rw-r--r--static/style.css13
-rw-r--r--views/blob.eta2
-rw-r--r--views/issues.eta2
-rw-r--r--views/layout.eta8
-rw-r--r--views/tree.eta2
8 files changed, 71 insertions, 17 deletions
diff --git a/src/index.ts b/src/index.ts
index eb3a0ee..db9915f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -70,7 +70,31 @@ app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => {
app.get("/:owner/:repo/issues", async (c) => {
const { owner, repo } = c.req.param();
- return tryRender(c, "issues.eta", getGitHubIssues(owner, repo));
+ const q = c.req.query("q");
+
+ // GitHub supports combined issue/PR search, apparently for legacy. We just
+ // redirect to issue-only search.
+ if (q && !/\bis:issue\b/.test(q)) {
+ return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(`is:issue ${q}`)}`);
+ }
+
+ return tryRender(c, "issues.eta", getGitHubIssues(owner, repo, { q }));
+});
+
+app.get("/:owner/:repo/search", async (c) => {
+ const { owner, repo } = c.req.param();
+ const q = c.req.query("q") ?? "";
+ const type = c.req.query("type") ?? "issues";
+
+ if (type === "issues") {
+ return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`);
+ }
+
+ // Redirect unhandled search types (like code, which requires sign in anyway).
+ c.header("Referrer-Policy", "no-referrer");
+ return c.redirect(
+ `https://github.com/${owner}/${repo}/search?${new URLSearchParams(c.req.query()).toString()}`,
+ );
});
app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => {
diff --git a/src/scraper.test.ts b/src/scraper.test.ts
index 42ddd6e..236b0f5 100644
--- a/src/scraper.test.ts
+++ b/src/scraper.test.ts
@@ -127,6 +127,7 @@ describe("GitHub scraper", () => {
assert.strictEqual(data.repo.owner, "actions");
assert.strictEqual(data.repo.name, "deploy-pages");
+ assert.strictEqual(data.q, undefined);
const issue402 = data.issues.find((issue) => issue.number === 402);
assert.ok(issue402);
@@ -135,6 +136,20 @@ describe("GitHub scraper", () => {
assert.strictEqual(issue402.createdAt, "2025-07-03T19:07:08Z");
});
+ it("should fetch filtered issues with q", async () => {
+ const data = await getGitHubIssues("actions", "deploy-pages", {
+ q: "is:issue state:closed",
+ });
+
+ assert.strictEqual(data.repo.owner, "actions");
+ assert.strictEqual(data.repo.name, "deploy-pages");
+ assert.strictEqual(data.q, "is:issue state:closed");
+ const issue417 = data.issues.find((issue) => issue.number === 417);
+ assert.ok(issue417);
+ assert.strictEqual(issue417.title, "Chats");
+ assert.ok(data.issues.every((issue) => issue.title !== ""));
+ });
+
it("should fetch commits", async () => {
const data = await getGitHubCommits("actions", "deploy-pages", "main", "");
diff --git a/src/scraper.ts b/src/scraper.ts
index 04a685b..2412e49 100644
--- a/src/scraper.ts
+++ b/src/scraper.ts
@@ -258,6 +258,7 @@ interface Issue {
export interface GitHubIssues extends GitHubCommon {
issues: Issue[];
+ q?: string;
}
export interface GitHubCommits extends GitHubNav {
@@ -513,9 +514,14 @@ export async function getGitHubBlob(
});
}
-export async function getGitHubIssues(owner: string, repo: string): Promise<GitHubIssues> {
- const html = await fetchGitHubPage(`${owner}/${repo}/issues`);
-
+export async function getGitHubIssues(
+ owner: string,
+ repo: string,
+ // We only support is:issue searches (no PR results).
+ { q }: { q?: string } = {},
+): Promise<GitHubIssues> {
+ const suffix = q ? `?q=${encodeURIComponent(q)}` : "";
+ const html = await fetchGitHubPage(`${owner}/${repo}/issues${suffix}`);
const payload = parsePayload(html);
let issuesQuery: IssueIndexPageQuery | undefined;
@@ -534,13 +540,7 @@ export async function getGitHubIssues(owner: string, repo: string): Promise<GitH
return { number, title: finalTitle, state, createdAt };
});
- return {
- repo: {
- owner,
- name: repo,
- },
- issues,
- };
+ return { repo: { owner, name: repo }, issues, q };
}
export async function getGitHubCommits(
diff --git a/static/style.css b/static/style.css
index b3ebb46..86a67d8 100644
--- a/static/style.css
+++ b/static/style.css
@@ -43,14 +43,20 @@ body {
color: var(--text-color);
}
+a {
+ text-decoration: none;
+ color: var(--link-color);
+}
+
h1, h2, h3, h4, h5, h6,
p, pre, ul, ol {
margin: .5em 0;
}
-a {
- text-decoration: none;
- color: var(--link-color);
+input, select, button {
+ background-color: var(--body-background);
+ color: var(--text-color);
+ border: 1px solid var(--border-color);
}
table {
@@ -75,6 +81,7 @@ td {
nav {
display: flex;
+ margin: .5em 0;
> a {
display: block;
padding-right: .5rem;
diff --git a/views/blob.eta b/views/blob.eta
index ea07059..50f92a9 100644
--- a/views/blob.eta
+++ b/views/blob.eta
@@ -1,4 +1,4 @@
-<% layout("./layout.eta") %>
+<% layout("./layout.eta"); it.searchType = "code" %>
<%~ include("./_nav.eta", it) %>
diff --git a/views/issues.eta b/views/issues.eta
index 6e4c9ba..646da46 100644
--- a/views/issues.eta
+++ b/views/issues.eta
@@ -1,4 +1,4 @@
-<% layout("./layout") %>
+<% layout("./layout"); it.searchType = "issues" %>
<table>
<% for (const issue of it.issues) { %>
diff --git a/views/layout.eta b/views/layout.eta
index ec3c7b8..f31c6e0 100644
--- a/views/layout.eta
+++ b/views/layout.eta
@@ -19,6 +19,14 @@ git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git</code></pr
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/commits/<%= it.branch %><%= it.path && it.path !== '/' ? '/' + it.path : '' %>">Commits</a>
<% } %>
</nav>
+ <form action="/<%= it.repo.owner %>/<%= it.repo.name %>/search" method="get">
+ <input type="text" name="q" placeholder="Search…" value="<%= it.q ?? "" %>">
+ <select name="type">
+ <option value="issues" <%= it.searchType === "issues" ? "selected" : "" %>>Issues</option>
+ <option value="code" <%= it.searchType === "code" || it.searchType == null ? "selected" : "" %>>Code</option>
+ </select>
+ <button type="submit">Search</button>
+ </form>
</div>
<%~ it.body %>
diff --git a/views/tree.eta b/views/tree.eta
index 6e675e1..41ded06 100644
--- a/views/tree.eta
+++ b/views/tree.eta
@@ -1,3 +1,3 @@
-<% layout("./layout.eta") %>
+<% layout("./layout.eta"); it.searchType = "code" %>
<%~ include("./_tree.eta", it) %>