openclaw/scripts/pr-lib/github.mjs
Peter Steinberger c2dbec2f80
fix: use writer identity for PR merge recovery (#155245)
* fix: use writer identity for PR merge recovery

* test: honor included headers in PR fixtures

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-09-21 16:29:16 -07:00

1006 lines
34 KiB
JavaScript

import { mkdtempSync, readFileSync, 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";
import {
isGraphqlQuotaExhausted,
isCoreQuotaExhausted,
parseGithubResponse,
rateLimitRetryGuidance,
readWriterLogin,
} from "./gh-api-preflight.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 deadline = options.timeout > 0 ? Date.now() + options.timeout : undefined;
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 graphqlQuotaExhausted = resourceFor(args) === "graphql" && isGraphqlQuotaExhausted(error);
const coreQuotaExhausted = resourceFor(args) === "core" && isCoreQuotaExhausted(error);
const reason = githubAccessFailure(error);
if (!reason) {
throw error;
}
const response = parseGithubResponse(String(error?.stdout ?? ""));
let diagnostic;
if (response.status) {
const { status, resource, remaining, limit, resetUtc, retryAfter } = response;
diagnostic = `original response: HTTP ${status}; resource=${resource}; remaining=${remaining ?? "unknown"}; limit=${limit ?? "unknown"}; reset=${resetUtc}${retryAfter === undefined ? "" : `; retry-after=${retryAfter}s`}.`;
diagnostic +=
reason === "quota"
? ` ${rateLimitRetryGuidance(response)}`
: " Check access policy before retrying.";
} else if (graphqlQuotaExhausted || coreQuotaExhausted) {
diagnostic = `${graphqlQuotaExhausted ? "GraphQL" : "REST core"} primary quota is exhausted; the original reset time is unknown.`;
} else {
const hostname = quotaHostname(args, inherited);
const host = hostname ? ["--hostname", hostname] : [];
let resources;
try {
// Diagnostics share the failed read's budget; they must not extend a watcher deadline.
const remaining = deadline === undefined ? undefined : deadline - Date.now();
if (remaining !== undefined && remaining <= 0) {
throw error;
}
resources = JSON.parse(
run(["api", ...host, "rate_limit"], {
...captured,
...(remaining === undefined ? {} : { timeout: remaining }),
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe", ...notifier],
}),
).resources;
} catch {
// A failed diagnostics probe must not conceal the original resource or retry it.
}
diagnostic = `Supplemental quota probe (remaining/limit; not the failing response): ${quotaSummary("graphql", resources?.graphql)} ${quotaSummary("core", resources?.core)}. Check access or throttling before retrying; the original response's quota and reset are unknown.`;
}
const failure = new Error(
`GitHub API request failed (resource=${resourceFor(args)}); ${diagnostic}`,
);
failure.code = "OPENCLAW_GH_ACCESS";
failure.status = reason === "quota" ? 75 : 77;
failure.graphqlQuotaExhausted = graphqlQuotaExhausted;
failure.coreQuotaExhausted = coreQuotaExhausted;
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, readOptions = () => ({})) {
const selected = explicit || process.env.GH_REPO;
const qualified =
/^(?:https:\/\/)?([A-Za-z0-9.-]+(?::[0-9]+)?)\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(
selected ?? "",
);
// A host-qualified locator already names the API target. Its ensuing REST
// read verifies the repository; another browse HEAD adds no authority.
if (qualified) {
return { host: qualified[1], name: qualified[2] };
}
// Noninteractive browse keeps gh's default/host resolution local. --no-browser
// adds a REST HEAD whose generic 403 hides quota evidence. The child-only
// launcher prints the address; the subsequent API read validates authority.
const options = readOptions();
const value = execPrGh(
["browse", ...(explicit ? ["--repo", explicit] : [])],
{
...options,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...(options.env ?? process.env),
// gh parses its launcher with shlex, then appends the URL as one argument.
GH_BROWSER: `'${process.execPath.replaceAll("'", "'\\''")}' -p 'process.argv[1]'`,
},
},
route,
).trim();
const match =
/^(?:(?:https?:\/\/|ssh:\/\/git@|git@)?([^/:]+(?::[0-9]+)?)[:/])?([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, options = {}) {
return execPrGhJson(
[
"api",
"--hostname",
repo.host,
endpoint,
...(paginate ? ["--paginate", "--slurp"] : []),
...(route === "plain" || options.revalidate ? ["-H", "Cache-Control: max-age=0"] : []),
],
{ ...options.readOptions?.(), stdio: ["ignore", "pipe", "pipe"] },
route,
);
}
function restPreferred(read, fallback) {
try {
return read();
} catch (error) {
if (!error.coreQuotaExhausted) {
throw error;
}
return fallback();
}
}
function graphql(repo, query, variables, route, options = {}) {
const result = execPrGhJson(
[
"api",
"--hostname",
repo.host,
"graphql",
"--input",
"-",
...(route === "plain" || options.revalidate ? ["-H", "Cache-Control: max-age=0"] : []),
],
{
...options.readOptions?.(),
input: JSON.stringify({ query, variables }),
stdio: ["pipe", "pipe", "pipe"],
},
route,
);
if (!result?.data || result.errors != null) {
throw invalidMetadata("GitHub returned incomplete GraphQL metadata.");
}
return result.data;
}
function namedRepository(name, host) {
if (
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(name ?? "") ||
!/^[A-Za-z0-9.-]+(?::[0-9]+)?$/.test(host ?? "")
) {
throw invalidMetadata("Invalid repository address.");
}
return { name, host };
}
function repositoryVariables(repo) {
const [owner, name] = repo.name.split("/");
return { owner, name };
}
function connectionNodes(readPage) {
const nodes = [];
const cursors = new Set();
let cursor = null;
let total;
do {
const page = readPage(cursor);
if (
!Array.isArray(page?.nodes) ||
!Number.isSafeInteger(page.totalCount) ||
page.totalCount < 0 ||
typeof page.pageInfo?.hasNextPage !== "boolean" ||
(total !== undefined && total !== page.totalCount)
) {
throw invalidMetadata("GitHub returned incomplete GraphQL pagination.");
}
total = page.totalCount;
nodes.push(...page.nodes);
if (!page.pageInfo.hasNextPage) {
break;
}
cursor = page.pageInfo.endCursor;
if (
typeof cursor !== "string" ||
!cursor ||
cursors.has(cursor) ||
page.nodes.length === 0 ||
nodes.length >= total
) {
throw invalidMetadata("GitHub returned incomplete GraphQL pagination.");
}
cursors.add(cursor);
} while (cursor !== null);
if (nodes.length !== total) {
throw invalidMetadata("GitHub returned incomplete GraphQL pagination.");
}
return nodes;
}
function validateRepoAuthority(repo, record) {
if (
!record ||
!Number.isSafeInteger(record.databaseId) ||
record.databaseId <= 0 ||
typeof record.id !== "string" ||
!record.id ||
typeof record.nameWithOwner !== "string" ||
record.nameWithOwner.toLowerCase() !== repo.name.toLowerCase() ||
typeof record.url !== "string" ||
record.url.toLowerCase() !== `https://${repo.host}/${repo.name}`.toLowerCase()
) {
throw invalidMetadata("GitHub returned invalid repository authority.");
}
}
function readRepoAuthority(repo, route) {
return restPreferred(
() => api(repo, `repos/${repo.name}`, route, false, { revalidate: true }),
() => {
const record = graphql(
repo,
"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id databaseId nameWithOwner url}}",
repositoryVariables(repo),
route,
).repository;
validateRepoAuthority(repo, record);
return {
id: record.databaseId,
node_id: record.id,
full_name: record.nameWithOwner,
html_url: record.url,
};
},
);
}
function readIssueComments(repo, pr, route) {
if (!/^[1-9][0-9]*$/.test(pr ?? "")) {
throw invalidMetadata("Expected a positive PR number.");
}
return restPreferred(
() => {
const pages = api(
repo,
`repos/${repo.name}/issues/${pr}/comments?per_page=100`,
route,
true,
{ revalidate: true },
);
pageItems(pages);
return pages;
},
() => {
const nodes = connectionNodes(
(cursor) =>
graphql(
repo,
"query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$cursor){totalCount pageInfo{hasNextPage endCursor} nodes{id databaseId body url createdAt updatedAt author{login __typename ... on User{id databaseId} ... on Bot{id databaseId}}}}}}}",
{ ...repositoryVariables(repo), number: Number(pr), cursor },
route,
).repository?.pullRequest?.comments,
);
const seen = new Set();
const comments = nodes.map((node) => {
if (
!node ||
!Number.isSafeInteger(node.databaseId) ||
node.databaseId <= 0 ||
seen.has(node.databaseId) ||
typeof node.body !== "string" ||
typeof node.url !== "string"
) {
throw invalidMetadata("GitHub returned invalid issue comments.");
}
seen.add(node.databaseId);
const author = node.author;
if (
author !== null &&
(typeof author?.login !== "string" ||
!author.login ||
typeof author.__typename !== "string")
) {
throw invalidMetadata("GitHub returned invalid comment authors.");
}
return {
id: node.databaseId,
node_id: node.id,
body: node.body,
html_url: node.url,
created_at: node.createdAt,
updated_at: node.updatedAt,
user:
author === null
? null
: {
id: author?.databaseId,
node_id: author?.id,
login:
author?.__typename === "Bot" && !author.login.endsWith("[bot]")
? `${author.login}[bot]`
: author?.login,
type: author?.__typename,
},
};
});
return [comments];
},
);
}
function writerLogin(host) {
let response;
let status = 0;
try {
response = execPrGh(
["api", ...(host ? ["--hostname", host] : []), "user", "--include"],
{ encoding: "utf8" },
"plain",
);
} catch (error) {
if (error.coreQuotaExhausted) {
response = execPrGh(
[
"api",
...(host ? ["--hostname", host] : []),
"graphql",
"--include",
"-f",
"query=query{viewer{login}}",
],
{ encoding: "utf8" },
"plain",
);
const parsed = parseGithubResponse(response);
const login = parsed.body?.data?.viewer?.login;
if (
parsed.status !== "200" ||
parsed.body?.errors != null ||
typeof login !== "string" ||
!login.trim()
) {
throw invalidMetadata("GitHub did not verify the GraphQL writer identity.");
}
return login;
}
if (error.code === "OPENCLAW_GH_ACCESS") {
throw error;
}
response = `${error.stdout ?? ""}${error.stderr ?? ""}`;
status = error.status || 1;
}
return readWriterLogin(status, response).trim();
}
function readAuthorPermission(repo, login, route) {
if (typeof login !== "string" || !login) {
throw invalidMetadata("Expected an author login.");
}
return restPreferred(
() =>
api(repo, `repos/${repo.name}/collaborators/${encodeURIComponent(login)}/permission`, route),
() => {
try {
const edges = connectionNodes((cursor) => {
const connection = graphql(
repo,
"query($owner:String!,$name:String!,$login:String!,$cursor:String){repository(owner:$owner,name:$name){collaborators(first:100,query:$login,after:$cursor){totalCount pageInfo{hasNextPage endCursor} edges{permission node{login}}}}}",
{ ...repositoryVariables(repo), login, cursor },
route,
).repository?.collaborators;
return { ...connection, nodes: connection?.edges };
});
const matches = edges.filter((edge) => edge?.node?.login === login);
if (matches.length === 0) {
return { permission: "none" };
}
const permission = matches[0]?.permission;
return {
permission:
matches.length === 1 &&
["ADMIN", "MAINTAIN", "WRITE", "TRIAGE", "READ"].includes(permission)
? permission.toLowerCase()
: "unknown",
};
} catch (error) {
if (error.status === 75) {
throw error;
}
return { permission: "unknown" };
}
},
);
}
function pageItems(pages) {
if (!Array.isArray(pages) || pages.length === 0 || pages.some((page) => !Array.isArray(page))) {
throw invalidMetadata("GitHub returned malformed paginated metadata.");
}
return pages.flat();
}
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 readPrRest(repo, pr, fields, route, options = {}) {
if (!/^[1-9][0-9]*$/.test(pr)) {
throw new Error("Expected a positive PR number.");
}
// These reads bind source acquisition and publication to the live PR head.
// Revalidate each observation through the relay, including both sides of a fetch.
const freshOptions = { ...options, revalidate: true };
const record = api(repo, `repos/${repo.name}/pulls/${pr}`, route, false, freshOptions);
if (!record || typeof record !== "object" || Array.isArray(record)) {
throw new Error("GitHub did not return one PR JSON object.");
}
if (
fields.includes("baseRepository") &&
(typeof record.base?.repo?.full_name !== "string" ||
typeof record.base.repo.html_url !== "string" ||
record.base.repo.full_name.toLowerCase() !== repo.name.toLowerCase() ||
record.base.repo.html_url.toLowerCase() !== `https://${repo.host}/${repo.name}`.toLowerCase())
) {
throw invalidMetadata("GitHub PR base repository does not match the requested repository.");
}
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,
baseRepository:
record.base?.repo == null
? record.base?.repo
: {
id: record.base.repo.node_id,
databaseId: record.base.repo.id,
nameWithOwner: record.base.repo.full_name,
url: record.base.repo.html_url,
},
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, freshOptions),
).map((file) => ({
path: file.filename,
additions: file.additions,
deletions: file.deletions,
changeType: file.status === "removed" ? "DELETED" : file.status?.toUpperCase(),
}));
}
return selectFields(result, fields, "PR");
}
function readPr(repo, pr, fields, route, options = {}) {
return restPreferred(
() => readPrRest(repo, pr, fields, route, options),
() => {
const connections = {
files: "path additions deletions changeType",
labels: "id name description color",
assignees: "id login name __typename",
};
const selections = {
number: "number",
title: "title",
state: "state",
isDraft: "isDraft",
author: "author{login __typename ... on User{id name} ... on Bot{id}}",
baseRefName: "baseRefName",
baseRefOid: "baseRefOid",
headRefName: "headRefName",
headRefOid: "headRefOid",
headRepository: "headRepository{id name nameWithOwner url}",
headRepositoryOwner: "headRepositoryOwner{id login __typename ... on User{name}}",
isCrossRepository: "isCrossRepository",
url: "url",
body: "body",
changedFiles: "changedFiles",
additions: "additions",
deletions: "deletions",
mergeable: "mergeable",
mergeStateStatus: "mergeStateStatus",
};
const needsBaseRepository = fields.includes("baseRepository");
const scalarFields = fields.filter(
(field) => field !== "baseRepository" && !Object.hasOwn(connections, field),
);
const selection = Object.values(selectFields(selections, scalarFields, "PR")).join(" ");
const freshOptions = { ...options, revalidate: true };
const variables = { ...repositoryVariables(repo), number: Number(pr) };
// A top-level pr view can be projected back to REST by a relay. An explicit
// GraphQL request both selects the independent quota and carries freshness.
const repository =
scalarFields.length || needsBaseRepository
? graphql(
repo,
`query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){${needsBaseRepository ? "id databaseId nameWithOwner url " : ""}pullRequest(number:$number){${selection || "id"}}}}`,
variables,
route,
freshOptions,
).repository
: null;
const result = scalarFields.length || needsBaseRepository ? repository?.pullRequest : {};
if (!result || typeof result !== "object" || Array.isArray(result)) {
throw invalidMetadata("GitHub did not return one PR JSON object.");
}
if (needsBaseRepository) {
validateRepoAuthority(repo, repository);
const { id, databaseId, nameWithOwner, url } = repository;
result.baseRepository = { id, databaseId, nameWithOwner, url };
}
const actor = (record) =>
record == null ? record : user({ ...record, node_id: record.id, type: record.__typename });
for (const field of ["author", "headRepositoryOwner"]) {
if (fields.includes(field)) {
result[field] = actor(result[field]);
}
}
for (const field of fields.filter((candidate) => Object.hasOwn(connections, candidate))) {
const nodes = connectionNodes(
(cursor) =>
graphql(
repo,
`query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){${field}(first:100,after:$cursor){totalCount pageInfo{hasNextPage endCursor} nodes{${connections[field]}}}}}}`,
{ ...variables, cursor },
route,
freshOptions,
).repository?.pullRequest?.[field],
);
if (
field === "files" &&
(nodes.some((file) => typeof file?.path !== "string") ||
new Set(nodes.map((file) => file.path)).size !== nodes.length)
) {
throw invalidMetadata("GitHub returned invalid PR files.");
}
result[field] = field === "assignees" ? nodes.map(actor) : nodes;
}
return selectFields(result, fields, "PR");
},
);
}
export function createPrMetadataReader(repository) {
let repo;
return (pr, fields, readOptions = () => ({})) => {
repo ??= repositoryLocator(repository, "read", readOptions);
return readPr(repo, String(pr), fields, "read", { readOptions });
};
}
function commitAuthor(author, account, changesTree) {
if (
typeof author?.name !== "string" ||
typeof author.email !== "string" ||
(account !== null &&
(typeof account?.login !== "string" ||
!account.login ||
typeof account.type !== "string" ||
!account.type))
) {
throw invalidMetadata("Cannot establish the requested source commit author.");
}
return {
name: author.name,
email: author.email,
user: account === null ? null : { login: account.login, type: account.type },
changesTree,
};
}
function readCommitAuthorsRest(repository, hostname, commits, route) {
const oid = /^[0-9a-f]{40}$/;
if (
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!/^[A-Za-z0-9.-]+(?::[0-9]+)?$/.test(hostname) ||
!Array.isArray(commits) ||
!commits.every((commit) => oid.test(commit?.oid) && typeof commit.changesTree === "boolean") ||
new Set(commits.map((commit) => commit.oid)).size !== commits.length
) {
throw invalidMetadata("Invalid source commit author request.");
}
const remaining = new Map(commits.map((commit) => [commit.oid, commit]));
const authors = new Map();
let batchSize = 100;
let cursor = commits.length - 1;
while (remaining.size > 0) {
while (!remaining.has(commits[cursor].oid)) {
cursor -= 1;
}
const head = commits[cursor].oid;
const limit = Math.min(batchSize, remaining.size);
const page = execPrGhJson(
["api", "--hostname", hostname, `repos/${repository}/commits?sha=${head}&per_page=${limit}`],
{},
route,
);
if (
!Array.isArray(page) ||
page.length === 0 ||
page.length > limit ||
!page.every((commit) => oid.test(commit?.sha)) ||
new Set(page.map((commit) => commit.sha)).size !== page.length ||
!page.some((commit) => commit.sha === head)
) {
throw invalidMetadata("Cannot establish the requested source commit author.");
}
let resolved = 0;
for (const commit of page) {
const source = remaining.get(commit.sha);
if (!source) {
continue;
}
authors.set(
commit.sha,
commitAuthor(commit.commit?.author, commit.author, source.changesTree),
);
remaining.delete(commit.sha);
resolved += 1;
}
// A merge can introduce unrelated ancestry. Stop expanding after the first
// mixed page: N source commits require at most N calls and N + 99 rows.
if (resolved < limit) {
batchSize = 1;
}
}
return commits.map((commit) => authors.get(commit.oid));
}
function readCommitAuthors(repository, hostname, commits, route) {
return restPreferred(
() => readCommitAuthorsRest(repository, hostname, commits, route),
() => {
const repo = namedRepository(repository, hostname);
const result = [];
for (let start = 0; start < commits.length; start += 100) {
const batch = commits.slice(start, start + 100);
const variables = repositoryVariables(repo);
const parameters = batch.map((commit, index) => {
variables[`oid${index}`] = commit.oid;
return `$oid${index}:GitObjectID!`;
});
const fields = batch.map(
(_, index) =>
`commit${index}:object(oid:$oid${index}){... on Commit{oid author{name email user{login __typename}}}}`,
);
const data = graphql(
repo,
`query($owner:String!,$name:String!,${parameters.join(",")}){repository(owner:$owner,name:$name){${fields.join(" ")}}}`,
variables,
route,
).repository;
for (const [index, source] of batch.entries()) {
const commit = data?.[`commit${index}`];
if (commit?.oid !== source.oid) {
throw invalidMetadata("Cannot establish the requested source commit author.");
}
const account = commit.author?.user;
result.push(
commitAuthor(
commit.author,
account === null ? null : { login: account?.login, type: account?.__typename },
source.changesTree,
),
);
}
}
return result;
},
);
}
function assignReviewer(pr, reviewer) {
if (!/^[1-9][0-9]*$/.test(pr) || typeof reviewer !== "string" || !reviewer.trim()) {
throw new Error("Expected a PR number and reviewer login.");
}
const repo = repositoryLocator(undefined, "plain");
const result = execPrGhJson(
[
"api",
"--hostname",
repo.host,
`repos/${repo.name}/issues/${pr}/assignees`,
"--method",
"POST",
"-f",
`assignees[]=${reviewer}`,
],
{},
"plain",
);
if (
!Array.isArray(result?.assignees) ||
!result.assignees.some((assignee) => assignee?.login === reviewer)
) {
throw invalidMetadata("GitHub did not assign the requested reviewer.");
}
}
function main([requestedRoute, ...args]) {
if (!["plain", "read", "plain-quota"].includes(requestedRoute)) {
throw new Error("Expected a GitHub CLI route.");
}
const route = requestedRoute === "plain-quota" ? "plain" : requestedRoute;
if (route === "plain" && args[0] === "writer-login" && args.length <= 2) {
process.stdout.write(`${writerLogin(args[1])}\n`);
return;
}
if (args[0] === "repo-authority" && args.length === 3) {
process.stdout.write(
`${JSON.stringify(readRepoAuthority(namedRepository(args[1], args[2]), route))}\n`,
);
return;
}
if (args[0] === "issue-comments" && args.length === 4) {
process.stdout.write(
`${JSON.stringify(readIssueComments(namedRepository(args[1], args[2]), args[3], route))}\n`,
);
return;
}
if (args[0] === "author-permission" && args.length === 4) {
process.stdout.write(
`${JSON.stringify(readAuthorPermission(namedRepository(args[1], args[2]), args[3], route))}\n`,
);
return;
}
if (route === "plain" && args[0] === "assign-reviewer" && args.length === 3) {
assignReviewer(args[1], args[2]);
return;
}
if (args[0] === "commit-authors" && args.length === 3) {
const authors = readCommitAuthors(args[1], args[2], JSON.parse(readFileSync(0, "utf8")), route);
process.stdout.write(`${JSON.stringify(authors)}\n`);
return;
}
let result;
// Keep the existing caller/artifact field contract while sourcing ordinary
// metadata through REST. Native landing owns the narrower quota fallback for
// required checks and ordinary squash admission; special routes keep GraphQL.
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 = readRepoAuthority(repo, 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 (
requestedRoute === "plain-quota" &&
args[0] === "api" &&
args.includes("graphql") &&
args.some((arg) => /^query=\s*query\b/.test(arg))
) {
// Mergeability and viewer previews must describe the writer, not a pooled reader.
const response = parseGithubResponse(
execPrGh(
[...args, "--include"],
{ encoding: "utf8", stdio: ["inherit", "pipe", "pipe"] },
route,
),
);
if (
response.status !== "200" ||
!response.body ||
typeof response.body !== "object" ||
Array.isArray(response.body)
) {
throw invalidMetadata("GitHub did not return a valid writer GraphQL response.");
}
process.stdout.write(`${JSON.stringify(response.body)}\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) {
const args = process.argv.slice(3);
const query = args.find((arg) => arg.startsWith("query="));
const quotaRead =
(args[0] === "pr" && args[1] === "checks") ||
(args[0] === "api" && args.includes("graphql") && /^query=\s*query\b/.test(query ?? ""));
if (process.argv[2] === "plain-quota" && quotaRead && error.graphqlQuotaExhausted) {
process.stdout.write('{"graphqlQuotaExhausted":true}\n');
} else {
// 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;
}
}
}