diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a72f71791..d7b09ef299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" - - run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs + - run: node --test scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs # The ruleset requires the per-OS check names (test (ubuntu-latest) etc.). # A matrix job skipped at job level reports no per-leg checks at all, so diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index c2e034d45c..c2a7362a0b 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -155,6 +155,14 @@ export const ACTIONS: UIAction[] = [ { id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" }, { id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" }, { id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" }, + { id: "community.join", kind: "shell-native", target: "none", proof: "interaction" }, + { id: "community.discord", kind: "shell-native", target: "none", proof: "interaction" }, + { id: "community.issues", kind: "shell-native", target: "none", proof: "interaction" }, + { id: "community.contributors", kind: "shell-native", target: "none", proof: "interaction" }, + { id: "community.profile", kind: "shell-native", target: "entity", proof: "interaction" }, + { id: "community.douyin-qr", kind: "view", target: "none", proof: "interaction" }, + { id: "community.author", kind: "shell-native", target: "none", proof: "interaction" }, + { id: "community.more", kind: "view", target: "none", proof: "interaction" }, { id: "feedback.open", kind: "view", target: "none", proof: "interaction" }, { id: "feedback.tab", kind: "view", target: "none", proof: "interaction" }, { id: "feedback.category", kind: "view", target: "none", proof: "interaction" }, diff --git a/desktop/frontend-next/src/assets/douyin-qr.jpg b/desktop/frontend-next/src/assets/douyin-qr.jpg new file mode 100644 index 0000000000..89067b9b34 Binary files /dev/null and b/desktop/frontend-next/src/assets/douyin-qr.jpg differ diff --git a/desktop/frontend-next/src/assets/qq-group-qr.svg b/desktop/frontend-next/src/assets/qq-group-qr.svg new file mode 100644 index 0000000000..da48148429 --- /dev/null +++ b/desktop/frontend-next/src/assets/qq-group-qr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/frontend-next/src/data/contributors.json b/desktop/frontend-next/src/data/contributors.json new file mode 100644 index 0000000000..64426a5522 --- /dev/null +++ b/desktop/frontend-next/src/data/contributors.json @@ -0,0 +1,141 @@ +[ + "SivanCola", + "ttmouse", + "KHG420", + "lifu963", + "HUQIANTAO", + "GTC2080", + "mchenziyi", + "Li-Charles-One", + "light-front-theory", + "eghrhegpe", + "Bernardxu123", + "HaoyueQin", + "BuGlessRB", + "chuiwenwei", + "CVEngineer66", + "JesonChou", + "lanshi17", + "SuMuxi66", + "paradoxSCH", + "CnsMaple", + "cyq1017", + "Guan0923", + "ipanta", + "XTLine", + "YoungDan-hero", + "erphenheimer", + "SauronSkywalker", + "ashishexee", + "lizhengwu", + "par73e", + "yizeyi18", + "Nath-Vikky", + "PMKang", + "qiaone", + "ferstar", + "Harbor404", + "liaoyl830", + "Lxiny-zy", + "zhangsanouo", + "AtmoOmen", + "codex", + "Diarica", + "ffff2004", + "ikelvingo", + "kekexunxun", + "lightfront", + "maoxin1234", + "nostalgia296", + "onionviolet", + "PaulDing98", + "scrappylabsai", + "skyzhao1223", + "Tatlatat", + "yekern", + "assoyuan", + "billycat", + "boscocp", + "closss", + "drafish", + "HBLADEH", + "heshuimu", + "iysun", + "jackie-cqz", + "LVGG", + "nanami7777777", + "qiukeren", + "sudoviber", + "warvyvr", + "whale-fall-ouo", + "wqshan-cn", + "xianyu-sheng", + "Xio-Shark", + "yhemeo613", + "zdjmrq", + "Abysstsuki", + "ahmedalbusaidy", + "Akashmdshetty", + "alxbnct", + "c020627", + "chengoak", + "CooperSheroy", + "dcs02280653-collab", + "dengbushi", + "EMEEEEMMMM", + "ERroooooR", + "gcoder1991", + "h4rk8s", + "HHHEEEWWW", + "hobostay", + "HorusJiang", + "i1j", + "innocarpe", + "j2bap", + "jackijianxa", + "jbts6", + "ji-tz", + "JimmyDaddy", + "kabaka9527", + "kate1128", + "LegGasai", + "leslie3747", + "LouF666", + "MagnumGoYB", + "MaitreyeeDeshmukh", + "myipanta", + "nanami-0713", + "Ninot1Quyi", + "NkAntony777", + "no-name-touse", + "Nontee22", + "onenewcode", + "Oxygen56", + "PorunC", + "Q1hangL", + "QingLong-C", + "Ray-lyy", + "rixzkiye", + "Robertoarce", + "ryukeilee", + "seankhliao", + "ShiroKSH", + "siwenyu666-beep", + "SprayHank", + "Starry0214", + "Surfire", + "taibai233", + "themarchrain", + "tic-top", + "weikengchen", + "wudiandy", + "xiaoshuai1024", + "xiaotaoya2022-cell", + "xinrenH", + "xxoingr", + "ying-hua", + "yuanchenglu", + "zhuguang-ZFG", + "Zhuocheng-Lang", + "zlwu" +] diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 135822b1ca..e88274a8ec 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -17,11 +17,13 @@ import { EN_USAGE } from "./en_usage"; import { EN_COMPOSER } from "./en_composer"; import { EN_BACKUP } from "./en_backup"; import { EN_FEEDBACK } from "./en_feedback"; +import { EN_COMMUNITY } from "./en_community"; export const EN: Record = { "工作台": "Workbench", "从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser", ...EN_SETTINGS, + ...EN_COMMUNITY, ...EN_METRICS, ...EN_STORAGE, ...EN_USAGE, diff --git a/desktop/frontend-next/src/i18n/en_community.ts b/desktop/frontend-next/src/i18n/en_community.ts new file mode 100644 index 0000000000..e3da55f760 --- /dev/null +++ b/desktop/frontend-next/src/i18n/en_community.ts @@ -0,0 +1,20 @@ +export const EN_COMMUNITY: Record = { + "社区与贡献者": "Community and contributors", + "遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。": "Join the community for help, tips and ideas. Below are the people who have contributed code to Reasonix.", + "QQ 群二维码": "QQ group QR code", + "群号": "Group number", + "复制群号": "Copy group number", + "用 QQ 扫描二维码,或点击下方按钮加入。": "Scan the QR code with QQ, or use the button below to join.", + "加入 QQ 群": "Join the QQ group", + "贡献者": "Contributors", + "按提交数排序,名单随版本更新": "Sorted by commits; the list is refreshed with each release", + "查看全部贡献者": "View all contributors", + "显示全部 {n} 位": "Show all {n}", + "抖音": "Douyin", + "抖音号": "Douyin ID", + "复制抖音号": "Copy Douyin ID", + "显示二维码": "Show QR code", + "收起二维码": "Hide QR code", + "抖音二维码": "Douyin QR code", + "作者与维护者": "Author and maintainer", +}; diff --git a/desktop/frontend-next/src/main.tsx b/desktop/frontend-next/src/main.tsx index dc208badd1..da42c3333c 100644 --- a/desktop/frontend-next/src/main.tsx +++ b/desktop/frontend-next/src/main.tsx @@ -7,6 +7,7 @@ import "./styles/tokens.css"; import "./styles/app.css"; import "./styles/studio.css"; import "./styles/feedback.css"; +import "./styles/community.css"; import { App } from "./ui/App"; import { SseHub } from "./port/hub"; import { HttpError } from "./port/http_error"; diff --git a/desktop/frontend-next/src/styles/LAYOUT.md b/desktop/frontend-next/src/styles/LAYOUT.md index 57fe1a55cd..ead3689706 100644 --- a/desktop/frontend-next/src/styles/LAYOUT.md +++ b/desktop/frontend-next/src/styles/LAYOUT.md @@ -803,6 +803,14 @@ what exists. - Remove is the one irreversible action in a row, so it does not look like the other buttons and appears only on hover. +## Community + +- The QR code sits on a white plate in both themes: a scanner needs dark modules + on a light field with a quiet zone, and an inverted code in the dark theme + does not scan. The plate is the image's own quiet zone, not a card behind it. +- The image is drawn at 5 CSS pixels per module (185px for 37 modules) so the + squares land on whole pixels; `image-rendering: pixelated` keeps them hard. + ## Version list - The list is one grid and each row a subgrid of it, so version, description, diff --git a/desktop/frontend-next/src/styles/community.css b/desktop/frontend-next/src/styles/community.css new file mode 100644 index 0000000000..9d67a2da99 --- /dev/null +++ b/desktop/frontend-next/src/styles/community.css @@ -0,0 +1,35 @@ +.comm { display: flex; flex-direction: column; gap: 22px; } +.comm-join { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 20px; } +.comm-qr { + flex: none; width: 185px; height: 185px; max-width: 100%; box-sizing: border-box; + background: #fff; border: 1px solid var(--border); border-radius: var(--r-md); + image-rendering: pixelated; +} +.comm-qq { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 10px; } +.comm-name { font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); color: var(--text); overflow-wrap: anywhere; } +.comm-num { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.comm-num code { font: var(--w-med) var(--fs-lead) var(--mono); color: var(--text); letter-spacing: .02em; user-select: all; } +.comm-k { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); } +.comm-how { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); } +.comm-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } +.comm-fail { margin: 0; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--err-ink); overflow-wrap: anywhere; } +.comm-who { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding-top: 16px; border-top: 1px solid var(--hair); } +.comm-who-hd { align-self: stretch; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; } +.comm-who-hd h4 { margin: 0; font: var(--w-semi) var(--fs-ui) var(--ui); color: var(--text); } +.comm-who-hd .btn { margin-left: auto; } +.comm-list { align-self: stretch; margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px; } +.comm-list li { min-width: 0; } +.comm-list a { + display: block; max-width: 100%; padding: 2px 9px; border: 1px solid var(--hair); border-radius: var(--r-pill); + font: var(--w-reg) var(--fs-meta)/1.7 var(--mono); color: var(--muted); text-decoration: none; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.comm-list a:hover { color: var(--text); border-color: var(--faint); } +.comm-list a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } +.comm-dy { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); } +.comm-dy code { font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted); user-select: all; } +.comm-dy-qr { align-self: flex-start; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--r-md); } +.comm-author { display: flex; align-items: baseline; gap: 10px; padding: 0 0 14px; } +.comm-author a { font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text); text-decoration: none; } +.comm-author a:hover { text-decoration: underline; } +.comm-author a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } diff --git a/desktop/frontend-next/src/ui/About.tsx b/desktop/frontend-next/src/ui/About.tsx new file mode 100644 index 0000000000..6de5410efd --- /dev/null +++ b/desktop/frontend-next/src/ui/About.tsx @@ -0,0 +1,26 @@ +import { t } from "../i18n"; +import type { AgentPort } from "../port/port"; +import { AuthorLine } from "./AuthorLine"; +import { Community } from "./Community"; +import { Group } from "./Group"; +import { Versions } from "./Versions"; + +export function About({ port }: { port: AgentPort }) { + return ( + <> + + + + + + + + + ); +} diff --git a/desktop/frontend-next/src/ui/AuthorLine.tsx b/desktop/frontend-next/src/ui/AuthorLine.tsx new file mode 100644 index 0000000000..2e93f21a67 --- /dev/null +++ b/desktop/frontend-next/src/ui/AuthorLine.tsx @@ -0,0 +1,12 @@ +import { t } from "../i18n"; +import type { AgentPort } from "../port/port"; +import { COMMUNITY } from "./communityLinks"; + +export function AuthorLine({ port }: { port: Pick }) { + return ( +
+ {t("作者与维护者")} + { e.preventDefault(); void port.openExternal(COMMUNITY.authorUrl).catch(() => {}); }}>{COMMUNITY.author} +
+ ); +} diff --git a/desktop/frontend-next/src/ui/Community.interaction.test.tsx b/desktop/frontend-next/src/ui/Community.interaction.test.tsx new file mode 100644 index 0000000000..f45a68f6d3 --- /dev/null +++ b/desktop/frontend-next/src/ui/Community.interaction.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { Community } from "./Community"; +import { AuthorLine } from "./AuthorLine"; +import { profileUrl } from "./communityLinks"; +import contributors from "../data/contributors.json"; +import type { AgentPort } from "../port/port"; + +const QR = import.meta.glob("../assets/qq-group-qr.svg", { query: "?raw", import: "default", eager: true }) as Record; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const portWith = (openExternal = vi.fn(async () => {})) => ({ openExternal }) as unknown as Pick; + +describe("community and contributors", () => { + it("names the group and shows its number", () => { + render(); + expect(screen.getByText("DeepSeek-Reasonix官方群")).toBeTruthy(); + expect(screen.getByText("1093562660")).toBeTruthy(); + }); + + it("opens the join link, Discord and issues through the host", async () => { + const openExternal = vi.fn(async () => {}); + render(); + await userEvent.click(screen.getByRole("button", { name: "加入 QQ 群" })); + expect(openExternal).toHaveBeenLastCalledWith("https://qm.qq.com/q/i59b0z2R8s"); + await userEvent.click(screen.getByRole("button", { name: "Discord" })); + expect(openExternal).toHaveBeenLastCalledWith("https://discord.gg/XF78rEME2D"); + await userEvent.click(screen.getByRole("button", { name: "GitHub issues" })); + expect(openExternal).toHaveBeenLastCalledWith("https://github.com/esengine/DeepSeek-Reasonix/issues"); + }); + + it("says so, with the address, when the host cannot open it", async () => { + const openExternal = vi.fn(async () => { throw new Error("no browser"); }); + render(); + await userEvent.click(screen.getByRole("button", { name: "Discord" })); + expect((await screen.findByRole("alert")).textContent).toContain("https://discord.gg/XF78rEME2D"); + }); + + it("copies the group number", async () => { + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + render(); + await userEvent.click(screen.getByRole("button", { name: "复制群号" })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("1093562660")); + }); + + it("references a QR image that exists", () => { + render(); + const img = screen.getByRole("img", { name: "QQ 群二维码" }) as HTMLImageElement; + expect(img.getAttribute("src")).toBeTruthy(); + expect(Object.values(QR)[0]).toMatch(/^
+
+ {t("QQ +
+ {COMMUNITY.qqName} + + {t("群号")} + {COMMUNITY.qqNumber} + + + {t("用 QQ 扫描二维码,或点击下方按钮加入。")} + + + + + +
+
+ +
+ {t("抖音")} + {COMMUNITY.douyinName} + {t("抖音号")} + {COMMUNITY.douyinId} + + +
+ {douyinQr && ( + {t("抖音二维码")} + )} + + {failed &&

{failed}

} + +
+
+

{t("贡献者")}

+ {t("按提交数排序,名单随版本更新")} + +
+ + {people.length > COLLAPSED && ( + + )} +
+
+ ); +} diff --git a/desktop/frontend-next/src/ui/Settings.tsx b/desktop/frontend-next/src/ui/Settings.tsx index a5751c4b4e..208ac2ec48 100644 --- a/desktop/frontend-next/src/ui/Settings.tsx +++ b/desktop/frontend-next/src/ui/Settings.tsx @@ -38,7 +38,7 @@ import { KIND_LABEL } from "./vendors"; import { planProtocolSwitch } from "./protocolswitch"; import { Boundary } from "./Boundary"; import { Path } from "./Path"; -import { Versions } from "./Versions"; +import { About } from "./About"; import { Memory } from "./Memory"; import { DEFAULT_DAYS, Usage } from "./Usage"; import { Storage } from "./Storage"; @@ -745,14 +745,7 @@ export function Settings({ hub, onError, port, networkPort, networkHost, status, )} - {at === "versions" && ( - - - - )} + {at === "versions" && } {at === "memory" && ( onFeedback("send") }, { id: "feedback-mine", label: t("我的反馈"), icon: "feedback", keywords: "feedback receipt status my reply 反馈 回执 进展 我的 回复", status: feedbackUnread > 0 ? t("{n} 项待查看", { n: feedbackUnread }) : undefined, run: () => onFeedback("mine") }, + { id: "community", label: t("社区与贡献者"), icon: "globe", keywords: "community discord qq group contributors 社区 交流群 加群 贡献者", + run: () => onSettings("versions:community") }, { id: "settings", label: t("设置"), icon: "settings", keywords: "settings preferences 设置 偏好", run: () => onSettings() }, ], diff --git a/desktop/frontend-next/src/ui/communityLinks.ts b/desktop/frontend-next/src/ui/communityLinks.ts new file mode 100644 index 0000000000..0e6e958f6d --- /dev/null +++ b/desktop/frontend-next/src/ui/communityLinks.ts @@ -0,0 +1,21 @@ +const REPO = "https://github.com/esengine/DeepSeek-Reasonix"; + +export const COMMUNITY = { + qqName: "DeepSeek-Reasonix官方群", + qqNumber: "1093562660", + qqJoin: "https://qm.qq.com/q/i59b0z2R8s", + douyinName: "做游戏的小鱼", + douyinId: "22703872788", + author: "esengine", + authorUrl: "https://github.com/esengine", + discord: "https://discord.gg/XF78rEME2D", + issues: `${REPO}/issues`, + contributors: `${REPO}/graphs/contributors`, +} as const; + +// GitHub's own rule: alphanumerics and single hyphens, no hyphen at either end, 39 at most. +const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; + +export function profileUrl(login: string): string | null { + return LOGIN.test(login) ? `https://github.com/${login}` : null; +} diff --git a/desktop/frontend-next/src/ui/prefsnav.tsx b/desktop/frontend-next/src/ui/prefsnav.tsx index 7937fd7b89..684fc27f8d 100644 --- a/desktop/frontend-next/src/ui/prefsnav.tsx +++ b/desktop/frontend-next/src/ui/prefsnav.tsx @@ -241,6 +241,7 @@ export const SETTINGS: SettingEntry[] = [ // Shown only while signed in; restoring writes through the ordinary config paths and reloads the runtime. { section: "account", anchor: "backup", title: "云备份", scope: "account", apply: "immediate", keywords: ["备份", "恢复", "换机", "同步", "导出"] }, { section: "versions", anchor: "versions", title: "版本", scope: "machine", apply: "immediate", keywords: ["更新", "升级"] }, + { section: "versions", anchor: "community", title: "社区与贡献者", scope: "machine", apply: "none", keywords: ["社区", "交流群", "QQ", "加群", "二维码", "discord", "贡献者", "contributors", "community"] }, { section: "memory", anchor: "memory", title: "记忆", scope: "chosen", apply: "immediate", keywords: ["记住", "忘记", "事实"] }, { section: "usage", anchor: "usage", title: "用量与成本", scope: "machine", apply: "none", keywords: ["token", "花费", "缓存命中"] }, { section: "storage", anchor: "storage", title: "存储", scope: "machine", apply: "restart", keywords: ["搬家", "迁移", "磁盘", "位置"] }, diff --git a/desktop/frontend-next/tools/make-qq-qr.py b/desktop/frontend-next/tools/make-qq-qr.py new file mode 100644 index 0000000000..4b7d016c26 --- /dev/null +++ b/desktop/frontend-next/tools/make-qq-qr.py @@ -0,0 +1,8 @@ +import segno + +URL = "https://qm.qq.com/q/i59b0z2R8s" +OUT = "src/assets/qq-group-qr.svg" + +qr = segno.make(URL, error="m", boost_error=False, micro=False) +qr.save(OUT, scale=8, border=4, dark="#000000", light="#ffffff", xmldecl=False, nl=False) +print(qr.designator) diff --git a/scripts/update-contributors.mjs b/scripts/update-contributors.mjs new file mode 100644 index 0000000000..878373efb8 --- /dev/null +++ b/scripts/update-contributors.mjs @@ -0,0 +1,41 @@ +// Refreshes the contributor list the desktop app shows, as logins only: no +// avatars, so the app never fetches from GitHub to draw it. The repository owner +// is left out. Run at release time. + +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { defaultRepository, isBotAccount } from "./release-credits.mjs"; + +export const outputPath = fileURLToPath(new URL("../desktop/frontend-next/src/data/contributors.json", import.meta.url)); + +export function ownerOf(repository) { + return repository.split("/")[0]; +} + +export function loginsFrom(rows, owner = "") { + const skip = owner.toLowerCase(); + return rows + .filter((row) => row && typeof row.login === "string" && !isBotAccount(row.login, row.type) && row.login.toLowerCase() !== skip) + .sort((a, b) => (b.contributions ?? 0) - (a.contributions ?? 0) || a.login.localeCompare(b.login)) + .map((row) => row.login); +} + +export function render(logins) { + return `${JSON.stringify(logins, null, 2)}\n`; +} + +function fetchRows(repository) { + const out = execFileSync("gh", ["api", `repos/${repository}/contributors?per_page=100`, "--paginate", "--slurp"], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + return JSON.parse(out).flat(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const repository = process.argv[2] ?? defaultRepository; + const logins = loginsFrom(fetchRows(repository), ownerOf(repository)); + writeFileSync(outputPath, render(logins)); + console.log(`wrote ${logins.length} contributors to ${outputPath}`); +} diff --git a/scripts/update-contributors.test.mjs b/scripts/update-contributors.test.mjs new file mode 100644 index 0000000000..249b695cab --- /dev/null +++ b/scripts/update-contributors.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { loginsFrom, ownerOf, render } from "./update-contributors.mjs"; + +test("bots are dropped and the rest sorted by contributions, ties by login", () => { + const rows = [ + { login: "b", type: "User", contributions: 3 }, + { login: "dependabot[bot]", type: "Bot", contributions: 99 }, + { login: "a", type: "User", contributions: 3 }, + { login: "top", type: "User", contributions: 10 }, + { login: "github-actions", type: "User", contributions: 50 }, + ]; + assert.deepEqual(loginsFrom(rows), ["top", "a", "b"]); +}); + +test("the file is a stable JSON array ending in a newline", () => { + assert.equal(render(["a", "b"]), '[\n "a",\n "b"\n]\n'); +}); + +test("the repository owner is left out, whatever the case, and nobody else moves", () => { + const rows = [ + { login: "Owner", type: "User", contributions: 100 }, + { login: "b", type: "User", contributions: 3 }, + { login: "a", type: "User", contributions: 3 }, + ]; + assert.equal(ownerOf("esengine/DeepSeek-Reasonix"), "esengine"); + assert.deepEqual(loginsFrom(rows, "owner"), ["a", "b"]); + assert.deepEqual(loginsFrom(rows, "owner"), loginsFrom([...rows].reverse(), "owner")); + assert.deepEqual(loginsFrom(rows), ["Owner", "a", "b"]); +});