mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
3aef4d546c
* fix: avoid stalled maintainer author activity lookups * test: validate required author activity fixture entries
315 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|