mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
* fix(update): preserve replaced clone directories * fix(update): verify linked Git runtime before rollback * test(update): retain linked runtime fixture tuple types * fix: preserve the invoked launcher when restarting Node * test: preserve real cancellation coverage during main integration * fix(config): import finite-number validator from value-tree * fix: remove unused config mutation validator import * test: join cleanup anchor retirement before teardown assertions * test(node): join retirement close within runner lifetime * test(node): make prepared workspace fixture owner private * fix: distinguish imported CLI entries during cache respawn * test(pr): preserve private-store binding in native provisioning fixtures Compose the no-config preload with the private handoff preload, witness its installation, and verify every cold helper including failure paths. The exact two CI failures reproduce before and pass after this repair; all native Git, hook, lock-loss, and APFS regressions remain. Co-authored-by: Dallin Romney <6581799+RomneyDa@users.noreply.github.com> --------- Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: Dallin Romney <6581799+RomneyDa@users.noreply.github.com>
1430 lines
54 KiB
TypeScript
1430 lines
54 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
const head = "a".repeat(40);
|
|
const base = "c".repeat(40);
|
|
|
|
function graphqlResponse(repository: unknown) {
|
|
return { data: { repository } };
|
|
}
|
|
|
|
function connectionPage(nodes: unknown[], hasNextPage: boolean) {
|
|
return {
|
|
nodes,
|
|
totalCount: 2,
|
|
pageInfo: { hasNextPage, endCursor: hasNextPage ? "next" : null },
|
|
};
|
|
}
|
|
|
|
type Fixture = {
|
|
changedFiles?: number | null;
|
|
files?: unknown;
|
|
initialPatch?: Record<string, unknown>;
|
|
finalPatch?: Record<string, unknown>;
|
|
failure?: "empty" | "exit" | "non-json" | "null" | "quota" | "forbidden";
|
|
failureCount?: number;
|
|
failureTarget?:
|
|
| "pull"
|
|
| "reread"
|
|
| "files"
|
|
| "user"
|
|
| "permission"
|
|
| "browse"
|
|
| "checks"
|
|
| "repository";
|
|
cacheUntilRevalidated?: boolean;
|
|
notify?: boolean;
|
|
ghRepo?: string;
|
|
ghHost?: string;
|
|
configuredHost?: string;
|
|
defaultRepoURL?: string;
|
|
probeGit?: boolean;
|
|
protectedGh?: boolean;
|
|
cleanupFailure?: boolean;
|
|
authorSources?: unknown;
|
|
authorPages?: unknown[];
|
|
coreQuotaAt?: string[];
|
|
graphqlResponses?: unknown[];
|
|
graphqlQuota?: boolean;
|
|
};
|
|
|
|
function readPrMetadata(
|
|
fixture: Fixture = {},
|
|
command = "pr_meta_json 42",
|
|
parentEnv: NodeJS.ProcessEnv = process.env,
|
|
) {
|
|
const dir = tempDirs.make("openclaw-pr-metadata-");
|
|
const gh = join(dir, "gh");
|
|
const trace = join(dir, "trace");
|
|
const selectedGit = join(dir, "selected-git");
|
|
const cleanupPreload = join(dir, "fail-adapter-cleanup.cjs");
|
|
if (fixture.cleanupFailure) {
|
|
writeFileSync(
|
|
cleanupPreload,
|
|
`const fs = require("node:fs");
|
|
const remove = fs.rmSync;
|
|
fs.rmSync = (path, options) => {
|
|
if (String(path).includes("openclaw-pr-gh-git-")) {
|
|
const error = new Error("Synthetic adapter cleanup failure");
|
|
error.code = "EACCES";
|
|
throw error;
|
|
}
|
|
return remove(path, options);
|
|
};
|
|
require("node:module").syncBuiltinESMExports();
|
|
`,
|
|
);
|
|
}
|
|
if (fixture.probeGit) {
|
|
writeFileSync(
|
|
selectedGit,
|
|
"#!/bin/sh\ncase \"$*\" in\n --version) printf 'selected fixture Git\\n' ;;\n *) exit 79 ;;\nesac\n",
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(join(dir, "git"), "#!/bin/sh\necho 'poisoned PATH Git' >&2\nexit 79\n", {
|
|
mode: 0o755,
|
|
});
|
|
}
|
|
writeFileSync(trace, "");
|
|
writeFileSync(join(dir, "count"), "0");
|
|
writeFileSync(join(dir, "graphql-count"), "0");
|
|
writeFileSync(join(dir, "graphql-inputs"), "");
|
|
writeFileSync(join(dir, "sleeps"), "");
|
|
writeFileSync(join(dir, "notify"), "");
|
|
writeFileSync(
|
|
gh,
|
|
`#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const args = process.argv.slice(2);
|
|
const root = __dirname;
|
|
const fixture = JSON.parse(process.env.FAKE_GH_FIXTURE);
|
|
fs.appendFileSync(path.join(root, "trace"), JSON.stringify(args) + "\\n");
|
|
const out = (value) => process.stdout.write(JSON.stringify(value) + "\\n");
|
|
const defaultHost = process.env.GH_HOST || fixture.configuredHost || "github.com";
|
|
const qualifyRepository = (repository) => {
|
|
repository = repository.replace(/\\.git$/, "");
|
|
return repository.startsWith("https://") ? repository
|
|
: "https://" + (repository.split("/").length === 3 ? repository : defaultHost + "/" + repository);
|
|
};
|
|
const operation = args[0] === "browse" ? "browse" : args.find((arg) => arg.startsWith("repos/") || arg === "user");
|
|
if (fixture.coreQuotaAt?.includes(operation) && (operation !== "browse" || args.includes("--no-browser"))) {
|
|
if (operation === "browse") {
|
|
console.error("HTTP 403: Forbidden (https://api.github.com/repos/base-owner/base-repo)");
|
|
process.exit(1);
|
|
}
|
|
console.log('HTTP/2 403 Forbidden\\nX-RateLimit-Resource: core\\nX-RateLimit-Remaining: 0\\n\\n'+JSON.stringify({message:"API rate limit exceeded"}));
|
|
console.error("gh: API rate limit exceeded");
|
|
process.exit(1);
|
|
}
|
|
if (args[0] === "pr" && args[1] === "view") {
|
|
throw new Error("Top-level pr view can spend REST quota again; use the GraphQL endpoint");
|
|
}
|
|
if (args[0] === "api" && args.includes("graphql")) {
|
|
if (args.includes("--input")) fs.appendFileSync(path.join(root,"graphql-inputs"),JSON.stringify(JSON.parse(fs.readFileSync(0,"utf8")))+"\\n");
|
|
if (fixture.graphqlQuota) {
|
|
console.error("gh: API rate limit exceeded");
|
|
process.exit(1);
|
|
}
|
|
const count = Number(fs.readFileSync(path.join(root,"graphql-count"),"utf8"));
|
|
fs.writeFileSync(path.join(root,"graphql-count"),String(count+1));
|
|
if (!fixture.graphqlResponses || count >= fixture.graphqlResponses.length) throw new Error("Unexpected GraphQL request");
|
|
if (args.includes("--include")) process.stdout.write("HTTP/2 200 OK\\n\\n");
|
|
let response = fixture.graphqlResponses[count];
|
|
if (fixture.cacheUntilRevalidated && !args.includes("Cache-Control: max-age=0") && response.data?.repository?.pullRequest?.headRefOid) {
|
|
response = fixture.graphqlResponses[0];
|
|
}
|
|
out(response);
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === "browse") {
|
|
if (!args.includes("--no-browser") && !process.env.GH_BROWSER) {
|
|
throw new Error("Repository discovery must not open the configured browser");
|
|
}
|
|
if (args.includes("--no-browser") && fixture.failure === "quota" && fixture.failureTarget === "browse") {
|
|
console.error("HTTP 403: API rate limit exceeded");
|
|
process.exit(1);
|
|
}
|
|
if (fixture.probeGit) {
|
|
const git = require("node:child_process").execFileSync("git", ["--version"], {encoding:"utf8"});
|
|
if (git.trim() !== "selected fixture Git") throw new Error("Wrong Git reached default repository resolver");
|
|
}
|
|
const repoFlag = args.indexOf("--repo");
|
|
const repository = repoFlag >= 0 ? args[repoFlag + 1]
|
|
: process.env.GH_REPO || fixture.defaultRepoURL || "base-owner/base-repo";
|
|
console.log(qualifyRepository(repository));
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === "pr" && args[1] === "edit") {
|
|
const repoFlag = args.indexOf("--repo");
|
|
const repo = repoFlag >= 0 ? args[repoFlag + 1] : args.find((arg) => arg.startsWith("--repo="))?.slice(7);
|
|
if (!repo || qualifyRepository(repo) !== qualifyRepository("base-owner/base-repo")) throw new Error("Writer targeted a different repository");
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === "pr" && args[1] === "checks") {
|
|
if (fixture.failure === "quota" && fixture.failureTarget === "checks") {
|
|
console.error("HTTP 403: API rate limit exceeded");
|
|
process.exit(1);
|
|
}
|
|
out([{name:"RATE_LIMIT",bucket:"pending",state:"PENDING"}]);
|
|
process.exit(8);
|
|
}
|
|
const endpoint = args.find((arg) => arg.startsWith("repos/") || ["user", "rate_limit"].includes(arg));
|
|
if (args[0] !== "api" || !endpoint) throw new Error("Only explicit REST endpoints are supported");
|
|
const hostFlag = args.indexOf("--hostname");
|
|
const apiHost = hostFlag >= 0 ? args[hostFlag + 1] : defaultHost;
|
|
const repoURL = "https://" + apiHost.toLowerCase() + "/base-owner/base-repo";
|
|
if (fixture.notify) fs.writeSync(3, endpoint + "\\n");
|
|
if (endpoint.startsWith("repos/base-owner/base-repo/commits?")) {
|
|
const count = Number(fs.readFileSync(path.join(root, "count"), "utf8"));
|
|
fs.writeFileSync(path.join(root, "count"), String(count + 1));
|
|
if (!fixture.authorPages || count >= fixture.authorPages.length) throw new Error("Unexpected author request");
|
|
out(fixture.authorPages[count]);
|
|
process.exit(0);
|
|
}
|
|
if (endpoint === "rate_limit") {
|
|
out({resources:{graphql:{remaining:0,limit:5000,reset:1800000000},core:{remaining:4900,limit:5000,reset:1800000300}}});
|
|
process.exit(0);
|
|
}
|
|
const isPull = endpoint === "repos/base-owner/base-repo/pulls/42";
|
|
let count = Number(fs.readFileSync(path.join(root,"count"),"utf8"));
|
|
if (isPull) fs.writeFileSync(path.join(root,"count"), String(++count));
|
|
const failureTarget = fixture.failureTarget || "pull";
|
|
const fail = failureTarget === "pull" ? isPull : failureTarget === "reread" ? isPull && count > 1 : failureTarget === "user" ? endpoint === "user" : failureTarget === "permission" ? endpoint.includes("/collaborators/") : failureTarget === "repository" ? endpoint === "repos/base-owner/base-repo" : endpoint.includes("/files?");
|
|
if (fixture.failure && fail && (fixture.failureCount === undefined || count <= fixture.failureCount)) {
|
|
if (fixture.failure === "forbidden") {
|
|
console.error("HTTP 403: Resource not accessible by integration; secret-response-must-not-escape");
|
|
process.exit(1);
|
|
}
|
|
if (fixture.failure === "quota") {
|
|
console.error("HTTP 403: API rate limit exceeded; secret-response-must-not-escape");
|
|
process.exit(1);
|
|
}
|
|
console.error("HTTP 503: No server is currently available");
|
|
if (fixture.failure === "exit") {
|
|
if (failureTarget === "files") out([[{filename:"src/partial.ts",status:"modified",additions:1,deletions:0}]]);
|
|
process.exit(7);
|
|
}
|
|
if (fixture.failure === "non-json") process.stdout.write("unavailable\\n");
|
|
if (fixture.failure === "null") out(null);
|
|
process.exit(0);
|
|
}
|
|
if (endpoint === "user") {
|
|
process.stdout.write('HTTP/2.0 200 OK\\n\\n');
|
|
out({login:"contributor"});
|
|
} else if (endpoint === "repos/base-owner/base-repo") {
|
|
out({id:1,full_name:"base-owner/base-repo",html_url:repoURL,node_id:"R_base"});
|
|
} else if (isPull) {
|
|
const record = {number:42,html_url:repoURL+"/pull/42",state:"open",draft:false,
|
|
base:{sha:"${base}",ref:"main",repo:{id:1,node_id:"R_base",full_name:"base-owner/base-repo",html_url:repoURL}},
|
|
head:{sha:"${head}",ref:"topic",repo:{id:2,name:"fork-repo",full_name:"fork-owner/fork-repo",html_url:"https://"+apiHost+"/fork-owner/fork-repo",owner:{login:"fork-owner"}}},
|
|
user:{login:"contributor"},changed_files:fixture.changedFiles === undefined ? 101 : fixture.changedFiles};
|
|
const stale = fixture.cacheUntilRevalidated && !args.includes("Cache-Control: max-age=0");
|
|
const patch = (count === 1 || stale ? fixture.initialPatch : fixture.finalPatch) || {};
|
|
out({...record,...patch,...(patch.base ? {base:{...record.base,...patch.base}} : {})});
|
|
} else if (endpoint.includes("/files?")) {
|
|
if (!args.includes("--paginate") || !args.includes("--slurp")) throw new Error("Files must be paginated");
|
|
const count = fixture.changedFiles === undefined ? 101 : fixture.changedFiles || 0;
|
|
const files = fixture.files === undefined ? Array.from({length:count},(_,i)=>({filename:"src/file-"+i+".ts",additions:1,deletions:0,status:i===count-1?"removed":"modified"})) : fixture.files;
|
|
out(Array.isArray(files) ? [files.slice(0,100), ...(files.length > 100 ? [files.slice(100)] : [])] : [files]);
|
|
} else throw new Error("Unexpected endpoint " + endpoint);
|
|
`,
|
|
);
|
|
chmodSync(gh, 0o755);
|
|
const selectedGh = join(dir, "selected-gh");
|
|
if (fixture.protectedGh) {
|
|
copyFileSync(gh, selectedGh);
|
|
chmodSync(selectedGh, 0o755);
|
|
writeFileSync(gh, "#!/bin/sh\necho 'Unexpected PATH gh route' >&2\nexit 79\n");
|
|
}
|
|
const result = spawnSync(
|
|
"/bin/bash",
|
|
[
|
|
"-c",
|
|
[
|
|
"set -euo pipefail",
|
|
"source scripts/lib/plain-gh.sh",
|
|
"source scripts/pr-lib/worktree.sh",
|
|
"source scripts/pr-lib/common.sh",
|
|
`sleep() { printf '%s\\n' "$*" >> '${join(dir, "sleeps")}'; }`,
|
|
command,
|
|
].join("; "),
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...parentEnv,
|
|
// This unsupervised child owns neither the parent's snapshot nor its FD3.
|
|
OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: undefined,
|
|
OPENCLAW_PR_LOCK_NOTIFY_FD: undefined,
|
|
FAKE_GH_FIXTURE: JSON.stringify(fixture),
|
|
PR_GH_WRITER_LOGIN: "untrusted-inherited-login",
|
|
PR_GH_WRITER_CONTEXT: "untrusted-inherited-context",
|
|
FAKE_GH_NOTIFY: join(dir, "notify"),
|
|
GH_REPO: fixture.ghRepo ?? "base-owner/base-repo",
|
|
GH_HOST: fixture.ghHost,
|
|
...(fixture.probeGit ? { OPENCLAW_PR_GIT: selectedGit } : {}),
|
|
OPENCLAW_GH_BIN: fixture.protectedGh ? selectedGh : "",
|
|
...(fixture.protectedGh ? { GH_TOKEN: "synthetic-writer-token" } : {}),
|
|
...(fixture.cleanupFailure
|
|
? {
|
|
TMPDIR: dir,
|
|
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --require ${JSON.stringify(cleanupPreload)}`,
|
|
}
|
|
: {}),
|
|
PATH: `${dir}:${process.env.PATH}`,
|
|
},
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
},
|
|
);
|
|
return {
|
|
...result,
|
|
attempts: Number(readFileSync(join(dir, "count"), "utf8")),
|
|
notifications: readFileSync(join(dir, "notify"), "utf8"),
|
|
calls: readFileSync(trace, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as string[]),
|
|
graphqlInputs: readFileSync(join(dir, "graphql-inputs"), "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as { query: string; variables: Record<string, unknown> }),
|
|
delays: readFileSync(join(dir, "sleeps"), "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map(Number),
|
|
};
|
|
}
|
|
|
|
describe("PR metadata through REST", () => {
|
|
it("reads real metadata with an unrelated inherited snapshot and closed notify FD", () => {
|
|
const result = readPrMetadata({}, "pr_meta_json 42", {
|
|
...process.env,
|
|
OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: tempDirs.make("unrelated-metadata-snapshot-"),
|
|
OPENCLAW_PR_LOCK_NOTIFY_FD: "3",
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({ number: 42, headRefOid: head });
|
|
expect(
|
|
result.calls.filter((args) => args.includes("repos/base-owner/base-repo/pulls/42")),
|
|
).toHaveLength(2);
|
|
expect(result.notifications).toBe("");
|
|
});
|
|
|
|
describe("core quota fallback", () => {
|
|
const repository = {
|
|
id: "R_base",
|
|
databaseId: 1,
|
|
nameWithOwner: "base-owner/base-repo",
|
|
url: "https://github.com/base-owner/base-repo",
|
|
};
|
|
|
|
it.each(["observation", "repository only"])(
|
|
"carries authoritative repository identity with one GraphQL PR read (%s)",
|
|
(selection) => {
|
|
const pullRequest = {
|
|
id: "PR_42",
|
|
number: 42,
|
|
url: `${repository.url}/pull/42`,
|
|
title: "Fixture",
|
|
state: "OPEN",
|
|
isDraft: false,
|
|
author: { login: "contributor", __typename: "User" },
|
|
baseRefName: "main",
|
|
baseRefOid: base,
|
|
headRefName: "topic",
|
|
headRefOid: head,
|
|
headRepository: {
|
|
name: "fork-repo",
|
|
nameWithOwner: "fork-owner/fork-repo",
|
|
url: "https://github.com/fork-owner/fork-repo",
|
|
},
|
|
headRepositoryOwner: { login: "fork-owner", __typename: "User" },
|
|
isCrossRepository: true,
|
|
};
|
|
const result = readPrMetadata(
|
|
{
|
|
ghRepo: "https://GITHUB.COM/Base-Owner/Base-Repo",
|
|
coreQuotaAt: ["repos/Base-Owner/Base-Repo/pulls/42"],
|
|
graphqlResponses: [{ data: { repository: { ...repository, pullRequest } } }],
|
|
},
|
|
selection === "observation"
|
|
? 'pr_observe 42; printf "%s\\n" "$PR_OBSERVATION"'
|
|
: "pr_gh pr view 42 --json baseRepository",
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({ baseRepository: repository });
|
|
if (selection === "observation") {
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
number: 42,
|
|
headRefOid: head,
|
|
headRefName: "topic",
|
|
headRepository: pullRequest.headRepository,
|
|
});
|
|
}
|
|
expect(result.calls).toEqual([
|
|
[
|
|
"api",
|
|
"--hostname",
|
|
"GITHUB.COM",
|
|
"repos/Base-Owner/Base-Repo/pulls/42",
|
|
"-H",
|
|
"Cache-Control: max-age=0",
|
|
],
|
|
[
|
|
"api",
|
|
"--hostname",
|
|
"GITHUB.COM",
|
|
"graphql",
|
|
"--input",
|
|
"-",
|
|
"-H",
|
|
"Cache-Control: max-age=0",
|
|
],
|
|
]);
|
|
expect(result.graphqlInputs).toHaveLength(1);
|
|
expect(result.graphqlInputs[0]?.query).toContain(
|
|
"repository(owner:$owner,name:$name){id databaseId nameWithOwner url pullRequest(number:$number){",
|
|
);
|
|
expect(result.graphqlInputs[0]?.variables).toEqual({
|
|
owner: "Base-Owner",
|
|
name: "Base-Repo",
|
|
number: 42,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ nameWithOwner: "other/repo" },
|
|
{ url: "https://other.invalid/base-owner/base-repo" },
|
|
{ id: null },
|
|
{ databaseId: 0 },
|
|
{ pullRequest: null },
|
|
])("rejects invalid carried repository or PR authority %j", (patch) => {
|
|
const result = readPrMetadata(
|
|
{
|
|
ghRepo: repository.url,
|
|
coreQuotaAt: ["repos/base-owner/base-repo/pulls/42"],
|
|
graphqlResponses: [
|
|
{ data: { repository: { ...repository, pullRequest: { id: "PR_42" }, ...patch } } },
|
|
],
|
|
},
|
|
"pr_gh pr view 42 --json baseRepository",
|
|
);
|
|
expect(result.status, result.stderr).toBe(65);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.calls).toHaveLength(2);
|
|
expect(result.graphqlInputs).toHaveLength(1);
|
|
});
|
|
|
|
it("verifies the protected writer through GraphQL when REST quota is exhausted", () => {
|
|
const result = readPrMetadata(
|
|
{
|
|
protectedGh: true,
|
|
coreQuotaAt: ["user"],
|
|
graphqlResponses: [{ data: { viewer: { login: "fixture-writer" } } }],
|
|
},
|
|
"pr_gh_writer_login github.com",
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout.trim()).toBe("fixture-writer");
|
|
expect(result.calls.filter((call) => call.includes("graphql"))).toHaveLength(1);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"resolves authoritative repository identity without a quota-blind HEAD (both budgets=%s)",
|
|
(graphqlQuota) => {
|
|
const result = readPrMetadata(
|
|
{
|
|
coreQuotaAt: ["browse", "repos/base-owner/base-repo"],
|
|
graphqlResponses: [graphqlResponse(repository)],
|
|
graphqlQuota,
|
|
},
|
|
"pr_gh_plain repo view --json id,nameWithOwner,url",
|
|
);
|
|
expect(result.status, result.stderr).toBe(graphqlQuota ? 75 : 0);
|
|
if (!graphqlQuota) {
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
id: "R_base",
|
|
nameWithOwner: repository.nameWithOwner,
|
|
url: repository.url,
|
|
});
|
|
}
|
|
expect(result.calls.filter((call) => call.includes("graphql"))).toHaveLength(1);
|
|
expect(result.calls).toContainEqual(["browse"]);
|
|
expect(
|
|
result.calls.filter((call) => call.includes("repos/base-owner/base-repo")),
|
|
).toHaveLength(1);
|
|
},
|
|
);
|
|
|
|
it("preserves canonical repository identities for differently cased callers", () => {
|
|
const result = readPrMetadata(
|
|
{
|
|
coreQuotaAt: ["repos/Base-Owner/Base-Repo"],
|
|
graphqlResponses: [graphqlResponse(repository)],
|
|
},
|
|
"pr_gh_plain repo-authority Base-Owner/Base-Repo GitHub.com",
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
id: 1,
|
|
node_id: "R_base",
|
|
full_name: repository.nameWithOwner,
|
|
html_url: repository.url,
|
|
});
|
|
});
|
|
|
|
it.each(["exact", "absent", "unavailable"])(
|
|
"resolves %s author permission without accepting a fuzzy collaborator match",
|
|
(mode) => {
|
|
const page = (login: string, permission: string, hasNextPage: boolean) =>
|
|
graphqlResponse({
|
|
collaborators: {
|
|
totalCount: 2,
|
|
edges: [{ permission, node: { login } }],
|
|
pageInfo: { hasNextPage, endCursor: hasNextPage ? "next" : null },
|
|
},
|
|
});
|
|
const result = readPrMetadata(
|
|
{
|
|
coreQuotaAt: ["repos/base-owner/base-repo/collaborators/human/permission"],
|
|
graphqlResponses:
|
|
mode === "unavailable"
|
|
? [
|
|
{
|
|
data: { repository: null },
|
|
errors: [{ message: "Unavailable collaborator contract" }],
|
|
},
|
|
]
|
|
: [
|
|
page("human-other", "ADMIN", true),
|
|
page(mode === "exact" ? "human" : "human-another", "WRITE", false),
|
|
],
|
|
},
|
|
"pr_gh author-permission base-owner/base-repo github.com human",
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
permission: mode === "exact" ? "write" : mode === "absent" ? "none" : "unknown",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(["complete", "partial", "duplicate", "errors"])(
|
|
"reads %s GraphQL comment evidence after core exhaustion",
|
|
(mode) => {
|
|
const node = {
|
|
id: "IC_1",
|
|
databaseId: 12,
|
|
body: "review",
|
|
url: `${repository.url}/pull/42#issuecomment-12`,
|
|
createdAt: "2026-09-20T00:00:00Z",
|
|
updatedAt: "2026-09-20T00:00:00Z",
|
|
author: { id: "BOT_1", databaseId: 274271284, login: "clawsweeper", __typename: "Bot" },
|
|
};
|
|
const page = (nodes: unknown[], hasNextPage: boolean) =>
|
|
graphqlResponse({ pullRequest: { comments: connectionPage(nodes, hasNextPage) } });
|
|
const result = readPrMetadata(
|
|
{
|
|
coreQuotaAt: ["repos/base-owner/base-repo/issues/42/comments?per_page=100"],
|
|
graphqlResponses:
|
|
mode === "errors"
|
|
? [{ ...page([node], false), errors: [{ message: "partial result" }] }]
|
|
: [
|
|
page([node], mode !== "partial"),
|
|
page([{ ...node, databaseId: mode === "duplicate" ? 12 : 13 }], false),
|
|
],
|
|
},
|
|
"pr_gh_plain issue-comments base-owner/base-repo github.com 42",
|
|
);
|
|
expect(result.status, result.stderr).toBe(mode === "complete" ? 0 : 65);
|
|
if (mode === "complete") {
|
|
const comments = JSON.parse(result.stdout).flat();
|
|
expect(comments).toHaveLength(2);
|
|
expect(comments[0]).toMatchObject({
|
|
id: 12,
|
|
body: "review",
|
|
user: { id: 274271284, login: "clawsweeper[bot]", type: "Bot" },
|
|
html_url: node.url,
|
|
});
|
|
} else {
|
|
expect(result.stdout).toBe("");
|
|
}
|
|
},
|
|
);
|
|
|
|
it("preserves pinned author order and human attribution through GraphQL", () => {
|
|
const first = "1".repeat(40);
|
|
const second = "2".repeat(40);
|
|
const result = readPrMetadata(
|
|
{
|
|
authorSources: [
|
|
{ oid: first, changesTree: true },
|
|
{ oid: second, changesTree: false },
|
|
],
|
|
coreQuotaAt: [`repos/base-owner/base-repo/commits?sha=${second}&per_page=2`],
|
|
graphqlResponses: [
|
|
graphqlResponse({
|
|
commit0: {
|
|
oid: first,
|
|
author: {
|
|
name: "Human",
|
|
email: "human@example.invalid",
|
|
user: { login: "human", __typename: "User" },
|
|
},
|
|
},
|
|
commit1: {
|
|
oid: second,
|
|
author: { name: "Unlinked", email: "unlinked@example.invalid", user: null },
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
'printf "%s\\n" "$FAKE_GH_FIXTURE" | jq .authorSources | pr_gh commit-authors base-owner/base-repo github.com',
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual([
|
|
{
|
|
name: "Human",
|
|
email: "human@example.invalid",
|
|
user: { login: "human", type: "User" },
|
|
changesTree: true,
|
|
},
|
|
{ name: "Unlinked", email: "unlinked@example.invalid", user: null, changesTree: false },
|
|
]);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"returns complete PR file metadata through GraphQL (truncated=%s)",
|
|
(truncated) => {
|
|
const page = (path: string, hasNextPage: boolean) =>
|
|
graphqlResponse({
|
|
pullRequest: {
|
|
files: connectionPage(
|
|
[{ path, additions: 1, deletions: 0, changeType: "MODIFIED" }],
|
|
hasNextPage,
|
|
),
|
|
},
|
|
});
|
|
const result = readPrMetadata(
|
|
{
|
|
coreQuotaAt: ["repos/base-owner/base-repo/pulls/42"],
|
|
graphqlResponses: [
|
|
graphqlResponse({
|
|
pullRequest: {
|
|
headRefOid: head,
|
|
author: null,
|
|
headRepository: null,
|
|
headRepositoryOwner: null,
|
|
},
|
|
}),
|
|
page("src/a.ts", !truncated),
|
|
page("src/b.ts", false),
|
|
],
|
|
},
|
|
"pr_gh pr view 42 --json headRefOid,author,headRepository,headRepositoryOwner,files",
|
|
);
|
|
expect(result.status, result.stderr).toBe(truncated ? 65 : 0);
|
|
if (!truncated) {
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
headRefOid: head,
|
|
author: null,
|
|
headRepository: null,
|
|
headRepositoryOwner: null,
|
|
files: ["src/a.ts", "src/b.ts"].map((path) => ({
|
|
path,
|
|
additions: 1,
|
|
deletions: 0,
|
|
changeType: "MODIFIED",
|
|
})),
|
|
});
|
|
} else {
|
|
expect(result.stdout).toBe("");
|
|
}
|
|
expect(result.calls.some((args) => args[0] === "pr")).toBe(false);
|
|
expect(
|
|
result.calls
|
|
.filter((args) => args.includes("graphql"))
|
|
.every((args) => args.includes("Cache-Control: max-age=0")),
|
|
).toBe(true);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("accepts canonical repository casing when adopting an explicit qualified observation", () => {
|
|
const result = readPrMetadata(
|
|
{ ghRepo: "https://GITHUB.COM/base-owner/base-repo" },
|
|
'pr_observe 42; printf "%s\\n" "$PR_REPOSITORY_URL"',
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toBe("https://github.com/base-owner/base-repo\n");
|
|
expect(result.calls).toHaveLength(1);
|
|
});
|
|
|
|
it("authenticates nested worktree entries once without trusting inherited login state", () => {
|
|
const result = readPrMetadata(
|
|
{},
|
|
'ensure_gh_api_auth; ensure_gh_api_auth; ensure_gh_api_auth; ensure_gh_api_auth; printf "%s\\n" "$PR_GH_WRITER_LOGIN"',
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toBe("contributor\n");
|
|
expect(result.calls.filter((args) => args.includes("user"))).toHaveLength(1);
|
|
});
|
|
|
|
it("revalidates writer identity after explicit credential selection changes", () => {
|
|
const result = readPrMetadata(
|
|
{},
|
|
"ensure_gh_api_auth; GH_TOKEN=synthetic-replacement ensure_gh_api_auth",
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.calls.filter((args) => args.includes("user"))).toHaveLength(2);
|
|
});
|
|
|
|
it("does not retain a failed authentication probe", () => {
|
|
const result = readPrMetadata(
|
|
{ failure: "quota", failureTarget: "user" },
|
|
"ensure_gh_api_auth || true; ensure_gh_api_auth",
|
|
);
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.calls.filter((args) => args.includes("user"))).toHaveLength(2);
|
|
});
|
|
|
|
describe("pinned source authors", () => {
|
|
const command =
|
|
'printf "%s\\n" "$FAKE_GH_FIXTURE" | jq .authorSources | pr_gh commit-authors base-owner/base-repo github.enterprise.invalid';
|
|
const sha = (index: number) => index.toString(16).padStart(40, "0");
|
|
const source = (index: number) => ({ oid: sha(index), changesTree: index % 2 === 0 });
|
|
const record = (index: number) => ({
|
|
sha: sha(index),
|
|
commit: { author: { name: `Author ${index}`, email: `author${index}@example.com` } },
|
|
author: { login: `author${index}`, type: "User" },
|
|
});
|
|
const requests = (calls: string[][]) =>
|
|
calls.map((args) => {
|
|
expect(args.slice(0, 3)).toEqual(["api", "--hostname", "github.enterprise.invalid"]);
|
|
const endpoint = args[3];
|
|
if (endpoint === undefined) {
|
|
throw new Error("Expected a commit-author API endpoint");
|
|
}
|
|
const query = new URL(endpoint, "https://github.enterprise.invalid/").searchParams;
|
|
return { sha: query.get("sha"), limit: Number(query.get("per_page")) };
|
|
});
|
|
|
|
it("does not read GitHub for an empty source selection", () => {
|
|
const result = readPrMetadata({ authorSources: [] }, command);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual([]);
|
|
expect(result.calls).toEqual([]);
|
|
});
|
|
|
|
it("resolves 101 pinned authors in two requests and retains original source order", () => {
|
|
const sources = Array.from({ length: 101 }, (_, index) => source(index + 1));
|
|
const records = Array.from({ length: 101 }, (_, index) => record(index + 1));
|
|
const result = readPrMetadata(
|
|
{ authorSources: sources, authorPages: [records.slice(1).toReversed(), [records[0]]] },
|
|
command,
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual(
|
|
records.map((commit, index) => ({
|
|
name: commit.commit.author.name,
|
|
email: commit.commit.author.email,
|
|
user: commit.author,
|
|
changesTree: source(index + 1).changesTree,
|
|
})),
|
|
);
|
|
expect(requests(result.calls)).toEqual([
|
|
{ sha: sha(101), limit: 100 },
|
|
{ sha: sha(1), limit: 1 },
|
|
]);
|
|
});
|
|
|
|
it("ignores unrelated ancestry and uses singleton requests for every remaining author", () => {
|
|
const result = readPrMetadata(
|
|
{
|
|
authorSources: [source(1), source(2), source(3), source(4)],
|
|
authorPages: [
|
|
[record(4), record(99), record(98), record(97)],
|
|
[record(3)],
|
|
[record(2)],
|
|
[record(1)],
|
|
],
|
|
},
|
|
command,
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout).map((author: { name: string }) => author.name)).toEqual([
|
|
"Author 1",
|
|
"Author 2",
|
|
"Author 3",
|
|
"Author 4",
|
|
]);
|
|
expect(requests(result.calls)).toEqual([
|
|
{ sha: sha(4), limit: 4 },
|
|
{ sha: sha(3), limit: 1 },
|
|
{ sha: sha(2), limit: 1 },
|
|
{ sha: sha(1), limit: 1 },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
null,
|
|
{},
|
|
[source(1), source(1)],
|
|
[{ oid: "main", changesTree: true }],
|
|
[{ oid: sha(1) }],
|
|
])("rejects invalid source selections before reading GitHub: %j", (authorSources) => {
|
|
const result = readPrMetadata({ authorSources }, command);
|
|
expect(result.status).toBe(65);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.calls).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
null,
|
|
{},
|
|
[],
|
|
[record(2)],
|
|
[record(1), record(1)],
|
|
[{ ...record(1), sha: "main" }],
|
|
[{ ...record(1), author: undefined }],
|
|
[{ ...record(1), author: { login: "bot", type: null } }],
|
|
[{ ...record(1), commit: { author: { name: null, email: "author1@example.com" } } }],
|
|
])("rejects malformed or unbound author evidence without retrying: %j", (page) => {
|
|
const result = readPrMetadata({ authorSources: [source(1)], authorPages: [page] }, command);
|
|
expect(result.status).toBe(65);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.calls).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects duplicate authors even when the batch has the requested size and tip", () => {
|
|
const result = readPrMetadata(
|
|
{
|
|
authorSources: [source(1), source(2)],
|
|
authorPages: [[record(2), record(2)]],
|
|
},
|
|
command,
|
|
);
|
|
expect(result.status).toBe(65);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.calls).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "qualified repo URL",
|
|
ghRepo: "base-owner/base-repo",
|
|
command:
|
|
"pr_gh_plain repo view --json url --repo https://github.enterprise.invalid/base-owner/base-repo",
|
|
failureTarget: "repository",
|
|
host: "github.enterprise.invalid",
|
|
},
|
|
{
|
|
name: "qualified GH_REPO",
|
|
ghRepo: "github.enterprise.invalid/base-owner/base-repo",
|
|
command: "pr_gh_plain repo view --json url",
|
|
failureTarget: "repository",
|
|
host: "github.enterprise.invalid",
|
|
},
|
|
{
|
|
name: "qualified short repo flag",
|
|
ghRepo: "base-owner/base-repo",
|
|
command:
|
|
"pr_gh_plain pr checks 42 --required --json name,bucket,state -R github.enterprise.invalid/base-owner/base-repo",
|
|
failureTarget: "checks",
|
|
host: "github.enterprise.invalid",
|
|
},
|
|
{
|
|
name: "raw API default",
|
|
ghRepo: "github.enterprise.invalid/base-owner/base-repo",
|
|
command: "pr_gh_plain api repos/base-owner/base-repo/pulls/42",
|
|
failureTarget: "pull",
|
|
host: "",
|
|
},
|
|
] as const)(
|
|
"probes the failing host for $name without changing API defaults",
|
|
({ ghRepo, command, failureTarget, host }) => {
|
|
const result = readPrMetadata(
|
|
{
|
|
ghRepo,
|
|
configuredHost: "github.com",
|
|
protectedGh: true,
|
|
failure: "quota",
|
|
failureTarget,
|
|
},
|
|
command,
|
|
);
|
|
expect(result.status, result.stderr).toBe(75);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.calls.filter((args) => args.includes("rate_limit"))).toEqual([
|
|
["api", ...(host ? ["--hostname", host] : []), "rate_limit"],
|
|
]);
|
|
expect(result.delays).toEqual([]);
|
|
},
|
|
);
|
|
it("keeps successful GitHub JSON intact when Git adapter cleanup fails", () => {
|
|
const result = readPrMetadata(
|
|
{ probeGit: true, cleanupFailure: true },
|
|
'response=$(pr_gh_plain api repos/base-owner/base-repo 2>&1); printf "%s\\n" "$response"',
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toBe(
|
|
JSON.stringify({
|
|
id: 1,
|
|
full_name: "base-owner/base-repo",
|
|
html_url: "https://github.com/base-owner/base-repo",
|
|
node_id: "R_base",
|
|
}) + "\n",
|
|
);
|
|
expect(result.stderr).toBe("");
|
|
});
|
|
it("resolves a protected writer's default repository through its selected gh binary", () => {
|
|
const result = readPrMetadata(
|
|
{ ghRepo: "", protectedGh: true },
|
|
"pr_gh_plain repo view --json url; pr_gh_plain pr edit 42 --add-assignee contributor",
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({ url: "https://github.com/base-owner/base-repo" });
|
|
expect(result.calls.filter((args) => args[0] === "browse")).toHaveLength(2);
|
|
expect(result.calls).toContainEqual([
|
|
"pr",
|
|
"edit",
|
|
"42",
|
|
"--add-assignee",
|
|
"contributor",
|
|
"--repo",
|
|
"https://github.com/base-owner/base-repo",
|
|
]);
|
|
});
|
|
it("rejects unsupported repository JSON fields", () => {
|
|
const result = readPrMetadata({}, "pr_gh repo view --json unsupported");
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("Unsupported REST repository metadata field: unsupported");
|
|
});
|
|
it.each([
|
|
{ command: "pr_gh pr view 42 --json headRefOid --jq .headRefOid", expected: head },
|
|
{
|
|
command: "pr_gh_plain repo view --json nameWithOwner --jq=.nameWithOwner",
|
|
expected: "base-owner/base-repo",
|
|
},
|
|
])("filters view JSON through the shell: $command", ({ command, expected }) => {
|
|
const result = readPrMetadata({}, command);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toBe(`${expected}\n`);
|
|
expect(result.stderr).toBe("");
|
|
});
|
|
it("uses gh's configured default for reads and explicitly bound writers with the selected Git", () => {
|
|
const result = readPrMetadata(
|
|
{ ghRepo: "", defaultRepoURL: "https://github.com/base-owner/base-repo", probeGit: true },
|
|
"pr_meta_json 42; pr_gh_plain pr edit 42 --add-assignee contributor",
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout).url).toBe("https://github.com/base-owner/base-repo/pull/42");
|
|
expect(result.calls.filter((args) => args[0] === "browse")).toEqual([["browse"], ["browse"]]);
|
|
expect(result.calls).toContainEqual([
|
|
"pr",
|
|
"edit",
|
|
"42",
|
|
"--add-assignee",
|
|
"contributor",
|
|
"--repo",
|
|
"https://github.com/base-owner/base-repo",
|
|
]);
|
|
});
|
|
it.each(["environment", "explicit", "explicit-git-suffix"])(
|
|
"preserves the %s repository override before gh's default",
|
|
(mode) => {
|
|
const explicit =
|
|
mode === "environment"
|
|
? ""
|
|
: ` --repo=https://github.com/base-owner/base-repo${mode === "explicit-git-suffix" ? ".git" : ""}`;
|
|
const result = readPrMetadata(
|
|
{
|
|
ghRepo: explicit ? "ignored/repo" : "base-owner/base-repo",
|
|
defaultRepoURL: "https://github.com/ignored/default",
|
|
},
|
|
`pr_gh pr view 42 --json number,headRefOid${explicit}; pr_gh_plain pr edit 42 --add-assignee contributor${explicit}`,
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({ number: 42, headRefOid: head });
|
|
expect(result.calls.filter((args) => args[0] === "browse")).toEqual(
|
|
explicit ? [] : [["browse"], ["browse"]],
|
|
);
|
|
},
|
|
);
|
|
it.each(
|
|
["GH_REPO", "--repo"].flatMap((selection) =>
|
|
["github.enterprise.invalid", "github.enterprise.invalid:8443"].map((enterpriseHost) => ({
|
|
selection,
|
|
enterpriseHost,
|
|
})),
|
|
),
|
|
)(
|
|
"uses the configured enterprise host $enterpriseHost for unqualified $selection with GH_HOST unset",
|
|
({ selection, enterpriseHost }) => {
|
|
const repoURL = `https://${enterpriseHost}/base-owner/base-repo`;
|
|
const explicit = selection === "--repo" ? " --repo base-owner/base-repo" : "";
|
|
const result = readPrMetadata(
|
|
{
|
|
ghRepo: selection === "GH_REPO" ? "base-owner/base-repo" : "",
|
|
configuredHost: enterpriseHost,
|
|
defaultRepoURL: repoURL,
|
|
protectedGh: true,
|
|
},
|
|
`pr_gh_plain repo view --json url${explicit}; pr_gh_plain pr edit 42 --add-assignee contributor`,
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({ url: repoURL });
|
|
expect(result.calls).toContainEqual([
|
|
"api",
|
|
"--hostname",
|
|
enterpriseHost,
|
|
"repos/base-owner/base-repo",
|
|
"-H",
|
|
"Cache-Control: max-age=0",
|
|
]);
|
|
expect(result.calls).toContainEqual([
|
|
"pr",
|
|
"edit",
|
|
"42",
|
|
"--add-assignee",
|
|
"contributor",
|
|
"--repo",
|
|
repoURL,
|
|
]);
|
|
expect(result.calls).toContainEqual(
|
|
selection === "--repo" ? ["browse", "--repo", "base-owner/base-repo"] : ["browse"],
|
|
);
|
|
},
|
|
);
|
|
it.each(["forbidden", "quota"] as const)(
|
|
"reports %s collaborator lookup failures with the existing preparation policy",
|
|
(failure) => {
|
|
const result = readPrMetadata(
|
|
{ failure, failureTarget: "permission" },
|
|
"source scripts/pr-lib/prepare-core.sh; resolve_pr_author_access_at_prepare contributor base-owner/base-repo github.com",
|
|
);
|
|
expect(result.status, result.stderr).toBe(failure === "forbidden" ? 0 : 1);
|
|
expect(result.stdout).toBe(failure === "forbidden" ? "unknown\n" : "");
|
|
expect(result.stderr).toContain("resource=core");
|
|
expect(result.stderr).toContain(
|
|
"graphql 0/5000 reset=2027-01-15T08:00:00Z core 4900/5000 reset=2027-01-15T08:05:00Z",
|
|
);
|
|
expect(result.stderr).toContain(
|
|
"Supplemental quota probe (remaining/limit; not the failing response)",
|
|
);
|
|
expect(result.stderr).not.toContain("Wait until");
|
|
expect(result.stderr).not.toContain("secret-response-must-not-escape");
|
|
expect(result.calls.filter((args) => args.includes("rate_limit"))).toHaveLength(1);
|
|
},
|
|
);
|
|
it.each([undefined, null, 0, -1, 1.5])(
|
|
"refuses CI dispatch when repository IDs are unavailable: %s",
|
|
(id) => {
|
|
const result = readPrMetadata(
|
|
{
|
|
initialPatch: {
|
|
base: { sha: base, ref: "main", repo: { id } },
|
|
head: { sha: head, ref: "topic", repo: { id } },
|
|
},
|
|
},
|
|
"source scripts/pr-lib/gates.sh; ci_dispatch 42",
|
|
);
|
|
expect(result.status, result.stderr).toBe(1);
|
|
expect(result.stderr).toContain("missing repository identity for workflow dispatch");
|
|
expect(result.stdout).toBe("");
|
|
expect(result.calls.some((args) => args[0] === "workflow")).toBe(false);
|
|
},
|
|
);
|
|
it("preserves pending checks exit 8 and JSON without mistaking check names for quota errors", () => {
|
|
const result = readPrMetadata(
|
|
{},
|
|
"pr_gh_plain pr checks 42 --required --json name,bucket,state",
|
|
);
|
|
expect(result.status, result.stderr).toBe(8);
|
|
expect(JSON.parse(result.stdout)).toEqual([
|
|
{ name: "RATE_LIMIT", bucket: "pending", state: "PENDING" },
|
|
]);
|
|
expect(result.calls.some((args) => args.includes("rate_limit"))).toBe(false);
|
|
});
|
|
it("forwards lock notifier FD3 through the shell, Node, API call and quota probe", () => {
|
|
const result = readPrMetadata(
|
|
{ notify: true, failure: "quota" },
|
|
'exec 3>"$FAKE_GH_NOTIFY"; export OPENCLAW_PR_LOCK_NOTIFY_FD=3; pr_gh api repos/base-owner/base-repo/pulls/42',
|
|
);
|
|
expect(result.status, result.stderr).toBe(75);
|
|
expect(result.notifications).toBe("repos/base-owner/base-repo/pulls/42\nrate_limit\n");
|
|
expect(result.stderr).toContain("resource=core");
|
|
});
|
|
it("collects complete paginated files without requesting unrelated checks or GraphQL", () => {
|
|
const result = readPrMetadata();
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
expect(result.attempts).toBe(2);
|
|
expect(result.calls.filter((args) => args[0] === "api")).toHaveLength(3);
|
|
expect(result.calls.some((args) => args.includes("repos/base-owner/base-repo"))).toBe(false);
|
|
expect(
|
|
result.calls.every(
|
|
(args) => (args[0] === "api" && !args.includes("graphql")) || args[0] === "browse",
|
|
),
|
|
).toBe(true);
|
|
const metadata = JSON.parse(result.stdout);
|
|
expect(metadata).toMatchObject({
|
|
number: 42,
|
|
changedFiles: 101,
|
|
headRefOid: head,
|
|
baseRefOid: base,
|
|
headRepository: { nameWithOwner: "fork-owner/fork-repo" },
|
|
});
|
|
expect(metadata.files).toHaveLength(101);
|
|
expect(metadata.files.at(-1)).toEqual({
|
|
path: "src/file-100.ts",
|
|
additions: 1,
|
|
deletions: 0,
|
|
changeType: "DELETED",
|
|
});
|
|
expect(
|
|
result.calls.some((args) =>
|
|
args.some((arg) => arg.includes("/check-runs?") || arg.includes("/status?")),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("accepts an explicit empty diff", () => {
|
|
const result = readPrMetadata({ changedFiles: 0 });
|
|
expect(result.status).toBe(0);
|
|
expect(JSON.parse(result.stdout).files).toEqual([]);
|
|
});
|
|
|
|
it("preserves renamed paths and change types from REST file pages", () => {
|
|
const result = readPrMetadata({
|
|
changedFiles: 1,
|
|
files: [
|
|
{
|
|
filename: "src/renamed.ts",
|
|
previous_filename: "src/previous.ts",
|
|
status: "renamed",
|
|
additions: 0,
|
|
deletions: 0,
|
|
},
|
|
],
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(JSON.parse(result.stdout).files).toEqual([
|
|
{ path: "src/renamed.ts", changeType: "RENAMED", additions: 0, deletions: 0 },
|
|
]);
|
|
});
|
|
|
|
it("rejects incomplete file pagination", () => {
|
|
const result = readPrMetadata({
|
|
changedFiles: 2,
|
|
files: [{ filename: "src/file.ts", status: "modified", additions: 1, deletions: 0 }],
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("expected 2 changed files, received 1 from paginated REST");
|
|
});
|
|
|
|
it("rejects a zero count contradicted by returned files", () => {
|
|
const result = readPrMetadata({
|
|
changedFiles: 0,
|
|
files: [{ filename: "src/file.ts", status: "modified", additions: 1, deletions: 0 }],
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("consistent with changedFiles");
|
|
});
|
|
|
|
it("does not publish a partial page when the files API fails", () => {
|
|
const result = readPrMetadata({ failure: "exit", failureTarget: "files" });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("exited with status 7");
|
|
});
|
|
|
|
it.each([null, {}, "unavailable"])("rejects unavailable file pages %j", (files) => {
|
|
const result = readPrMetadata({ changedFiles: 0, files });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("malformed paginated metadata");
|
|
});
|
|
|
|
it.each([
|
|
[{ filename: "x", status: "modified", additions: -1, deletions: 0 }],
|
|
[{ filename: "x", status: "modified", additions: 0.5, deletions: 0 }],
|
|
[{ filename: "", status: "modified", additions: 0, deletions: 0 }],
|
|
[
|
|
{ filename: "x", status: "modified", additions: 0, deletions: 0 },
|
|
{ filename: "x", status: "modified", additions: 0, deletions: 0 },
|
|
],
|
|
])("rejects invalid or duplicate entries %j", (...files) => {
|
|
const result = readPrMetadata({ changedFiles: files.length, files });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("files must be an explicit array");
|
|
});
|
|
|
|
it.each([
|
|
{ number: 43 },
|
|
{ html_url: "https://github.com/other/repo/pull/42" },
|
|
{ base: { sha: null, ref: "main" } },
|
|
{ base: { sha: base, ref: "" } },
|
|
{ head: { sha: "not-a-sha", ref: "topic" } },
|
|
{ head: { ref: "topic" } },
|
|
])("rejects mismatched or incomplete PR identity %j", (initialPatch) => {
|
|
const result = readPrMetadata({ initialPatch });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.attempts).toBe(1);
|
|
expect(result.delays).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
{ number: 43 },
|
|
{ html_url: "https://github.com/other/repo/pull/42" },
|
|
{ base: { sha: "d".repeat(40), ref: "main" } },
|
|
{ base: { sha: base, ref: "release" } },
|
|
{ head: { sha: head, ref: "different", repo: { full_name: "another/fork" } } },
|
|
])("rejects changed post-collection identity %j", (finalPatch) => {
|
|
const result = readPrMetadata({ finalPatch });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain(
|
|
"base/head or repository identity changed or became unavailable",
|
|
);
|
|
});
|
|
|
|
it.each(["REST", "GraphQL"])(
|
|
"revalidates both %s observations and rejects files collected while the PR head moves",
|
|
(transport) => {
|
|
const metadata = {
|
|
number: 42,
|
|
title: "Fixture",
|
|
state: "OPEN",
|
|
isDraft: false,
|
|
author: null,
|
|
baseRefName: "main",
|
|
baseRefOid: base,
|
|
headRefName: "topic",
|
|
headRefOid: head,
|
|
isCrossRepository: false,
|
|
headRepository: null,
|
|
headRepositoryOwner: null,
|
|
url: "https://github.com/base-owner/base-repo/pull/42",
|
|
body: "",
|
|
changedFiles: 0,
|
|
additions: 0,
|
|
deletions: 0,
|
|
};
|
|
const response = (pullRequest: unknown) =>
|
|
graphqlResponse({
|
|
id: "R_base",
|
|
databaseId: 1,
|
|
nameWithOwner: "base-owner/base-repo",
|
|
url: "https://github.com/base-owner/base-repo",
|
|
pullRequest,
|
|
});
|
|
const emptyPage = { totalCount: 0, nodes: [], pageInfo: { hasNextPage: false } };
|
|
const result = readPrMetadata({
|
|
cacheUntilRevalidated: true,
|
|
finalPatch: { head: { sha: "b".repeat(40), ref: "topic" } },
|
|
...(transport === "GraphQL"
|
|
? {
|
|
coreQuotaAt: ["repos/base-owner/base-repo/pulls/42"],
|
|
graphqlResponses: [
|
|
response(metadata),
|
|
response({ labels: emptyPage }),
|
|
response({ assignees: emptyPage }),
|
|
response({ files: emptyPage }),
|
|
response({ ...metadata, headRefOid: "b".repeat(40) }),
|
|
],
|
|
}
|
|
: {}),
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("PR head changed while collecting file metadata");
|
|
},
|
|
);
|
|
|
|
it("rejects an invalid changed-file count", () => {
|
|
const result = readPrMetadata({ changedFiles: null });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("changedFiles must be a non-negative integer");
|
|
});
|
|
|
|
it.each(["empty", "exit", "non-json", "null"] as const)(
|
|
"keeps the bounded metadata retry for %s responses",
|
|
(failure) => {
|
|
const result = readPrMetadata({ failure });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.attempts).toBe(3);
|
|
expect(result.delays).toEqual([1, 2]);
|
|
expect(result.stderr).toContain("GitHub API failure while reading PR #42");
|
|
},
|
|
);
|
|
|
|
it("recovers from a transient metadata failure", () => {
|
|
const result = readPrMetadata({ failure: "empty", failureCount: 1 });
|
|
expect(result.status).toBe(0);
|
|
expect(result.attempts).toBe(3);
|
|
expect(result.delays).toEqual([1]);
|
|
});
|
|
|
|
it("fails closed when the post-collection identity read is unavailable", () => {
|
|
const result = readPrMetadata({ failure: "exit", failureTarget: "reread" });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.attempts).toBe(4);
|
|
expect(result.delays).toEqual([1, 2]);
|
|
expect(result.stderr).toContain("GitHub API failure while reading PR #42");
|
|
});
|
|
|
|
it.each([
|
|
{ command: "pr_meta_json 42", failureTarget: "pull", resource: "core", exitCode: 1 },
|
|
{ command: "ensure_gh_api_auth", failureTarget: "user", resource: "core", exitCode: 1 },
|
|
{
|
|
command: "pr_gh pr view 42 --json headRefOid --jq .headRefOid",
|
|
failureTarget: "pull",
|
|
resource: "core",
|
|
exitCode: 75,
|
|
},
|
|
{
|
|
command: "pr_gh_plain pr view 42 --json headRefOid --jq=.headRefOid",
|
|
failureTarget: "pull",
|
|
resource: "core",
|
|
exitCode: 75,
|
|
},
|
|
] as const)(
|
|
"labels supplemental quotas for a $resource failure without retrying: $command",
|
|
({ command, failureTarget, resource, exitCode }) => {
|
|
const result = readPrMetadata({ failure: "quota", failureTarget }, command);
|
|
expect(result.status).toBe(exitCode);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain(
|
|
"Supplemental quota probe (remaining/limit; not the failing response)",
|
|
);
|
|
expect(result.stderr).not.toContain("Wait until");
|
|
expect(result.stderr).toContain(`resource=${resource}`);
|
|
expect(result.stderr).toContain(
|
|
"graphql 0/5000 reset=2027-01-15T08:00:00Z core 4900/5000 reset=2027-01-15T08:05:00Z",
|
|
);
|
|
expect(result.stderr).not.toContain("secret-response-must-not-escape");
|
|
expect(result.calls.filter((args) => args.includes("rate_limit"))).toHaveLength(1);
|
|
expect(result.attempts).toBe(failureTarget === "pull" ? 1 : 0);
|
|
expect(result.delays).toEqual([]);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("merge outcome API diagnostics", () => {
|
|
it.each(["quota", "forbidden", "exit"] as const)(
|
|
"keeps %s diagnostics nonfatal and exposes only bounded access errors",
|
|
(failure) => {
|
|
const observation = JSON.stringify({
|
|
main: base,
|
|
pr: {
|
|
state: "OPEN",
|
|
headRefOid: head,
|
|
baseRefName: "main",
|
|
isDraft: false,
|
|
mergeable: "MERGEABLE",
|
|
mergeStateStatus: "CLEAN",
|
|
autoMergeRequest: null,
|
|
isInMergeQueue: false,
|
|
},
|
|
});
|
|
const result = readPrMetadata(
|
|
{ failure },
|
|
`MERGE_REPO_HOST=github.com MERGE_REPO_NAME=base-owner/base-repo PREP_HEAD_SHA=${head} merge_outcome_diagnose 42 '${observation}'; printf 'diagnostic completed\\n'`,
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toBe("diagnostic completed\n");
|
|
expect(result.stderr).toContain(
|
|
"REST pulls/42: mergeable/mergeable_state unavailable (diagnostic only)",
|
|
);
|
|
expect(result.stderr).not.toContain("secret-response-must-not-escape");
|
|
expect(result.stderr).not.toContain("HTTP 503: No server is currently available");
|
|
if (failure === "exit") {
|
|
expect(result.stderr).not.toContain("GitHub API request failed");
|
|
} else {
|
|
expect(result.stderr).toContain("resource=core");
|
|
expect(result.stderr).toContain(
|
|
"graphql 0/5000 reset=2027-01-15T08:00:00Z core 4900/5000 reset=2027-01-15T08:05:00Z",
|
|
);
|
|
}
|
|
expect(result.attempts).toBe(1);
|
|
expect(result.delays).toEqual([]);
|
|
expect(result.calls.filter((args) => args.includes("rate_limit"))).toHaveLength(
|
|
failure === "exit" ? 0 : 1,
|
|
);
|
|
expect(result.calls.every((args) => args[0] === "api")).toBe(true);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("PR GitHub helper snapshot trust", () => {
|
|
it.each(["changed source", "redirected import root", "changed response parser"])(
|
|
"rejects %s before loading snapshot code",
|
|
(kind) => {
|
|
const root = tempDirs.make("openclaw-pr-gh-snapshot-");
|
|
for (const file of [
|
|
"pr-lib/github.sh",
|
|
"pr-lib/github.mjs",
|
|
"pr-lib/gh-api-preflight.mjs",
|
|
"lib/plain-gh.mjs",
|
|
"lib/direct-run.mjs",
|
|
]) {
|
|
const target = join(root, "scripts", file);
|
|
mkdirSync(dirname(target), { recursive: true });
|
|
copyFileSync(join(process.cwd(), "scripts", file), target);
|
|
}
|
|
const target = join(
|
|
root,
|
|
"scripts/pr-lib",
|
|
kind === "changed response parser" ? "gh-api-preflight.mjs" : "github.mjs",
|
|
);
|
|
if (kind !== "redirected import root") {
|
|
writeFileSync(target, "throw new Error('unverified source ran');\n");
|
|
} else {
|
|
const outside = join(root, "outside/github.mjs");
|
|
mkdirSync(dirname(outside));
|
|
copyFileSync(target, outside);
|
|
unlinkSync(target);
|
|
symlinkSync(outside, target);
|
|
}
|
|
const result = spawnSync(
|
|
"/bin/bash",
|
|
["-c", 'source "$1"', "snapshot-test", join(process.cwd(), "scripts/pr-lib/github.sh")],
|
|
{
|
|
encoding: "utf8",
|
|
env: { ...process.env, OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: root },
|
|
},
|
|
);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("Refusing unverified scripts/pr GitHub helper snapshot");
|
|
expect(result.stderr).not.toContain("unverified source ran");
|
|
},
|
|
);
|
|
});
|