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 = `
+
+ `;
+
+ 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 = ``;
@@ -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 =