mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 02:00:10 +00:00
* fix(scripts): resolve pr tooling from a configurable root and preflight host git * fix(scripts): preserve PR tooling contracts in dependency checks and fixtures * test(scripts): match cross-checkout PR identity diagnostics * fix(scripts): preserve GitHub CLI host selection for PR reads * test(scripts): inject remote base drift at hosted verification
374 lines
13 KiB
JavaScript
374 lines
13 KiB
JavaScript
import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { delimiter, join, resolve } from "node:path";
|
|
import { isDirectRunUrl } from "../lib/direct-run.mjs";
|
|
import { execGhRead, execPlainGh } from "../lib/plain-gh.mjs";
|
|
|
|
function githubAccessFailure(error) {
|
|
const limited = (text) =>
|
|
typeof text === "string" &&
|
|
/(?:\bHTTP(?:\/\d+(?:\.\d+)?)?\s+429\b|\b429 too many requests\b|\bRATE_LIMIT(?:ED)?\b|\brate[ -]limit(?:ed|ing)?\b)/i.test(
|
|
text,
|
|
);
|
|
const stderr = String(error?.stderr ?? "");
|
|
const stdout = String(error?.stdout ?? "");
|
|
const forbidden =
|
|
/(?:\bHTTP(?:\/\d+(?:\.\d+)?)?\s+403\b|\b403 forbidden\b)/i.test(stderr) ||
|
|
/^HTTP\/\d+(?:\.\d+)? 403(?:\s|$)/m.test(stdout);
|
|
if (limited(stderr) || /^HTTP\/\d+(?:\.\d+)? 429(?:\s|$)/m.test(stdout)) {
|
|
return "quota";
|
|
}
|
|
const boundary = /\r?\n\r?\n/.exec(stdout);
|
|
if (
|
|
forbidden &&
|
|
/^(?:x-ratelimit-remaining:\s*0|retry-after:\s*\d+)\s*$/im.test(
|
|
boundary ? stdout.slice(0, boundary.index) : "",
|
|
)
|
|
) {
|
|
return "quota";
|
|
}
|
|
try {
|
|
const body = JSON.parse(boundary ? stdout.slice(boundary.index + boundary[0].length) : stdout);
|
|
if (
|
|
limited(body?.message) ||
|
|
(Array.isArray(body?.errors) &&
|
|
body.errors.some(
|
|
(entry) =>
|
|
["RATE_LIMIT", "RATE_LIMITED"].includes(entry?.type) || limited(entry?.message),
|
|
))
|
|
) {
|
|
return "quota";
|
|
}
|
|
} catch {
|
|
// A non-JSON response cannot supply additional quota evidence.
|
|
}
|
|
return forbidden ? "forbidden" : undefined;
|
|
}
|
|
|
|
function invalidMetadata(message) {
|
|
const error = new Error(message);
|
|
error.status = 65;
|
|
return error;
|
|
}
|
|
|
|
function resourceFor(args) {
|
|
return args.includes("graphql") || args[0] === "pr" ? "graphql" : "core";
|
|
}
|
|
|
|
function quotaHostname(args, env) {
|
|
const hostname = option(args, "--hostname");
|
|
if (hostname || !["browse", "pr", "run", "workflow"].includes(args[0])) {
|
|
return hostname;
|
|
}
|
|
const repository = option(args, "--repo") || option(args, "-R") || env.GH_REPO || "";
|
|
const qualified = /^(?:https?:\/\/)?([^/]+)\/[^/]+\/[^/]+\/?$/.exec(repository);
|
|
if (!qualified) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return new URL(`https://${qualified[1]}`).host;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function quotaSummary(resource, quota) {
|
|
const number = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : "unknown");
|
|
const reset =
|
|
Number.isSafeInteger(quota?.reset) && quota.reset >= 0 && quota.reset <= 253402300799
|
|
? new Date(quota.reset * 1000).toISOString().replace(".000Z", "Z")
|
|
: "unknown";
|
|
return `${resource} ${number(quota?.remaining)}/${number(quota?.limit)} reset=${reset}`;
|
|
}
|
|
|
|
// Keep the failing call's route and host: a pooled reader and the mutation CLI
|
|
// may use different credentials. Never print raw API error bodies in quota diagnostics.
|
|
export function execPrGh(args, options = {}, route = "read") {
|
|
const run = route === "plain" ? execPlainGh : execGhRead;
|
|
const inherited = options.env ?? process.env;
|
|
const selectedGit = inherited.OPENCLAW_PR_GIT || inherited.GIT_EXEC;
|
|
const notifier = inherited.OPENCLAW_PR_LOCK_NOTIFY_FD === "3" ? [3] : [];
|
|
const captured = {
|
|
...options,
|
|
stdio: [
|
|
Array.isArray(options.stdio) ? options.stdio[0] : "ignore",
|
|
"pipe",
|
|
"pipe",
|
|
...notifier,
|
|
],
|
|
};
|
|
let gitPath;
|
|
try {
|
|
if (selectedGit) {
|
|
// gh resolves Git through PATH even for local repository selection. This
|
|
// call-owned adapter also covers advisory commands without a supervisor.
|
|
gitPath = mkdtempSync(join(tmpdir(), "openclaw-pr-gh-git-"));
|
|
symlinkSync(resolve(options.cwd ?? process.cwd(), selectedGit), join(gitPath, "git"));
|
|
captured.env = { ...inherited, PATH: `${gitPath}${delimiter}${inherited.PATH ?? ""}` };
|
|
}
|
|
return run(args, captured);
|
|
} catch (error) {
|
|
const reason = githubAccessFailure(error);
|
|
if (!reason) {
|
|
throw error;
|
|
}
|
|
const hostname = quotaHostname(args, inherited);
|
|
const host = hostname ? ["--hostname", hostname] : [];
|
|
let resources;
|
|
try {
|
|
resources = JSON.parse(
|
|
run(["api", ...host, "rate_limit"], {
|
|
...captured,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe", ...notifier],
|
|
}),
|
|
).resources;
|
|
} catch {
|
|
// A failed diagnostics probe must not conceal the original resource or retry it.
|
|
}
|
|
const failure = new Error(
|
|
`GitHub API request failed (resource=${resourceFor(args)}); ${quotaSummary("graphql", resources?.graphql)} ${quotaSummary("core", resources?.core)}. Check access or wait for the exhausted resource's reset before retrying.`,
|
|
);
|
|
failure.code = "OPENCLAW_GH_ACCESS";
|
|
failure.status = reason === "quota" ? 75 : 77;
|
|
throw failure;
|
|
} finally {
|
|
if (gitPath) {
|
|
try {
|
|
rmSync(gitPath, { recursive: true, force: true });
|
|
} catch {
|
|
// Best-effort cleanup must preserve the result and combined JSON output.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function execPrGhJson(args, options = {}, route = "read") {
|
|
return JSON.parse(execPrGh(args, { ...options, encoding: "utf8" }, route));
|
|
}
|
|
|
|
function repositoryLocator(explicit, route) {
|
|
// gh browse shares PR commands' SmartBaseRepoFunc and preserves the configured
|
|
// default and host. --no-browser only verifies it with REST HEAD and prints its URL.
|
|
const value = execPrGh(
|
|
["browse", "--no-browser", ...(explicit ? ["--repo", explicit] : [])],
|
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
route,
|
|
).trim();
|
|
const match =
|
|
/^(?:(?:https?:\/\/|ssh:\/\/git@|git@)?([^/:]+)[:/])?([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(
|
|
value,
|
|
);
|
|
if (!match?.[1]) {
|
|
throw new Error("Cannot resolve the GitHub repository; set GH_REPO to owner/repo.");
|
|
}
|
|
return { host: match[1], name: match[2] };
|
|
}
|
|
|
|
function option(args, name) {
|
|
const assignment = args.find((arg) => arg.startsWith(`${name}=`));
|
|
if (assignment) {
|
|
return assignment.slice(name.length + 1);
|
|
}
|
|
const index = args.indexOf(name);
|
|
return index < 0 ? undefined : args[index + 1];
|
|
}
|
|
|
|
function api(repo, endpoint, route, paginate = false) {
|
|
return execPrGhJson(
|
|
[
|
|
"api",
|
|
"--hostname",
|
|
repo.host,
|
|
endpoint,
|
|
...(paginate ? ["--paginate", "--slurp"] : []),
|
|
...(route === "plain" ? ["-H", "Cache-Control: max-age=0"] : []),
|
|
],
|
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
route,
|
|
);
|
|
}
|
|
|
|
function pageItems(pages, key) {
|
|
if (
|
|
!Array.isArray(pages) ||
|
|
pages.length === 0 ||
|
|
pages.some((page) => !Array.isArray(key ? page?.[key] : page))
|
|
) {
|
|
throw invalidMetadata("GitHub returned malformed paginated metadata.");
|
|
}
|
|
return pages.flatMap((page) => (key ? page[key] : page));
|
|
}
|
|
|
|
function user(record) {
|
|
return record == null
|
|
? record
|
|
: {
|
|
id: record.node_id,
|
|
login: record.login,
|
|
is_bot: record.type === "Bot",
|
|
...(record.name === undefined ? {} : { name: record.name }),
|
|
};
|
|
}
|
|
|
|
function selectFields(record, fields, kind) {
|
|
return Object.fromEntries(
|
|
fields.map((field) => {
|
|
if (!Object.hasOwn(record, field)) {
|
|
throw new Error(`Unsupported REST ${kind} metadata field: ${field}`);
|
|
}
|
|
return [field, record[field]];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function readPr(repo, pr, fields, route) {
|
|
if (!/^[1-9][0-9]*$/.test(pr)) {
|
|
throw new Error("Expected a positive PR number.");
|
|
}
|
|
const record = api(repo, `repos/${repo.name}/pulls/${pr}`, route);
|
|
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
throw new Error("GitHub did not return one PR JSON object.");
|
|
}
|
|
if (fields.includes("statusCheckRollup") && !/^[0-9a-f]{40}$/.test(record.head?.sha)) {
|
|
throw invalidMetadata(
|
|
`Invalid PR identity for #${pr}: expected complete base/head OIDs and refs before reading checks.`,
|
|
);
|
|
}
|
|
const result = {
|
|
number: record.number,
|
|
title: record.title,
|
|
state: record.merged_at ? "MERGED" : record.state?.toUpperCase(),
|
|
isDraft: record.draft,
|
|
author: user(record.user),
|
|
baseRefName: record.base?.ref,
|
|
baseRefOid: record.base?.sha,
|
|
headRefName: record.head?.ref,
|
|
headRefOid: record.head?.sha,
|
|
headRepository:
|
|
record.head?.repo == null
|
|
? record.head?.repo
|
|
: {
|
|
id: record.head.repo.node_id,
|
|
name: record.head.repo.name,
|
|
nameWithOwner: record.head.repo.full_name,
|
|
url: record.head.repo.html_url,
|
|
},
|
|
headRepositoryOwner: user(record.head?.repo?.owner),
|
|
isCrossRepository: [record.head?.repo?.id, record.base?.repo?.id].every(
|
|
(id) => Number.isSafeInteger(id) && id > 0,
|
|
)
|
|
? record.head.repo.id !== record.base.repo.id
|
|
: undefined,
|
|
url: record.html_url,
|
|
body: record.body,
|
|
labels: record.labels?.map((label) => ({
|
|
id: label.node_id,
|
|
name: label.name,
|
|
description: label.description,
|
|
color: label.color,
|
|
})),
|
|
assignees: record.assignees?.map(user),
|
|
changedFiles: record.changed_files,
|
|
additions: record.additions,
|
|
deletions: record.deletions,
|
|
mergeable:
|
|
record.mergeable === true
|
|
? "MERGEABLE"
|
|
: record.mergeable === false
|
|
? "CONFLICTING"
|
|
: "UNKNOWN",
|
|
mergeStateStatus: record.mergeable_state?.toUpperCase(),
|
|
};
|
|
if (fields.includes("files")) {
|
|
result.files = pageItems(
|
|
api(repo, `repos/${repo.name}/pulls/${pr}/files?per_page=100`, "plain", true),
|
|
).map((file) => ({
|
|
path: file.filename,
|
|
additions: file.additions,
|
|
deletions: file.deletions,
|
|
changeType: file.status === "removed" ? "DELETED" : file.status?.toUpperCase(),
|
|
}));
|
|
}
|
|
if (fields.includes("statusCheckRollup")) {
|
|
const commit = `repos/${repo.name}/commits/${record.head.sha}`;
|
|
const checks = pageItems(
|
|
api(repo, `${commit}/check-runs?filter=latest&per_page=100`, route, true),
|
|
"check_runs",
|
|
);
|
|
const statuses = pageItems(api(repo, `${commit}/status?per_page=100`, route, true), "statuses");
|
|
result.statusCheckRollup = [
|
|
...checks.map((check) => ({
|
|
__typename: "CheckRun",
|
|
name: check.name,
|
|
status: check.status?.toUpperCase(),
|
|
conclusion: check.conclusion?.toUpperCase(),
|
|
detailsUrl: check.details_url,
|
|
startedAt: check.started_at,
|
|
completedAt: check.completed_at,
|
|
workflowName: check.check_suite?.workflow_run?.name ?? "",
|
|
})),
|
|
...statuses.map((check) => ({
|
|
__typename: "StatusContext",
|
|
context: check.context,
|
|
state: check.state?.toUpperCase(),
|
|
targetUrl: check.target_url,
|
|
startedAt: check.created_at,
|
|
})),
|
|
];
|
|
}
|
|
return selectFields(result, fields, "PR");
|
|
}
|
|
|
|
function main([route, ...args]) {
|
|
if (!["plain", "read"].includes(route)) {
|
|
throw new Error("Expected a GitHub CLI route.");
|
|
}
|
|
let result;
|
|
// Keep the existing caller/artifact field contract while sourcing ordinary
|
|
// metadata through REST. Required-check app bindings and merge queue queries
|
|
// still use their explicit GraphQL owners; REST has no equivalent authority.
|
|
if (["pr", "repo"].includes(args[0]) && args[1] === "view") {
|
|
const repo = repositoryLocator(option(args, "--repo") || option(args, "-R"), route);
|
|
const fields = option(args, "--json")?.split(",");
|
|
if (!fields?.length) {
|
|
throw new Error("GitHub metadata reads require explicit JSON fields.");
|
|
}
|
|
if (args[0] === "pr") {
|
|
result = readPr(repo, args[2], fields, route);
|
|
} else {
|
|
const record = api(repo, `repos/${repo.name}`, route);
|
|
result = selectFields(
|
|
{ nameWithOwner: record.full_name, url: record.html_url, id: record.node_id },
|
|
fields,
|
|
"repository",
|
|
);
|
|
}
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
} else {
|
|
if (args[0] === "pr" && !option(args, "--repo") && !option(args, "-R")) {
|
|
const repo = repositoryLocator(undefined, route);
|
|
args.push("--repo", `https://${repo.host}/${repo.name}`);
|
|
}
|
|
process.stdout.write(
|
|
execPrGh(args, { encoding: "utf8", stdio: ["inherit", "pipe", "pipe"] }, route),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
|
try {
|
|
main(process.argv.slice(2));
|
|
} catch (error) {
|
|
// Quota errors contain only bounded numeric metadata, never raw response text.
|
|
if (error.code !== "OPENCLAW_GH_ACCESS" && error.stdout) {
|
|
process.stdout.write(error.stdout);
|
|
}
|
|
console.error(
|
|
error.code === "OPENCLAW_GH_ACCESS"
|
|
? error.message
|
|
: String(error.stderr || error.message).trim(),
|
|
);
|
|
process.exitCode = Number.isInteger(error.status) && error.status > 0 ? error.status : 1;
|
|
}
|
|
}
|