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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Better GitHub is a Chrome extension that enhances the GitHub experience. Inspire

### Profile

- **Contributor Card** — Hover a username to append a panel of objective facts to GitHub's native hovercard: account age, relation to the current repo (first-time vs. returning contributor), historical PR merge rate, and last-year activity. Helps you size up an unfamiliar contributor at a glance — for instance, spotting mass-generated low-effort PRs. Facts only: no scoring, no labels. The activity row requires a token.
- **Contributor Card** — Hover a username to append account age, follower/following counts, relation to the current repo (first-time vs. returning contributor), PR history, and last-year activity to GitHub's native hovercard. Following 20,000 or more users shows a possible mass-following hint based only on that count. The activity row requires a token.

<details>
<summary>Screenshot</summary>
Expand Down
2 changes: 1 addition & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Better GitHub 是一个增强 GitHub 使用体验的 Chrome 插件,灵感源

### 用户

- **贡献者背景卡** — 悬停用户名时,在 GitHub 原生 hovercard 底部追加一块客观事实:账号年龄、与当前仓库的关系(首次贡献 / 回访贡献者)、历史 PR 合并率、近一年活跃度。帮你一眼判断一个陌生的贡献者——例如识别批量生成的低质量 PR。只摆事实,不打分、不贴标签。活跃度一行需要 token。
- **贡献者背景卡** — 悬停用户名时,在 GitHub 原生 hovercard 底部追加账号年龄、粉丝与关注数量、与当前仓库的关系(首次贡献 / 回访贡献者)、历史 PR 和近一年活跃度。关注数达到 20,000 时显示疑似批量关注的提示,仅根据关注数量触发。活跃度一行需要 token。

<details>
<summary>示例截图</summary>
Expand Down
17 changes: 13 additions & 4 deletions docs/contributor-profile-card.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
# Contributor Profile Card

Contributor Profile Card 在 GitHub 原生用户 hovercard 底部追加一组贡献者资料,
帮助用户快速了解不熟悉的贡献者。它只展示来自 GitHub 的客观信息,不打分、不贴
标签,也不判断账号或贡献质量。
帮助用户快速了解不熟悉的贡献者。资料来自 GitHub API,关注数量过高时追加
疑似批量关注的提示。

## 展示内容

| 项目 | 内容 | 数据来源 |
| ---------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| 账号年龄 | 账号创建至今的时间及创建月份 | REST `GET /users/{login}` |
| 关注关系 | 粉丝数、关注数及关注数 / 粉丝数的倍数 | 同一用户资料请求的 `followers`、`following` |
| 本仓库关系 | Owner、Member、Collaborator、Contributor 或 First-time contributor | REST `GET /repos/{owner}/{repo}/issues?creator={login}` 返回的 `author_association` |
| 历史 PR | PR 总数、已合并数、关闭但未合并数 | Search API |
| 活跃度 | 过去一年的贡献数 | GraphQL `contributionsCollection` |

本仓库关系仅在仓库页面且 GitHub 返回有效关系时展示。历史 PR 总数为零或查询失败
时不展示该行。

粉丝数和关注数合并在 Follow 行展示,大数字缩写为 `30k`、`100k` 等。悬停时
显示关注数除以粉丝数的比例;没有粉丝时比例显示 `—`。

实际关注数达到 20,000 时,在同一行追加 `⚠`;粉丝数量和关注比例不参与触发。
图标的悬停说明为“疑似批量关注,可能通过回关积累粉丝”,并解释触发条件。
20,000 是经验阈值,不是 GitHub 官方判定标准。关注比例不代表回关率,数量提示
也不能单独证明账号动机。关注关系复用已有用户资料请求,不额外获取粉丝或关注列表。

活跃度需要 GitHub token。未配置 token 时,卡片保留该行并提示用户连接 token;
其他公开资料仍可匿名获取,但受 GitHub API 匿名限额约束。

Expand All @@ -41,8 +50,8 @@ Contributor Profile Card 在 GitHub 原生用户 hovercard 底部追加一组贡

- 所有资料都直接从 GitHub API 获取,不发送给第三方服务。
- GitHub token 保存在 Chrome 本地存储中,只用于请求 GitHub API。
- 功能不生成风险分数、可疑标签或颜色警告。
- Followers 和 public repository 数量虽然随用户资料一起返回,但当前不会展示。
- 关注提示只基于公开的关注数量,不生成风险分数。
- Public repository 数量虽然随用户资料一起返回,但当前不会展示。

## 代码位置

Expand Down
18 changes: 17 additions & 1 deletion src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,25 @@
},
"featContributorCardName": { "message": "Contributor Background Card" },
"featContributorCardDesc": {
"message": "Add objective facts (account age, relation to this repo, historical merge rate, activity) to the hovercard shown when you hover a username. Facts only — no scoring. Activity needs a token."
"message": "Add account age, follow counts, repo relationship, PR history, and activity to user hovercards. High following counts show a possible mass-following hint. Activity needs a token."
},
"ccAge": { "message": "Account age" },
"ccFollow": { "message": "Follow" },
"ccMassFollowingHelp": {
"message": "Possible mass-following to gain followers through follow-backs. Based only on following count (20,000 or more)."
},
"ccFollowersCount": {
"message": "$count$ followers",
"placeholders": { "count": { "content": "$1", "example": "91" } }
},
"ccFollowingCount": {
"message": "$count$ following",
"placeholders": { "count": { "content": "$1", "example": "7" } }
},
"ccFollowRatioHelp": {
"message": "Following ÷ followers: $ratio$. This is not a follow-back rate. — means the account has no followers.",
"placeholders": { "ratio": { "content": "$1", "example": "0.08×" } }
},
"ccRepo": { "message": "This repo" },
"ccHistory": { "message": "PR history" },
"ccActivity": { "message": "Activity" },
Expand Down
18 changes: 17 additions & 1 deletion src/_locales/zh_CN/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,25 @@
},
"featContributorCardName": { "message": "贡献者背景卡" },
"featContributorCardDesc": {
"message": "在 hover 用户名弹出的卡片里追加客观事实(账号年龄、与本仓库关系、历史 merge 率、活跃度),帮你判断账号。只摆事实、不打分。活跃度需要令牌。"
"message": "在用户名悬停卡片中展示账号年龄、关注关系、本仓库关系、历史 PR 和活跃度。关注数量过高时提示疑似批量关注。活跃度需要令牌。"
},
"ccAge": { "message": "账号年龄" },
"ccFollow": { "message": "关注" },
"ccMassFollowingHelp": {
"message": "疑似批量关注,可能通过回关积累粉丝。仅根据关注数量达到 20,000 的情况提示。"
},
"ccFollowersCount": {
"message": "$count$ 粉丝",
"placeholders": { "count": { "content": "$1", "example": "91" } }
},
"ccFollowingCount": {
"message": "关注 $count$ 人",
"placeholders": { "count": { "content": "$1", "example": "7" } }
},
"ccFollowRatioHelp": {
"message": "关注数 ÷ 粉丝数:$ratio$,不代表回关率。— 表示该账号没有粉丝。",
"placeholders": { "ratio": { "content": "$1", "example": "0.08×" } }
},
"ccRepo": { "message": "本仓库" },
"ccHistory": { "message": "历史 PR" },
"ccActivity": { "message": "活跃度" },
Expand Down
18 changes: 17 additions & 1 deletion src/_locales/zh_TW/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,25 @@
},
"featContributorCardName": { "message": "貢獻者背景卡" },
"featContributorCardDesc": {
"message": "在 hover 使用者名稱彈出的卡片裡追加客觀事實(帳號年齡、與本儲存庫關係、歷史 merge 率、活躍度),幫你判斷帳號。只擺事實、不打分。活躍度需要權杖。"
"message": "在使用者名稱懸停卡片中展示帳號年齡、關注關係、本儲存庫關係、歷史 PR 和活躍度。關注數量過高時提示疑似批量關注。活躍度需要權杖。"
},
"ccAge": { "message": "帳號年齡" },
"ccFollow": { "message": "關注" },
"ccMassFollowingHelp": {
"message": "疑似批量關注,可能透過回關累積粉絲。僅根據關注數量達到 20,000 的情況提示。"
},
"ccFollowersCount": {
"message": "$count$ 粉絲",
"placeholders": { "count": { "content": "$1", "example": "91" } }
},
"ccFollowingCount": {
"message": "關注 $count$ 人",
"placeholders": { "count": { "content": "$1", "example": "7" } }
},
"ccFollowRatioHelp": {
"message": "關注數 ÷ 粉絲數:$ratio$,不代表回關率。— 表示該帳號沒有粉絲。",
"placeholders": { "ratio": { "content": "$1", "example": "0.08×" } }
},
"ccRepo": { "message": "本儲存庫" },
"ccHistory": { "message": "歷史 PR" },
"ccActivity": { "message": "活躍度" },
Expand Down
83 changes: 83 additions & 0 deletions src/features/contributor-card.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setUrl } from "../test-utils/url";
import type { ContributorInfo } from "../lib/messages";
import { setLocale } from "../lib/i18n";

// The card fetches via the service-worker bridge; stub it so tests are pure DOM.
const fetchContributorInfo =
Expand All @@ -21,6 +22,7 @@ function baseInfo(over: Partial<ContributorInfo> = {}): ContributorInfo {
login: "octocat",
createdAt: "2026-06-13T00:00:00Z", // 3 days before NOW
followers: 10,
following: 5,
publicRepos: 5,
prTotal: 8,
prMerged: 2,
Expand Down Expand Up @@ -56,6 +58,7 @@ describe("injectContributorCard", () => {
setUrl(`${GH}/owner/repo/pull/1`);
document.body.innerHTML = "";
fetchContributorInfo.mockReset();
setLocale("en");
});

afterEach(() => {
Expand All @@ -80,6 +83,78 @@ describe("injectContributorCard", () => {
expect(text).toContain("1240 contributions in the past year"); // token activity
});

it.each([
[
"en",
"Follow",
"30k followers · 100k following ⚠",
"This is not a follow-back rate",
"Possible mass-following",
],
["zh_CN", "关注", "30k 粉丝 · 关注 100k 人 ⚠", "不代表回关率", "疑似批量关注"],
["zh_TW", "關注", "30k 粉絲 · 關注 100k 人 ⚠", "不代表回關率", "疑似批量關注"],
] as const)(
"shows follower and following counts in one row in %s",
async (locale, label, counts, help, warningText) => {
setLocale(locale);
fetchContributorInfo.mockResolvedValue(baseInfo({ followers: 30000, following: 100000 }));
injectContributorCard();
document.body.appendChild(hovercard("octocat"));
await flush();

const rows = [...document.querySelectorAll<HTMLElement>(`${BLOCK}-row`)];
const connections = rows.find((r) => r.textContent?.startsWith(label));
expect(connections?.querySelector(`${BLOCK}-label`)?.textContent).toBe(label);
expect(connections?.querySelector(`${BLOCK}-value`)?.textContent).toBe(counts);
expect(connections?.title).toContain("3.33×");
expect(connections?.title).toContain(help);
const warning = connections?.querySelector<HTMLElement>(`${BLOCK}-warning`);
expect(warning?.title).toContain(warningText);
expect(warning?.getAttribute("aria-label")).toBe(warning?.title);
expect(rows).toHaveLength(5);
},
);

it.each([
[0, 100, "—"],
[0, 0, "—"],
[10, 0, "0×"],
[10, 5, "0.5×"],
[92, 7, "0.08×"],
])("handles %i followers and %i following", async (followers, following, ratio) => {
fetchContributorInfo.mockResolvedValue(baseInfo({ followers, following }));
injectContributorCard();
document.body.appendChild(hovercard("octocat"));
await flush();

const connections = [...document.querySelectorAll<HTMLElement>(`${BLOCK}-row`)].find((r) =>
r.textContent?.startsWith("Follow"),
);
expect(connections?.textContent).toBe(`Follow${followers} followers · ${following} following`);
expect(connections?.title).toContain(`Following ÷ followers: ${ratio}.`);
});

it.each([
[30000, 19999, "30k followers · 20k following", false],
[30000, 20000, "30k followers · 20k following ⚠", true],
[0, 20000, "0 followers · 20k following ⚠", true],
[1000000, 7, "1m followers · 7 following", false],
])(
"checks the actual following count for %i followers and %i following",
async (followers, following, counts, flagged) => {
fetchContributorInfo.mockResolvedValue(baseInfo({ followers, following }));
injectContributorCard();
document.body.appendChild(hovercard("octocat"));
await flush();

const connections = [...document.querySelectorAll<HTMLElement>(`${BLOCK}-row`)].find((r) =>
r.textContent?.startsWith("Follow"),
);
expect(connections?.querySelector(`${BLOCK}-value`)?.textContent).toBe(counts);
expect(connections?.querySelector(`${BLOCK}-warning`) != null).toBe(flagged);
},
);

it("passes the current repo context to the fetch", async () => {
fetchContributorInfo.mockResolvedValue(baseInfo());
injectContributorCard();
Expand Down Expand Up @@ -305,6 +380,11 @@ describe("injectContributorCard", () => {
expect(loading!.dataset.skeleton).toBe("true"); // a skeleton while pending
expect(loading!.querySelector(`${BLOCK}-skeleton`)).not.toBeNull();
expect(loading!.textContent).not.toContain("days"); // no real data yet
const loadingLabels = [...loading!.querySelectorAll(`${BLOCK}-label`)].map(
(el) => el.textContent,
);
expect(loadingLabels).toContain("Follow");
expect(loadingLabels).toHaveLength(5);

resolve(baseInfo());
await flush();
Expand All @@ -313,6 +393,9 @@ describe("injectContributorCard", () => {
expect(panels).toHaveLength(1); // skeleton replaced in place, not duplicated
expect(panels[0].dataset.skeleton).toBeUndefined();
expect(panels[0].textContent).toContain("3 days · 2026-06"); // real data
expect([...panels[0].querySelectorAll(`${BLOCK}-label`)].map((el) => el.textContent)).toEqual(
loadingLabels,
);
});

it("does not double-decorate the same populated card", async () => {
Expand Down
35 changes: 33 additions & 2 deletions src/features/contributor-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
//
// GitHub shows a hovercard when you hover a username. We attach a panel of
// *objective facts* to it — account age, relation to this repo, historical merge
// history, and activity. Facts only: no score, labels, or warnings.
// history, activity, and follower/following counts. A high following count gets
// a possible mass-following hint.
// See docs/contributor-profile-card.md.
//
// DOM verified against a live page (2026-06):
Expand All @@ -28,6 +29,11 @@ import type { ContributorInfo } from "../lib/messages";
import { t } from "../lib/i18n";

const PANEL_CLASS = "better-github-contributor-card";
const FOLLOW_COUNT_FORMAT = new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
});
const MASS_FOLLOWING_THRESHOLD = 20_000;

/** Read the login from a hovercard content root's hydro-view payload, but only
* when it's a *user* hovercard (repos/issues reuse the same container). */
Expand Down Expand Up @@ -134,6 +140,30 @@ function buildPanel(login: string, info: ContributorInfo): HTMLElement {
row("ccAge", valueEl([{ strong: `${age.value} ${ageUnitWord(age)}` }, { dim: ` · ${created}` }])),
);

const followers = FOLLOW_COUNT_FORMAT.format(info.followers).toLowerCase();
const following = FOLLOW_COUNT_FORMAT.format(info.following).toLowerCase();
const followCounts = valueEl([
...emphasizeToken(t("ccFollowersCount", followers), followers),
" · ",
...emphasizeToken(t("ccFollowingCount", following), following),
]);
const connections = row("ccFollow", followCounts);
const ratio =
info.followers === 0
? "—"
: `${(info.following / info.followers).toLocaleString(undefined, { maximumFractionDigits: 2 })}×`;
connections.title = t("ccFollowRatioHelp", ratio);
if (info.following >= MASS_FOLLOWING_THRESHOLD) {
const warning = document.createElement("span");
warning.className = `${PANEL_CLASS}-warning`;
warning.textContent = "⚠";
warning.title = t("ccMassFollowingHelp");
warning.setAttribute("role", "img");
warning.setAttribute("aria-label", warning.title);
followCounts.append(" ", warning);
}
panel.appendChild(connections);

const rel = repoRelation(info.repoAssociation);
if (rel) {
panel.appendChild(row("ccRepo", valueEl([{ strong: t(relationLabelKey(rel)) }])));
Expand Down Expand Up @@ -189,9 +219,10 @@ function buildSkeleton(login: string): HTMLElement {
panel.dataset.login = login;
panel.dataset.skeleton = "true";
panel.appendChild(header());
// [label key, value-bar width] per row — mirrors age/repo/history/activity.
// [label key, value-bar width] per row — mirrors the loaded facts.
for (const [labelKey, vw] of [
["ccAge", 92],
["ccFollow", 132],
["ccRepo", 132],
["ccHistory", 108],
["ccActivity", 150],
Expand Down
1 change: 1 addition & 0 deletions src/lib/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export interface ContributorInfo {
/** ISO timestamp of account creation (REST `created_at`). */
createdAt: string;
followers: number;
following: number;
publicRepos: number;
/** Total PRs authored across GitHub (Search `type:pr author:X`). */
prTotal: number;
Expand Down
28 changes: 28 additions & 0 deletions src/service-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,34 @@ describe("service worker", () => {
vi.restoreAllMocks();
});

it("returns follower and following counts from the cached user profile request", async () => {
const state = await loadWorker();
const fetchMock = vi.mocked(fetch);
fetchMock.mockImplementation(async (input) => {
if (String(input) === "https://api.github.com/users/octocat") {
return jsonResponse({
created_at: "2020-01-01T00:00:00Z",
followers: 30000,
following: 100000,
public_repos: 5,
});
}
return jsonResponse({ total_count: 0 });
});
const request: ServiceWorkerRequest = { type: "FETCH_CONTRIBUTOR_INFO", login: "octocat" };

const first = await sendMessage(state.messageListeners[0], request);
const second = await sendMessage(state.messageListeners[0], request);

expect(first).toMatchObject({
ok: true,
data: { followers: 30000, following: 100000, hasToken: false },
});
expect(second).toEqual(first);
expect(fetchMock).toHaveBeenCalledTimes(4); // one user profile and three existing PR searches
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes("/users/"))).toHaveLength(1);
});

it("coalesces concurrent requests and caches successful PR branch responses", async () => {
const state = await loadWorker();
const fetchDeferred = deferred<Response>();
Expand Down
Loading
Loading