mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 16:31:42 +00:00
fix(scripts): bound clawtributor GitHub lookups (#109968)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user