Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 1 addition & 0 deletions src/_locales/zh_CN/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
"noStargazers": { "message": "暂无 Stargazers" },
"noForks": { "message": "暂无 Forks" },
"failedToLoad": { "message": "加载失败" },
"githubAccessRestrictions": { "message": "GitHub 访问限制说明" },

"collapseTree": { "message": "折叠目录树" },
"expandTree": { "message": "展开目录树" },
Expand Down
1 change: 1 addition & 0 deletions src/_locales/zh_TW/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
"noStargazers": { "message": "尚無 Stargazers" },
"noForks": { "message": "尚無 Forks" },
"failedToLoad": { "message": "載入失敗" },
"githubAccessRestrictions": { "message": "GitHub 存取限制說明" },

"collapseTree": { "message": "摺疊目錄樹" },
"expandTree": { "message": "展開目錄樹" },
Expand Down
49 changes: 49 additions & 0 deletions src/features/watch-fork-star-popup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div data-testid="responsive-social-buttons">
<button data-testid="notifications-subscriptions-menu-button">
Watch <span data-component="CounterLabel">4</span>
</button>
<a data-testid="fork-button">
Fork <span data-component="CounterLabel">2</span>
</a>
<button data-testid="star-button">
Star <span data-component="CounterLabel">9</span>
</button>
</div>
`;

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([
Expand All @@ -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<HTMLAnchorElement>("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<HTMLAnchorElement>(".bg-wfs-popup-footer a")].some(
(candidate) => candidate.getAttribute("href") === "/owner/repo/watchers",
),
).toBe(true);

vi.useRealTimers();
});

it("portals the popup to <body>, out of the stargazers anchor, so clicks can't leak to it", async () => {
vi.useFakeTimers();
// GitHub nests the star counter inside an `<a href=".../stargazers">`.
Expand Down
65 changes: 42 additions & 23 deletions src/features/watch-fork-star-popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <body> and positioned with fixed coordinates, so JS needs the width to right-
Expand Down Expand Up @@ -100,6 +102,23 @@ function renderUserList(
`).join("");
}

function renderSocialList(
list: HTMLElement,
data: Awaited<ReturnType<typeof fetchStargazers | typeof fetchWatchers>>,
emptyMsg: string,
): void {
if (Array.isArray(data)) {
renderUserList(list, data, emptyMsg);
return;
}

list.innerHTML = `<div class="bg-wfs-popup-empty">
<a href="${ACCESS_RESTRICTIONS_URL}" target="_blank" rel="noopener noreferrer">
${t("githubAccessRestrictions")}
</a>
</div>`;
}

function renderForks(list: HTMLElement, items: ForkInfo[]): void {
if (items.length === 0) {
list.innerHTML = `<div class="bg-wfs-popup-empty">${t("noForks")}</div>`;
Expand Down Expand Up @@ -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<HTMLElement>(
'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<HTMLElement>(
'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);
Expand All @@ -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<ReturnType<typeof fetchStargazers>> | 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<HTMLElement>(
'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"));
});
}
}
Expand Down
16 changes: 12 additions & 4 deletions src/lib/github-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
TagInfo,
StargazerInfo,
WatcherInfo,
SocialList,
ForkInfo,
ContributorInfo,
} from "./messages";
Expand Down Expand Up @@ -47,6 +48,7 @@ export type {
TagInfo,
StargazerInfo,
WatcherInfo,
SocialList,
ForkInfo,
ContributorInfo,
};
Expand Down Expand Up @@ -197,9 +199,12 @@ export async function approvePR(
}
}

export async function fetchStargazers(owner: string, repo: string): Promise<StargazerInfo[]> {
export async function fetchStargazers(
owner: string,
repo: string,
): Promise<SocialList<StargazerInfo>> {
try {
return await sendMessage<StargazerInfo[]>({
return await sendMessage<SocialList<StargazerInfo>>({
type: "FETCH_STARGAZERS",
owner,
repo,
Expand All @@ -210,9 +215,12 @@ export async function fetchStargazers(owner: string, repo: string): Promise<Star
}
}

export async function fetchWatchers(owner: string, repo: string): Promise<WatcherInfo[]> {
export async function fetchWatchers(
owner: string,
repo: string,
): Promise<SocialList<WatcherInfo>> {
try {
return await sendMessage<WatcherInfo[]>({
return await sendMessage<SocialList<WatcherInfo>>({
type: "FETCH_WATCHERS",
owner,
repo,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface WatcherInfo {
name: string | null;
}

export type SocialList<T> = T[] | { restricted: true };

export interface ForkInfo {
owner: string;
ownerAvatarUrl: string;
Expand Down
29 changes: 25 additions & 4 deletions src/service-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand Down
57 changes: 49 additions & 8 deletions src/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
TagInfo,
StargazerInfo,
WatcherInfo,
SocialList,
ForkInfo,
ContributorInfo,
} from "./lib/messages";
Expand Down Expand Up @@ -485,18 +486,52 @@ async function fetchRepoTags(owner: string, repo: string): Promise<TagInfo[]> {
});
}

async function fetchStargazers(owner: string, repo: string): Promise<StargazerInfo[]> {
async function canAccessSocialLists(owner: string, repo: string, token: string): Promise<boolean> {
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<SocialList<StargazerInfo>> {
const token = await getToken();
if (!(await canAccessSocialLists(owner, repo, token))) return { restricted: true };

const cacheKey = `cache:stargazers:${owner}/${repo}`;
return cachedFetch<StargazerInfo[]>(cacheKey, async () => {
const headers = restHeaders(await getToken(), "application/vnd.github.star+json");
return cachedFetch<SocialList<StargazerInfo>>(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 });

if (!response.ok) {
console.error(
`[Better GitHub] Stargazers API error: ${response.status} ${response.statusText}`,
);
return [];
return response.status === 403 || response.status === 404 ? { restricted: true } : [];
}

const data: Array<{
Expand All @@ -512,18 +547,24 @@ async function fetchStargazers(owner: string, repo: string): Promise<StargazerIn
});
}

async function fetchWatchers(owner: string, repo: string): Promise<WatcherInfo[]> {
async function fetchWatchers(
owner: string,
repo: string,
): Promise<SocialList<WatcherInfo>> {
const token = await getToken();
if (!(await canAccessSocialLists(owner, repo, token))) return { restricted: true };

const cacheKey = `cache:watchers:${owner}/${repo}`;
return cachedFetch<WatcherInfo[]>(cacheKey, async () => {
const headers = restHeaders(await getToken());
return cachedFetch<SocialList<WatcherInfo>>(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 });

if (!response.ok) {
console.error(
`[Better GitHub] Watchers API error: ${response.status} ${response.statusText}`,
);
return [];
return response.status === 403 || response.status === 404 ? { restricted: true } : [];
}

const data: Array<{ login: string; avatar_url: string; name?: string | null }> =
Expand Down