From 226984ad63b063c362e18260a591a3f9a035a8d3 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Fri, 25 Sep 2026 01:48:21 -0400 Subject: Show each repo nav item only if the GitHub page includes it Co-Authored-By: Claude Opus 5.5 --- src/app.test.ts | 20 ++++++++++++++++++++ src/scraper.test.ts | 13 +++++++++++++ src/scraper.ts | 53 ++++++++++++++++++++++++++++++++++++----------------- 3 files changed, 69 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/app.test.ts b/src/app.test.ts index 59a2c61..e870666 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -92,6 +92,26 @@ describe("sidebar fragment", () => { }); }); +describe("repository navigation", () => { + const releases = { repo: { owner: "torvalds", name: "linux" }, releases: [] }; + + it("should only link to the tabs GitHub shows", () => { + const html = eta.render("releases.eta", { ...releases, tabs: ["code", "actions", "wiki"] }); + + assert.match(html, /href="\/torvalds\/linux\/wiki"/); + assert.doesNotMatch(html, /href="\/torvalds\/linux\/issues"/); + assert.doesNotMatch(html, /href="\/torvalds\/linux\/pulls"/); + assert.doesNotMatch(html, /href="\/torvalds\/linux\/discussions"/); + }); + + it("should link to every tab without GitHub's navigation to go by", () => { + const html = eta.render("releases.eta", { ...releases, tabs: null }); + + assert.match(html, /href="\/torvalds\/linux\/issues"/); + assert.match(html, /href="\/torvalds\/linux\/wiki"/); + }); +}); + describe("blob view", () => { const blob = { repo: { owner: "justrajdeep", name: "fonts" }, diff --git a/src/scraper.test.ts b/src/scraper.test.ts index a8debd7..83a82f0 100644 --- a/src/scraper.test.ts +++ b/src/scraper.test.ts @@ -45,6 +45,10 @@ describe("GitHub scraper", () => { assert.ok(data.items.length > 0); assert.ok(typeof data.overviewHtml?.["README.md"] === "string"); + + assert.ok(data.tabs?.includes("issues")); + assert.ok(data.tabs?.includes("pull-requests")); + assert.ok(!data.tabs?.includes("wiki")); }); it("should fetch tree", async () => { @@ -434,6 +438,7 @@ describe("GitHub scraper", () => { assert.strictEqual(data.repo.owner, "microsoft"); assert.strictEqual(data.repo.name, "vscode"); assert.strictEqual(data.title, "Home"); + assert.ok(data.tabs?.includes("wiki")); assert.strictEqual(data.updatedBy, "Greg Van Liew"); assert.strictEqual(data.updatedAt, "2019-11-22T03:08:32Z"); @@ -496,6 +501,14 @@ describe("GitHub scraper", () => { assert.strictEqual(home.updatedAt, "2018-02-08T18:57:56Z"); }); + it("should leave out the tabs of features a repository has disabled", async () => { + const data = await getGitHubReleases("torvalds", "linux"); + + assert.ok(data.tabs?.includes("code")); + assert.ok(!data.tabs?.includes("issues")); + assert.ok(!data.tabs?.includes("pull-requests")); + }); + it("should redirect to the repository when it has no wiki", async () => { await assert.rejects(getGitHubWiki("git", "git"), (err) => { assert(err instanceof InternalRedirectError, "error should be an InternalRedirectError"); diff --git a/src/scraper.ts b/src/scraper.ts index 19b8318..ac1820a 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -297,7 +297,12 @@ interface GitHubCommon { }; } -interface GitHubNav extends GitHubCommon { +interface GitHubRepoPage extends GitHubCommon { + // Enabled features, like issues or wiki. + tabs: string[] | null; +} + +interface GitHubNav extends GitHubRepoPage { branch: string; path: string; } @@ -347,12 +352,12 @@ interface Issue { numPRs?: number; } -export interface GitHubIssues extends GitHubCommon { +export interface GitHubIssues extends GitHubRepoPage { issues: Issue[]; q?: string; } -export interface GitHubPulls extends GitHubCommon { +export interface GitHubPulls extends GitHubRepoPage { pulls: Issue[]; q?: string; } @@ -384,7 +389,7 @@ export type IssueTimelineItem = | ({ kind: "comment" } & IssueComment) | ({ kind: "event" } & IssueEvent); -export interface GitHubIssue extends GitHubCommon { +export interface GitHubIssue extends GitHubRepoPage { number: number; titleHtml: string; state: string; @@ -437,7 +442,7 @@ export interface DiscussionComment { hiddenReplies: number; } -export interface GitHubDiscussions extends GitHubCommon { +export interface GitHubDiscussions extends GitHubRepoPage { discussions: DiscussionSummary[]; categories: DiscussionCategory[]; // The category the list is narrowed to, if any. @@ -445,7 +450,7 @@ export interface GitHubDiscussions extends GitHubCommon { q?: string; } -export interface GitHubDiscussion extends GitHubCommon { +export interface GitHubDiscussion extends GitHubRepoPage { number: number; titleHtml: string; author: string | null; @@ -520,11 +525,11 @@ export interface ReleaseAsset { publishedAt?: string; } -export interface GitHubReleases extends GitHubCommon { +export interface GitHubReleases extends GitHubRepoPage { releases: Release[]; } -export interface GitHubRelease extends GitHubCommon { +export interface GitHubRelease extends GitHubRepoPage { release: Release; } @@ -534,7 +539,7 @@ export interface WikiPage { updatedAt?: string; } -export interface GitHubWiki extends GitHubCommon { +export interface GitHubWiki extends GitHubRepoPage { title: string; updatedBy: string | null; updatedAt: string | null; @@ -544,7 +549,7 @@ export interface GitHubWiki extends GitHubCommon { pages: WikiPage[]; } -export interface GitHubWikiPages extends GitHubCommon { +export interface GitHubWikiPages extends GitHubRepoPage { pages: WikiPage[]; } @@ -657,7 +662,13 @@ function extractOverviewHtml( return Object.keys(result).length > 0 ? result : undefined; } -function extractGitHub(payload: AppPayload | null, extra: T): GitHubNav & T { +function parseRepoTabs(html: string): string[] | null { + const nav = /]*\baria-label="Repository"[^>]*>([\s\S]*?)<\/nav>/.exec(html); + if (!nav) return null; + return Array.from(nav[1].matchAll(/\bid="([a-z-]+)-tab"/g), (match) => match[1]); +} + +function extractGitHub(html: string, payload: AppPayload | null, extra: T): GitHubNav & T { const layoutRoute = payload?.codeViewLayoutRoute; if (layoutRoute === undefined) { throw new Error("Missing codeViewLayoutRoute"); @@ -668,6 +679,7 @@ function extractGitHub(payload: AppPayload | null, extra: T): GitHubNav & T { owner: layoutRoute.repo.ownerLogin, name: layoutRoute.repo.name, }, + tabs: parseRepoTabs(html), branch: layoutRoute.refInfo.name, path: layoutRoute.path, ...extra, @@ -695,7 +707,7 @@ export async function getGitHubRepo(owner: string, repo: string): Promise c.slug === category) ?? null, @@ -1220,6 +1234,7 @@ export async function getGitHubDiscussion( return { repo: { owner, name: repo }, + tabs: parseRepoTabs(html), number, titleHtml: title ? getInnerHTML(title).trim() : "", author: meta ? selectText("a[href]", meta) : null, @@ -1255,6 +1270,7 @@ export async function getGitHubCommits( owner, name: repo, }, + tabs: parseRepoTabs(html), branch, path, commitGroups: commitsRoute.commitGroups, @@ -1467,6 +1483,7 @@ export async function getGitHubReleases(owner: string, repo: string): Promise