fix(scripts): bound clawtributor GitHub lookups (#109968)

This commit is contained in:
Alix-007
2026-07-18 16:31:47 +08:00
committed by GitHub
parent 30a938772a
commit 10902e9035
2 changed files with 148 additions and 23 deletions

View File

@@ -1,9 +1,10 @@
// Update Clawtributors script supports OpenClaw repository automation.
import { execFileSync, execSync } from "node:child_process";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import pMap, { pMapSkip } from "p-map";
import { expectDefined } from "../packages/normalization-core/src/expect.js";
import { execPlainGh } from "./lib/plain-gh.mjs";
import type { ApiContributor, Entry, MapConfig, User } from "./update-clawtributors.types.js";
const REPO = "openclaw/openclaw";
@@ -12,6 +13,9 @@ const AVATAR_PROBE_SIZE = 40;
const AVATAR_PROBE_MAX_BYTES = 256 * 1024;
const AVATAR_PROBE_TIMEOUT_MS = 8000;
const AVATAR_SIZE = 48;
// The 5,000-PR history query can take about a minute; preserve healthy pagination
// headroom while bounding a stalled GitHub CLI process.
const GH_COMMAND_TIMEOUT_MS = 120_000;
const CLAWTRIBUTORS_START = "<!-- clawtributors:start -->";
const CLAWTRIBUTORS_END = "<!-- clawtributors:end -->";
const CLAWTRIBUTORS_HIDDEN_START = "<!-- clawtributors:hidden:start";
@@ -30,7 +34,7 @@ const seedCommit = mapConfig.seedCommit ?? null;
const seedEntries = seedCommit ? parseReadmeEntries(run(`git show ${seedCommit}:README.md`)) : [];
const currentReadme = readFileSync(readmePath, "utf8");
const hiddenReadmeLogins = new Set(parseHiddenReadmeLogins(currentReadme));
const raw = run(`gh api "repos/${REPO}/contributors?per_page=100&anon=1" --paginate`);
const raw = runGh(["api", `repos/${REPO}/contributors?per_page=100&anon=1`, "--paginate"]);
const contributors = parsePaginatedJson(raw) as ApiContributor[];
const apiByLogin = new Map<string, User>();
const contributionsByLogin = new Map<string, number>();
@@ -129,9 +133,20 @@ for (const login of ensureLogins) {
}
const prsByLogin = new Map<string, number>();
const prRaw = run(
`gh pr list -R ${REPO} --state merged --limit 5000 --json author --jq '.[].author.login'`,
);
const prRaw = runGh([
"pr",
"list",
"-R",
REPO,
"--state",
"merged",
"--limit",
"5000",
"--json",
"author",
"--jq",
".[].author.login",
]);
for (const login of prRaw.split("\n")) {
const trimmed = login.trim().toLowerCase();
if (!trimmed) {
@@ -349,6 +364,16 @@ function run(cmd: string): string {
}).trim();
}
function runGh(args: string[]): string {
return execPlainGh(args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 1024 * 1024 * 200,
timeout: GH_COMMAND_TIMEOUT_MS,
killSignal: "SIGKILL",
}).trim();
}
function parsePaginatedJson(rawLocal: string): unknown[] {
const items: unknown[] = [];
for (const line of rawLocal.split("\n")) {
@@ -423,10 +448,7 @@ function fetchUser(login: string): User | null {
return null;
}
try {
const data = execFileSync("gh", ["api", `users/${normalized}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const data = runGh(["api", `users/${normalized}`]);
const parsed = JSON.parse(data);
if (!parsed?.login || !parsed?.html_url || !parsed?.avatar_url) {
return null;

View File

@@ -1,19 +1,30 @@
// Update Clawtributors tests cover update clawtributors script behavior.
import { execFileSync as realExecFileSync } from "node:child_process";
import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
const originalCwd = process.cwd();
type GhRunner = (args: readonly string[], options: ExecFileSyncOptionsWithStringEncoding) => string;
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.doUnmock("node:fs");
vi.doUnmock("node:child_process");
vi.doUnmock("../../scripts/lib/plain-gh.mjs");
vi.resetModules();
});
function mockClawtributorsFixture() {
function mockClawtributorsFixture({
ensureLogins = [],
runGh,
}: {
ensureLogins?: string[];
runGh?: GhRunner;
} = {}) {
const readme = [
"# Fixture",
"",
@@ -27,7 +38,7 @@ function mockClawtributorsFixture() {
vi.doMock("node:fs", () => ({
readFileSync: vi.fn((path: string) => {
if (path.endsWith("scripts/clawtributors-map.json")) {
return "{}\n";
return `${JSON.stringify({ ensureLogins })}\n`;
}
if (path.endsWith("README.md")) {
return readme;
@@ -50,18 +61,9 @@ function mockClawtributorsFixture() {
contributions: 3,
};
const execSync = vi.fn((cmd: string) => {
if (cmd === 'gh api "repos/openclaw/openclaw/contributors?per_page=100&anon=1" --paginate') {
return `${JSON.stringify([contributor])}\n`;
}
if (cmd === "git log --reverse --format=%aN%x1f%aE%x1f%aI --numstat") {
return "";
}
if (
cmd ===
"gh pr list -R openclaw/openclaw --state merged --limit 5000 --json author --jq '.[].author.login'"
) {
return "";
}
if (cmd === "git rev-list --max-parents=0 HEAD") {
return "root-sha\n";
}
@@ -70,13 +72,53 @@ function mockClawtributorsFixture() {
}
throw new Error(`unexpected command: ${cmd}`);
});
const execPlainGh = vi.fn(
(args: readonly string[], options: ExecFileSyncOptionsWithStringEncoding) => {
if (runGh) {
return runGh(args, options);
}
if (
args.join("\0") ===
["api", "repos/openclaw/openclaw/contributors?per_page=100&anon=1", "--paginate"].join("\0")
) {
return `${JSON.stringify([contributor])}\n`;
}
if (
args.join("\0") ===
[
"pr",
"list",
"-R",
"openclaw/openclaw",
"--state",
"merged",
"--limit",
"5000",
"--json",
"author",
"--jq",
".[].author.login",
].join("\0")
) {
return "";
}
if (args[0] === "api" && args[1]?.startsWith("users/")) {
const login = args[1].slice("users/".length);
return JSON.stringify({
login,
html_url: `https://github.com/${login}`,
avatar_url: "https://avatars.githubusercontent.com/u/2?v=4",
});
}
throw new Error(`unexpected gh arguments: ${args.join(" ")}`);
},
);
vi.doMock("../../scripts/lib/plain-gh.mjs", () => ({ execPlainGh }));
vi.doMock("node:child_process", () => ({
execFileSync: vi.fn(() => {
throw new Error("unexpected execFileSync");
}),
execSync,
}));
return {
execPlainGh,
readWrittenReadme: () => writtenReadme,
};
}
@@ -87,6 +129,67 @@ async function importUpdateClawtributors() {
}
describe("update-clawtributors", () => {
it("bounds every GitHub CLI lookup", async () => {
const fixture = mockClawtributorsFixture({ ensureLogins: ["extra"] });
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 404 })),
);
vi.spyOn(console, "log").mockImplementation(() => undefined);
await importUpdateClawtributors();
const options = {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 1024 * 1024 * 200,
timeout: 120_000,
killSignal: "SIGKILL",
};
expect(fixture.execPlainGh).toHaveBeenNthCalledWith(
1,
["api", "repos/openclaw/openclaw/contributors?per_page=100&anon=1", "--paginate"],
options,
);
expect(fixture.execPlainGh).toHaveBeenNthCalledWith(2, ["api", "users/extra"], options);
expect(fixture.execPlainGh).toHaveBeenNthCalledWith(
3,
[
"pr",
"list",
"-R",
"openclaw/openclaw",
"--state",
"merged",
"--limit",
"5000",
"--json",
"author",
"--jq",
".[].author.login",
],
options,
);
});
it("kills a sleeping GitHub CLI process at the deadline", async () => {
const fixture = mockClawtributorsFixture({
runGh: (_args, options) => {
expect(options).toMatchObject({ timeout: 120_000, killSignal: "SIGKILL" });
return realExecFileSync(process.execPath, ["-e", "setInterval(() => {}, 1_000)"], {
...options,
timeout: 50,
});
},
});
await expect(importUpdateClawtributors()).rejects.toMatchObject({
code: "ETIMEDOUT",
signal: "SIGKILL",
});
expect(fixture.execPlainGh).toHaveBeenCalledTimes(1);
});
it("rejects unsafe avatar probe content lengths before reading the body", async () => {
const fixture = mockClawtributorsFixture();
const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));