Files
openclaw/test/scripts/github-activity-helper.test.ts
Peter Steinberger 3aef4d546c fix: avoid stalled maintainer author activity lookups (#130724)
* fix: avoid stalled maintainer author activity lookups

* test: validate required author activity fixture entries
2026-08-26 23:17:23 -07:00

315 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "./test-helpers.ts";
const repoRoot = path.resolve(import.meta.dirname, "../..");
const helperPath = path.join(
repoRoot,
".agents/skills/openclaw-pr-maintainer/scripts/github-activity.sh",
);
const { createTempDir } = createScriptTestHarness();
type Fixture = {
blockActivity?: boolean;
now?: string;
profile?: unknown;
search?: unknown[];
global?: unknown;
fail?: string[];
};
type Request = { route: string; args: string[]; output: string };
function requiredAt<T>(items: T[], index: number): T {
const item = items[index];
assert(item !== undefined, `Missing fixture item at index ${index}`);
return item;
}
function runHelper(args: string[], fixture: Fixture = {}) {
const dir = createTempDir("github-activity-helper-");
const binDir = path.join(dir, "bin");
const logPath = path.join(dir, "gh.log");
const outputPath = path.join(dir, "stdout");
const fixturePath = path.join(dir, "fixture.json");
mkdirSync(binDir);
writeFileSync(logPath, "");
writeFileSync(fixturePath, JSON.stringify(fixture));
const ghPath = path.join(binDir, "gh");
const bypassPath = path.join(binDir, "gh-plain");
const epoch = Date.parse(fixture.now ?? "2026-08-26T12:34:56Z") / 1000;
writeFileSync(
path.join(binDir, "date"),
`#!/bin/sh
if [ "$*" = "-u +%s" ]; then printf '%s\\n' '${epoch}'; else exec /bin/date "$@"; fi
`,
{ mode: 0o755 },
);
writeFileSync(
ghPath,
`#!/bin/bash
set -euo pipefail
jq -cn --arg route "\${0##*/}" --rawfile output "$OUTPUT" --args \
'{route: $route, args: $ARGS.positional, output: $output}' -- "$@" >> "$REQUEST_LOG"
if body=$(jq -cn --slurpfile fixtures "$FIXTURE" --slurpfile requests "$REQUEST_LOG" --args '
$fixtures[0] as $fixture | $ARGS.positional as $args |
if any($fixture.fail[]?; . as $needle | any($args[]; contains($needle))) then
null | halt_error(1)
elif $fixture.blockActivity and $args[0] == "api" and ($args[1] | startswith("users/") | not) then
null | halt_error(124)
elif $args[0] == "api" and ($args[1] | startswith("users/")) then
if $fixture | has("profile") then $fixture.profile else
{login: "Canonical", name: "Example Author", created_at: "2010-09-21T00:00:00Z", type: "User"}
end
elif $args[0] == "api" and ($args[1] | startswith("search/")) then
((([$requests[] | select(.args[1] | startswith("search/"))] | length) - 1) % 3) as $index |
if $fixture.search and $index < ($fixture.search | length) then $fixture.search[$index] else
{total_count: ([2345, 0, 9876][$index]), incomplete_results: false, items: []}
end
elif $args[0] == "api" and $args[1] == "graphql" then
if $fixture | has("global") then $fixture.global else
{data: {user: {contributionsCollection: {
totalCommitContributions: 8, totalIssueContributions: 1,
totalPullRequestContributions: 3, totalPullRequestReviewContributions: 2
}}}}
end
else null | halt_error(64) end
' -- "$@" 2>/dev/null); then
jq_filter=""
previous=""
for arg in "$@"; do
if [[ "$previous" == "--jq" ]]; then jq_filter="$arg"; fi
previous="$arg"
done
if [[ -n "$jq_filter" ]]; then
printf '%s\\n' "$body" | jq -cr "$jq_filter"
else
printf '%s\\n' "$body"
fi
else
response_code=$?
# Bound the blocked-scan reproduction without network or a surviving child process.
if [[ "$response_code" == 124 ]]; then sleep 0.5; fi
exit "$response_code"
fi
`,
{ mode: 0o755 },
);
writeFileSync(bypassPath, readFileSync(ghPath), { mode: 0o755 });
const output = openSync(outputPath, "w");
let result;
try {
result = spawnSync("bash", [helperPath, ...args], {
cwd: repoRoot,
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", output, "pipe"],
// Only CLI fixtures and command lookup cross this boundary; no live auth/config.
env: {
PATH: `${binDir}:${process.env.PATH ?? ""}`,
HOME: dir,
GH_TOKEN: "offline-fixture",
OPENCLAW_GH_BIN: bypassPath,
FIXTURE: fixturePath,
REQUEST_LOG: logPath,
OUTPUT: outputPath,
},
});
} finally {
closeSync(output);
}
return {
result,
stdout: readFileSync(outputPath, "utf8"),
requests: readFileSync(logPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as Request),
};
}
describe("openclaw-pr-maintainer github activity helper", () => {
it("prints canonical identity before the first activity request, even when a scan blocks", () => {
const { requests } = runHelper(["alias"], { blockActivity: true });
expect(requiredAt(requests, 0).args[1]).toBe("users/alias");
expect(requiredAt(requests, 1).output).toContain(
"Example Author (@Canonical, User, account created 2010-09-21",
);
});
it("uses three single-page search aggregates regardless of row count", () => {
const { result, stdout, requests } = runHelper(["alias"]);
expect(result.status).toBe(0);
expect(stdout).toContain("2345 PRs, 0 issues, 9876 commits");
expect(requests).toHaveLength(4);
expect(requests.every((request) => request.route === "gh")).toBe(true);
const searches = requests.slice(1).map(({ args }) => {
expect(args[0]).toBe("api");
expect(args).not.toContain("--paginate");
expect(args).not.toContain("-f");
expect(args).not.toContain("-F");
const url = new URL(requiredAt(args, 1), "https://api.github.test/");
expect(url.searchParams.get("per_page")).toBe("1");
expect(url.searchParams.get("q")).toContain("repo:openclaw/openclaw author:Canonical");
return url;
});
expect(searches.map((url) => url.pathname)).toEqual([
"/search/issues",
"/search/issues",
"/search/commits",
]);
expect(requiredAt(searches, 0).searchParams.get("q")).toContain("is:pr");
expect(requiredAt(searches, 1).searchParams.get("q")).toContain("is:issue");
expect(requiredAt(searches, 2).searchParams.get("q")).toContain("committer-date:");
expect(stdout).toContain("index/cache may lag");
});
it("distinguishes incomplete, missing, and failed searches from a valid zero", () => {
const { result, stdout, requests } = runHelper(["alias"], {
search: [{ total_count: 7, incomplete_results: true }, {}, {}],
fail: ["search/commits"],
});
expect(result.status).toBe(1);
expect(requests).toHaveLength(4);
expect(stdout).toContain("incomplete (reported 7) PRs");
expect(stdout).toContain("unavailable (invalid response) issues");
expect(stdout).toContain("unavailable (request failed) commits");
expect(stdout).not.toContain("0 commits");
});
it.each([
null,
{ total_count: -1, incomplete_results: false },
{ total_count: 0 },
{ total_count: "0", incomplete_results: false },
])("rejects malformed aggregate %j", (bad) => {
const { result, stdout } = runHelper(["alias"], { search: [bad, bad, bad] });
expect(result.status).toBe(1);
expect(stdout).toContain("unavailable (invalid response)");
});
it("keeps a longer repo window separate from the capped global window", () => {
const { result, stdout, requests } = runHelper(["--months", "24", "--global", "alias"]);
expect(result.status).toBe(0);
expect(requests).toHaveLength(5);
expect(requests.map(({ args }) => args[args.indexOf("--cache") + 1])).toEqual(
Array(5).fill("1h"),
);
const repoArgs = requiredAt(requests, 1).args;
const repoQuery =
new URL(requiredAt(repoArgs, 1), "https://api.github.test").searchParams.get("q") ?? "";
const repoRange = /created:([^ ]+)\.\.([^ ]+)/.exec(repoQuery);
expect(repoRange).not.toBeNull();
const graphql = requiredAt(requests, 4).args;
const from = graphql.find((arg) => arg.startsWith("from="))?.slice(5) ?? "";
const to = graphql.find((arg) => arg.startsWith("to="))?.slice(3) ?? "";
expect(repoRange?.slice(1)).toEqual(["2024-08-26T00:00:00Z", "2026-08-25T23:59:59Z"]);
expect(from).toBe("2025-08-26T00:00:00Z");
expect(to).toBe("2026-08-26T00:00:00Z");
expect(Date.parse(to) - Date.parse(from)).toBeLessThanOrEqual(366 * 86400000);
expect(stdout).toContain("GitHub contributions last 12mo");
expect(stdout).toContain("requested 24mo");
expect(stdout).toContain("8 commits, 3 PRs, 1 issues, 2 reviews");
expect(stdout).not.toContain("GitHub public");
});
it.each([
{ data: { user: null } },
{
data: {
user: {
contributionsCollection: {
totalCommitContributions: "0",
totalIssueContributions: 0,
totalPullRequestContributions: 0,
totalPullRequestReviewContributions: 0,
},
},
},
},
{ data: { user: { contributionsCollection: {} } } },
{ errors: [{ message: "unavailable" }], data: { user: null } },
])("does not turn unavailable contributions into zero: %j", (global) => {
const { result, stdout, requests } = runHelper(["--global", "alias"], { global });
expect(result.status).toBe(1);
expect(requests).toHaveLength(5);
expect(stdout).toMatch(/GitHub contributions.*unavailable/);
expect(stdout).not.toContain("0 reviews");
});
it("continues other logins after profile failure without activity for the unknown identity", () => {
const { result, stdout, requests } = runHelper(["missing", "alias"], {
fail: ["users/missing"],
});
expect(result.status).toBe(1);
expect(stdout).toContain("@missing (profile unavailable; account age unknown)");
expect(stdout).toContain("Example Author (@Canonical");
expect(requests).toHaveLength(5);
expect(requiredAt(requests, 1).args[1]).toBe("users/alias");
});
it("continues later logins after a failed global request without falling back to scans", () => {
const { result, stdout, requests } = runHelper(["--global", "first", "second"], {
fail: ["graphql"],
});
expect(result.status).toBe(1);
expect(requests).toHaveLength(10);
expect(stdout.match(/unavailable \(request failed\)/g)).toHaveLength(2);
expect(requiredAt(requests, 5).args[1]).toBe("users/second");
});
it("keeps valid contribution zeroes without inferring inactivity or scanning other endpoints", () => {
const { result, stdout, requests } = runHelper(["--global", "alias"], {
global: {
data: {
user: {
contributionsCollection: {
totalCommitContributions: 0,
totalIssueContributions: 0,
totalPullRequestContributions: 0,
totalPullRequestReviewContributions: 0,
},
},
},
},
});
expect(result.status).toBe(0);
expect(requests).toHaveLength(5);
expect(stdout).toContain("0 commits, 0 PRs, 0 issues, 0 reviews");
expect(stdout).toContain("Zero does not prove inactivity");
});
it.each([null, {}, { login: null }])("skips activity for malformed identity %j", (profile) => {
const { result, stdout, requests } = runHelper(["alias"], { profile });
expect(result.status).toBe(1);
expect(requests).toHaveLength(1);
expect(stdout).toContain("account age unknown");
});
it("preserves a nameless bot profile with an unknown creation date", () => {
const { result, stdout } = runHelper(["alias"], {
profile: { login: "helper[bot]", name: null, created_at: "invalid", type: "Bot" },
});
expect(result.status).toBe(0);
expect(stdout).toContain("@helper[bot] (Bot, account age unknown)");
});
it.each([
["2024-03-31T15:00:00Z", "1", "2024-02-29T00:00:00Z"],
["2024-02-29T15:00:00Z", "12", "2023-02-28T00:00:00Z"],
["2025-03-31T15:00:00Z", "1", "2025-02-28T00:00:00Z"],
])("bounds calendar subtraction at month ends: %s", (now, months, from) => {
const { result, requests } = runHelper(["--months", months, "--global", "alias"], { now });
expect(result.status).toBe(0);
const repoArgs = requiredAt(requests, 1).args;
const query = new URL(requiredAt(repoArgs, 1), "https://api.github.test").searchParams.get("q");
expect(query).toContain(`created:${from}..`);
const graphql = requiredAt(requests, 4).args;
expect(graphql).toContain(`from=${from}`);
const to = graphql.find((arg) => arg.startsWith("to="))?.slice(3) ?? "";
expect(Date.parse(to) - Date.parse(from)).toBeLessThanOrEqual(366 * 86400000);
});
});