diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 96fb51d..1b1f634 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -167,6 +167,7 @@ "noStargazers": { "message": "No stargazers yet" }, "noForks": { "message": "No forks yet" }, "failedToLoad": { "message": "Failed to load" }, + "githubAccessRestrictions": { "message": "GitHub access restrictions" }, "collapseTree": { "message": "Collapse tree" }, "expandTree": { "message": "Expand tree" }, diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json index fbd736e..d0296a9 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -167,6 +167,7 @@ "noStargazers": { "message": "暂无 Stargazers" }, "noForks": { "message": "暂无 Forks" }, "failedToLoad": { "message": "加载失败" }, + "githubAccessRestrictions": { "message": "GitHub 访问限制说明" }, "collapseTree": { "message": "折叠目录树" }, "expandTree": { "message": "展开目录树" }, diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json index 8fa4ce4..425a886 100644 --- a/src/_locales/zh_TW/messages.json +++ b/src/_locales/zh_TW/messages.json @@ -167,6 +167,7 @@ "noStargazers": { "message": "尚無 Stargazers" }, "noForks": { "message": "尚無 Forks" }, "failedToLoad": { "message": "載入失敗" }, + "githubAccessRestrictions": { "message": "GitHub 存取限制說明" }, "collapseTree": { "message": "摺疊目錄樹" }, "expandTree": { "message": "展開目錄樹" }, diff --git a/src/features/watch-fork-star-popup.test.ts b/src/features/watch-fork-star-popup.test.ts index 37f5620..02fc87b 100644 --- a/src/features/watch-fork-star-popup.test.ts +++ b/src/features/watch-fork-star-popup.test.ts @@ -40,6 +40,32 @@ describe("watch/fork/star popup", () => { expect(document.querySelector(".bg-wfs-popup")).toBeNull(); }); + it("finds repository actions after GitHub replaces the legacy wrappers and counter classes", () => { + document.body.innerHTML = ` +
+ + + Fork 2 + + +
+ `; + + injectWatchForkStarPopup(); + + expect(document.querySelectorAll(".bg-wfs-counter-wrap")).toHaveLength(3); + expect(document.querySelectorAll(".bg-wfs-popup")).toHaveLength(3); + expect( + [...document.querySelectorAll(".bg-wfs-popup-header span:last-child")].map( + (count) => count.textContent, + ), + ).toEqual(["4", "2", "9"]); + }); + it("loads and renders the user list when a counter is hovered", async () => { vi.useFakeTimers(); vi.mocked(fetchWatchers).mockResolvedValue([ @@ -64,6 +90,29 @@ describe("watch/fork/star popup", () => { vi.useRealTimers(); }); + it("shows GitHub's restriction notice instead of a list for non-collaborators", async () => { + vi.useFakeTimers(); + vi.mocked(fetchWatchers).mockResolvedValue({ restricted: true }); + + injectWatchForkStarPopup(); + document.getElementById("watch-counter")!.dispatchEvent(new Event("mouseenter")); + await vi.advanceTimersByTimeAsync(300); + + const empty = document.querySelector(".bg-wfs-popup-empty")!; + const link = empty.querySelector("a")!; + expect(link.href).toBe( + "https://github.blog/changelog/2026-06-30-upcoming-access-restrictions-to-public-api-endpoints-and-ui-views/", + ); + expect(link.rel).toBe("noopener noreferrer"); + expect( + [...document.querySelectorAll(".bg-wfs-popup-footer a")].some( + (candidate) => candidate.getAttribute("href") === "/owner/repo/watchers", + ), + ).toBe(true); + + vi.useRealTimers(); + }); + it("portals the popup to , out of the stargazers anchor, so clicks can't leak to it", async () => { vi.useFakeTimers(); // GitHub nests the star counter inside an ``. diff --git a/src/features/watch-fork-star-popup.ts b/src/features/watch-fork-star-popup.ts index ffc1e30..2f67050 100644 --- a/src/features/watch-fork-star-popup.ts +++ b/src/features/watch-fork-star-popup.ts @@ -6,6 +6,8 @@ import type { ForkInfo } from "../lib/github-api"; const WRAP_CLASS = "bg-wfs-counter-wrap"; const POPUP_CLASS = "bg-wfs-popup"; +const ACCESS_RESTRICTIONS_URL = + "https://github.blog/changelog/2026-06-30-upcoming-access-restrictions-to-public-api-endpoints-and-ui-views/"; // Must match `.bg-wfs-popup { width }` in content.css — the popup is portaled to // and positioned with fixed coordinates, so JS needs the width to right- @@ -100,6 +102,23 @@ function renderUserList( `).join(""); } +function renderSocialList( + list: HTMLElement, + data: Awaited>, + emptyMsg: string, +): void { + if (Array.isArray(data)) { + renderUserList(list, data, emptyMsg); + return; + } + + list.innerHTML = ``; +} + function renderForks(list: HTMLElement, items: ForkInfo[]): void { if (items.length === 0) { list.innerHTML = `
${t("noForks")}
`; @@ -240,31 +259,29 @@ export function injectWatchForkStarPopup(): void { const { owner, repo } = repoInfo; - // Check that pagehead-actions exist (repo page) - const actions = document.querySelector("ul.pagehead-actions"); - if (!actions) return; - - // Watch counter (Primer React component — don't move it, attach in-place) - const watchCounter = actions.querySelector('[class*="CounterLabel"]') as HTMLElement | null; - if (watchCounter) { - const countText = watchCounter.textContent?.trim() || "0"; - attachPopup(watchCounter, { + const watchTargets = document.querySelectorAll( + 'ul.pagehead-actions [class*="CounterLabel"], ' + + '[data-testid="notifications-subscriptions-menu-button"] [data-component="CounterLabel"]', + ); + for (const watchTarget of watchTargets) { + attachPopup(watchTarget, { title: t("watchers"), - countText, + countText: watchTarget.textContent?.trim() || "0", viewAllUrl: `/${owner}/${repo}/watchers`, }, async (list) => { const data = await fetchWatchers(owner, repo); - renderUserList(list, data, t("noWatchers")); + renderSocialList(list, data, t("noWatchers")); }); } - // Fork counter - const forkCounter = actions.querySelector("#fork-button .Counter") as HTMLElement | null; - if (forkCounter) { - const countText = forkCounter.textContent?.trim() || "0"; - attachPopup(forkCounter, { + const forkTargets = document.querySelectorAll( + 'ul.pagehead-actions #fork-button .Counter, ' + + '[data-testid="fork-button"] [data-component="CounterLabel"]', + ); + for (const forkTarget of forkTargets) { + attachPopup(forkTarget, { title: t("forks"), - countText, + countText: forkTarget.textContent?.trim() || "0", viewAllUrl: `/${owner}/${repo}/forks`, }, async (list) => { const data = await fetchForks(owner, repo); @@ -276,16 +293,18 @@ export function injectWatchForkStarPopup(): void { // so the popup works regardless of toggle state. // Share fetched data so toggling star state doesn't re-fetch. let starData: Awaited> | null = null; - const starCounters = actions.querySelectorAll(".Counter.js-social-count"); - for (const starCounter of starCounters) { - const countText = starCounter.textContent?.trim() || "0"; - attachPopup(starCounter as HTMLElement, { + const starTargets = document.querySelectorAll( + 'ul.pagehead-actions .Counter.js-social-count, ' + + '[data-testid="star-button"] [data-component="CounterLabel"]', + ); + for (const starTarget of starTargets) { + attachPopup(starTarget, { title: t("stargazers"), - countText, + countText: starTarget.textContent?.trim() || "0", viewAllUrl: `/${owner}/${repo}/stargazers`, }, async (list) => { if (!starData) starData = await fetchStargazers(owner, repo); - renderUserList(list, starData, t("noStargazers")); + renderSocialList(list, starData, t("noStargazers")); }); } } diff --git a/src/lib/github-api.ts b/src/lib/github-api.ts index 80b6691..79817ad 100644 --- a/src/lib/github-api.ts +++ b/src/lib/github-api.ts @@ -9,6 +9,7 @@ import type { TagInfo, StargazerInfo, WatcherInfo, + SocialList, ForkInfo, ContributorInfo, } from "./messages"; @@ -47,6 +48,7 @@ export type { TagInfo, StargazerInfo, WatcherInfo, + SocialList, ForkInfo, ContributorInfo, }; @@ -197,9 +199,12 @@ export async function approvePR( } } -export async function fetchStargazers(owner: string, repo: string): Promise { +export async function fetchStargazers( + owner: string, + repo: string, +): Promise> { try { - return await sendMessage({ + return await sendMessage>({ type: "FETCH_STARGAZERS", owner, repo, @@ -210,9 +215,12 @@ export async function fetchStargazers(owner: string, repo: string): Promise { +export async function fetchWatchers( + owner: string, + repo: string, +): Promise> { try { - return await sendMessage({ + return await sendMessage>({ type: "FETCH_WATCHERS", owner, repo, diff --git a/src/lib/messages.ts b/src/lib/messages.ts index d0a483f..1b2f243 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -61,6 +61,8 @@ export interface WatcherInfo { name: string | null; } +export type SocialList = T[] | { restricted: true }; + export interface ForkInfo { owner: string; ownerAvatarUrl: string; diff --git a/src/service-worker.test.ts b/src/service-worker.test.ts index 072d438..f1a2f78 100644 --- a/src/service-worker.test.ts +++ b/src/service-worker.test.ts @@ -409,12 +409,12 @@ describe("service worker", () => { it("maps the stargazers REST payload", async () => { const state = await loadWorker("token"); - vi.mocked(fetch).mockResolvedValue( - jsonResponse([ + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse({ permissions: { admin: true } })) + .mockResolvedValueOnce(jsonResponse([ { user: { login: "octocat", avatar_url: "https://a/o.png", name: "Octo Cat" }, starred_at: "2026-01-01T00:00:00Z" }, { user: { login: "mona", avatar_url: "https://a/m.png", name: null }, starred_at: "2026-01-02T00:00:00Z" }, - ]), - ); + ])); const response = await sendMessage(state.messageListeners[0], { type: "FETCH_STARGAZERS", @@ -431,6 +431,27 @@ describe("service worker", () => { }); }); + it.each([ + ["FETCH_STARGAZERS", "/stargazers"], + ["FETCH_WATCHERS", "/subscribers"], + ] as const)("skips %s for repositories without collaborator access", async (type, path) => { + const state = await loadWorker("token"); + vi.mocked(fetch).mockResolvedValueOnce( + jsonResponse({ permissions: { pull: true, push: false } }), + ); + + const response = await sendMessage(state.messageListeners[0], { + type, + owner: "owner", + repo: "repo", + }); + + expect(response).toEqual({ ok: true, data: { restricted: true } }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetch).mock.calls[0][0]).toBe("https://api.github.com/repos/owner/repo"); + expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).includes(path))).toBe(false); + }); + it("maps the forks REST payload", async () => { const state = await loadWorker("token"); vi.mocked(fetch).mockResolvedValue( diff --git a/src/service-worker.ts b/src/service-worker.ts index f6dce4d..b21f382 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -10,6 +10,7 @@ import type { TagInfo, StargazerInfo, WatcherInfo, + SocialList, ForkInfo, ContributorInfo, } from "./lib/messages"; @@ -485,10 +486,44 @@ async function fetchRepoTags(owner: string, repo: string): Promise { }); } -async function fetchStargazers(owner: string, repo: string): Promise { +async function canAccessSocialLists(owner: string, repo: string, token: string): Promise { + if (!token) return false; + + const cacheKey = `cache:social-list-access:${owner}/${repo}`; + const result = await cachedFetch<{ allowed: boolean }>(cacheKey, async () => { + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: restHeaders(token), + }); + if (!response.ok) return { allowed: false }; + + const data: { + permissions?: { admin?: boolean; maintain?: boolean; push?: boolean; triage?: boolean }; + } = await response.json(); + const permissions = data.permissions; + // `pull` is true for every public repository visitor, so only elevated + // permissions prove that the token belongs to an admin or collaborator. + return { + allowed: Boolean( + permissions?.admin || + permissions?.maintain || + permissions?.push || + permissions?.triage, + ), + }; + }); + return result.allowed; +} + +async function fetchStargazers( + owner: string, + repo: string, +): Promise> { + const token = await getToken(); + if (!(await canAccessSocialLists(owner, repo, token))) return { restricted: true }; + const cacheKey = `cache:stargazers:${owner}/${repo}`; - return cachedFetch(cacheKey, async () => { - const headers = restHeaders(await getToken(), "application/vnd.github.star+json"); + return cachedFetch>(cacheKey, async () => { + const headers = restHeaders(token, "application/vnd.github.star+json"); const url = `https://api.github.com/repos/${owner}/${repo}/stargazers?per_page=30`; const response = await fetch(url, { headers }); @@ -496,7 +531,7 @@ async function fetchStargazers(owner: string, repo: string): Promise { +async function fetchWatchers( + owner: string, + repo: string, +): Promise> { + const token = await getToken(); + if (!(await canAccessSocialLists(owner, repo, token))) return { restricted: true }; + const cacheKey = `cache:watchers:${owner}/${repo}`; - return cachedFetch(cacheKey, async () => { - const headers = restHeaders(await getToken()); + return cachedFetch>(cacheKey, async () => { + const headers = restHeaders(token); const url = `https://api.github.com/repos/${owner}/${repo}/subscribers?per_page=30`; const response = await fetch(url, { headers }); @@ -523,7 +564,7 @@ async function fetchWatchers(owner: string, repo: string): Promise =