summaryrefslogtreecommitdiff
path: root/cgithub/src
diff options
context:
space:
mode:
Diffstat (limited to 'cgithub/src')
-rw-r--r--cgithub/src/app.test.ts34
-rw-r--r--cgithub/src/app.ts6
-rw-r--r--cgithub/src/scraper.test.ts15
-rw-r--r--cgithub/src/scraper.ts126
4 files changed, 88 insertions, 93 deletions
diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts
index 6544f03..5474f8d 100644
--- a/cgithub/src/app.test.ts
+++ b/cgithub/src/app.test.ts
@@ -58,6 +58,40 @@ describe("branch picker", () => {
});
});
+describe("sidebar fragment", () => {
+ const repo = { owner: "actions", name: "deploy-pages" };
+ const languages = [{ name: "JavaScript", percentage: 100, color: "#f1e05a" }];
+ const latestRelease = { tag: "v5.0.0", publishedAt: "2026-03-25T16:59:14Z" };
+
+ // The release replaces the empty element it names, beside the release count.
+ it("should render the latest release and the languages", () => {
+ const html = eta.render("_sidebar.eta", { repo, languages, latestRelease });
+
+ assert.match(html, /<span data-target="latest-release">\(<a href/);
+ assert.match(html, /href="\/actions\/deploy-pages\/releases\/tag\/v5.0.0"/);
+ assert.match(html, /<time class="timestamp" title="2026-03-25T16:59:14Z">2026-03-25<\/time>/);
+ assert.match(html, /<div class="languages box">/);
+ });
+
+ it("should name a spot the repo page holds open", () => {
+ const html = eta.render("repo.eta", {
+ repo,
+ branch: "main",
+ path: "/",
+ items: [],
+ info: { description: null, website: null, stars: null, forks: null, numReleases: 38 },
+ });
+
+ assert.match(html, /<span id="latest-release"><\/span>/);
+ });
+
+ it("should render nothing when the repository has neither", () => {
+ const html = eta.render("_sidebar.eta", { repo, languages: [], latestRelease: null });
+
+ assert.strictEqual(html.trim(), "");
+ });
+});
+
describe("redirects to GitHub", () => {
// An HTTP redirect would be caught by the extension's declarativeNetRequest
// rules and bounced straight back here.
diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts
index 81e47fd..e27db59 100644
--- a/cgithub/src/app.ts
+++ b/cgithub/src/app.ts
@@ -6,7 +6,7 @@ import {
getGitHubBlob,
getGitHubCommits,
getGitHubIssues,
- getGitHubLanguages,
+ getGitHubSidebar,
getGitHubLatestCommit,
getGitHubOwner,
getGitHubPulls,
@@ -178,9 +178,9 @@ export function createApp(eta: Eta) {
);
});
- app.get("/api/:owner/:repo/languages", async (c) => {
+ app.get("/api/:owner/:repo/sidebar", async (c) => {
const { owner, repo } = c.req.param();
- return tryRenderFragment(c, "_languages.eta", getGitHubLanguages(owner, repo));
+ return tryRenderFragment(c, "_sidebar.eta", getGitHubSidebar(owner, repo));
});
app.get("/:owner/:repo/releases/tag/:tag", async (c) => {
diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts
index 5b5e78b..24fdfc0 100644
--- a/cgithub/src/scraper.test.ts
+++ b/cgithub/src/scraper.test.ts
@@ -4,7 +4,7 @@ import {
getGitHubBlob,
getGitHubCommits,
getGitHubIssues,
- getGitHubLanguages,
+ getGitHubSidebar,
getGitHubLatestCommit,
getGitHubPulls,
getGitHubRefs,
@@ -35,8 +35,6 @@ describe("GitHub scraper", () => {
assert.strictEqual(data.info.stars?.length, 3);
assert.strictEqual(data.info.forks?.length, 3);
assert.strictEqual(data.info.numReleases, 38);
- assert.strictEqual(data.info.latestReleaseTag, "v5.0.0");
- assert.ok(data.info.latestReleaseDate?.startsWith("2026-03-25"));
assert.ok(data.items.length > 0);
assert.ok(typeof data.overviewHtml?.["README.md"] === "string");
@@ -220,8 +218,8 @@ describe("GitHub scraper", () => {
assert.ok(data.commit.authors.length > 0);
});
- it("should fetch languages", async () => {
- const data = await getGitHubLanguages("actions", "deploy-pages");
+ it("should fetch the sidebar", async () => {
+ const data = await getGitHubSidebar("actions", "deploy-pages");
assert.strictEqual(data.repo.owner, "actions");
assert.strictEqual(data.repo.name, "deploy-pages");
@@ -229,10 +227,13 @@ describe("GitHub scraper", () => {
assert.deepStrictEqual(data.languages, [
{ name: "JavaScript", percentage: 100, color: "#f1e05a" },
]);
+
+ assert.strictEqual(data.latestRelease?.tag, "v5.0.0");
+ assert.ok(data.latestRelease?.publishedAt?.startsWith("2026-03-25"));
});
- it("should fetch languages for a multi-language repository", async () => {
- const data = await getGitHubLanguages("rust-lang", "rust");
+ it("should fetch the sidebar for a multi-language repository", async () => {
+ const data = await getGitHubSidebar("rust-lang", "rust");
const rust = data.languages.find((language) => language.name === "Rust");
assert.ok(rust);
diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts
index 223839f..4220dc4 100644
--- a/cgithub/src/scraper.ts
+++ b/cgithub/src/scraper.ts
@@ -143,7 +143,17 @@ interface CodeViewBlobRoute {
richText: string | null;
}
+interface SidebarAbout {
+ description?: string | null;
+ website?: string | null;
+ stargazerCount?: number;
+ forksCount?: number;
+ // A section is false when the repository has none of that thing.
+ sections?: { releases?: { releaseCount?: number } | false };
+}
+
interface AppPayload {
+ sidebarAbout?: SidebarAbout;
codeViewLayoutRoute?: CodeViewLayoutRoute;
codeViewRepoRoute?: CodeViewRepoRoute;
codeViewTreeRoute?: CodeViewTreeRoute;
@@ -230,8 +240,6 @@ export interface RepoInfo {
stars: string | null;
forks: string | null;
numReleases?: number;
- latestReleaseTag?: string;
- latestReleaseDate?: string;
}
export interface GitHubRepo extends GitHubTree {
@@ -292,8 +300,14 @@ export interface Language {
color: string | null;
}
-export interface GitHubLanguages extends GitHubCommon {
+export interface LatestRelease {
+ tag: string;
+ publishedAt: string | null;
+}
+
+export interface GitHubSidebar extends GitHubCommon {
languages: Language[];
+ latestRelease: LatestRelease | null;
}
export interface Ref {
@@ -425,81 +439,6 @@ function parsePayload(html: string): AppPayload | null {
return parseEmbeddedPayload<AppPayload>(html);
}
-function parseRepoInfo(html: string): RepoInfo {
- const document = parseDocument(html);
-
- const aboutSection = cssSelect.selectOne(".BorderGrid-cell h2:contains('About')", document);
- let description: string | null = null;
- let website: string | null = null;
-
- if (aboutSection) {
- const parent = aboutSection.parent;
- if (parent) {
- const descElem = cssSelect.selectOne("p.f4", parent);
- if (descElem) {
- description = textContent(descElem).trim();
- }
- const websiteElem = cssSelect.selectOne(
- "a[rel='noopener noreferrer nofollow']",
- parent,
- ) as Element | null;
- if (websiteElem) {
- website = getAttributeValue(websiteElem, "href") || null;
- }
- }
- }
-
- const starsElem = cssSelect.selectOne("#repo-stars-counter-star", document) as Element | null;
- const stars = starsElem
- ? getAttributeValue(starsElem, "title") || textContent(starsElem).trim()
- : null;
-
- const forksElem = cssSelect.selectOne("#repo-network-counter", document) as Element | null;
- const forks = forksElem
- ? getAttributeValue(forksElem, "title") || textContent(forksElem).trim()
- : null;
-
- const releaseElem = cssSelect.selectOne(
- "a[href$='/releases'] .Counter",
- document,
- ) as Element | null;
- let numReleases: number | undefined;
- let latestReleaseTag: string | undefined;
- let latestReleaseDate: string | undefined;
-
- if (releaseElem) {
- const releaseText = getAttributeValue(releaseElem, "title") || textContent(releaseElem);
- numReleases = parseInt(releaseText.replace(/,/g, ""), 10);
-
- // Find the latest release tag and date if available
- const cell = (releaseElem.parent as Element)?.parent?.parent as Element; // span -> a -> h2 -> cell
- if (cell) {
- const latestLink = cssSelect.selectOne("a[href*='/releases/tag/']", cell) as Element;
- if (latestLink) {
- const tagElem = cssSelect.selectOne(".css-truncate-target", latestLink);
- if (tagElem) {
- latestReleaseTag = textContent(tagElem).trim();
- }
-
- const timeElem = cssSelect.selectOne("relative-time", latestLink);
- if (timeElem) {
- latestReleaseDate = getAttributeValue(timeElem, "datetime") || undefined;
- }
- }
- }
- }
-
- return {
- description,
- website,
- stars,
- forks,
- numReleases,
- latestReleaseTag,
- latestReleaseDate,
- };
-}
-
function extractOverviewHtml(
overviewFiles: OverviewFile[] = [],
): Record<string, string | null> | undefined {
@@ -539,7 +478,15 @@ export async function getGitHubRepo(owner: string, repo: string): Promise<GitHub
throw new Error("Could not find tree data in embedded JSON");
}
- const info = parseRepoInfo(html);
+ const about = payload?.sidebarAbout;
+ const releases = about?.sections?.releases;
+ const info = {
+ description: about?.description ?? null,
+ website: about?.website ?? null,
+ stars: about?.stargazerCount?.toLocaleString("en-US") ?? null,
+ forks: about?.forksCount?.toLocaleString("en-US") ?? null,
+ numReleases: releases === false ? undefined : releases?.releaseCount,
+ };
const overviewHtml = extractOverviewHtml(repoRoute.overview?.overviewFiles);
return extractGitHub(payload, { items: repoRoute.tree.items, info, overviewHtml });
@@ -742,11 +689,16 @@ interface SidebarPayload {
color?: string;
}[];
};
+ releases?: {
+ // The name is the release title, which need not be the tag; the path is.
+ latestRelease?: { name?: string; path: string; publishedAt?: string } | null;
+ };
}
-// The repository sidebar (languages, contributors, ...) is not rendered into
-// the repo page at all; GitHub's own UI lazily fetches it as JSON from here.
-export async function getGitHubLanguages(owner: string, repo: string): Promise<GitHubLanguages> {
+// The repository sidebar (languages, releases, contributors, ...) is not
+// rendered into the repo page at all; GitHub's own UI lazily fetches it as
+// JSON from here.
+export async function getGitHubSidebar(owner: string, repo: string): Promise<GitHubSidebar> {
const payload = await fetchGitHubJson<SidebarPayload>(`${owner}/${repo}/_sidebar`);
const languages: Language[] = [];
@@ -758,7 +710,15 @@ export async function getGitHubLanguages(owner: string, repo: string): Promise<G
});
}
- return { repo: { owner, name: repo }, languages };
+ const release = payload.releases?.latestRelease;
+ const latestRelease = release
+ ? {
+ tag: decodeURIComponent(release.path.split("/releases/tag/")[1] ?? release.name ?? ""),
+ publishedAt: release.publishedAt ?? null,
+ }
+ : null;
+
+ return { repo: { owner, name: repo }, languages, latestRelease };
}
interface BranchRefEntry {