fix(cli): expose PR closeout metadata

Host commander merge after read-only audit. PR #72 is CLEAN/MERGEABLE and limited to UniDesk CLI PR closeout metadata: mergeability/status fields for gh pr read/view plus contract test/docs. Runner validation reported gh-cli-pr-contract-test and live metadata query; no HWLAB business code touched.
This commit is contained in:
Lyon
2026-05-23 00:35:06 +08:00
committed by GitHub
parent 8ebc9c7023
commit 2a8e9a5cd3
5 changed files with 216 additions and 24 deletions
+174 -18
View File
@@ -19,7 +19,8 @@ const BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks",
const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const;
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const PR_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const;
const PR_LIST_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const;
const PR_READ_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
const ISSUE_LIST_STATES = ["open", "closed", "all"] as const;
const BODY_UPDATE_MODES = ["replace", "append"] as const;
const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const;
@@ -49,7 +50,8 @@ const COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS = [
type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number];
type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number];
type PrJsonField = typeof PR_JSON_FIELDS[number];
type PrListJsonField = typeof PR_LIST_JSON_FIELDS[number];
type PrReadJsonField = typeof PR_READ_JSON_FIELDS[number];
type IssueListState = typeof ISSUE_LIST_STATES[number];
type BodyUpdateMode = typeof BODY_UPDATE_MODES[number];
type BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number];
@@ -312,7 +314,8 @@ interface GitHubOptions {
head?: string;
jsonFields?: IssueViewJsonField[];
issueListJsonFields?: IssueListJsonField[];
prJsonFields?: PrJsonField[];
prListJsonFields?: PrListJsonField[];
prJsonFields?: PrReadJsonField[];
listState: IssueListState;
mode: BodyUpdateMode;
expectUpdatedAt?: string;
@@ -385,10 +388,38 @@ interface GitHubPullRequest {
user?: { login?: string };
head?: { ref?: string; sha?: string };
base?: { ref?: string; sha?: string };
mergeable?: string | null;
merge_state_status?: string | null;
created_at?: string;
updated_at?: string;
}
interface GitHubPullRequestGraphqlStatusContext {
__typename?: string;
name?: string | null;
status?: string | null;
conclusion?: string | null;
context?: string | null;
state?: string | null;
targetUrl?: string | null;
description?: string | null;
}
interface GitHubPullRequestGraphqlStatusCheckRollup {
state?: string | null;
contexts?: {
nodes?: GitHubPullRequestGraphqlStatusContext[] | null;
} | null;
}
interface GitHubPullRequestGraphqlMetadata {
mergeable?: string | null;
mergeStateStatus?: string | null;
headRefName?: string | null;
baseRefName?: string | null;
statusCheckRollup?: GitHubPullRequestGraphqlStatusCheckRollup | null;
}
interface GitHubRepository {
id?: number;
full_name?: string;
@@ -520,8 +551,12 @@ function parseIssueListJsonFields(requested: string[] | undefined): IssueListJso
return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS);
}
function parsePrJsonFields(command: string, requested: string[] | undefined): PrJsonField[] | undefined {
return validateJsonFields(command, requested, PR_JSON_FIELDS);
function parsePrListJsonFields(requested: string[] | undefined): PrListJsonField[] | undefined {
return validateJsonFields("gh pr list", requested, PR_LIST_JSON_FIELDS);
}
function parsePrReadJsonFields(requested: string[] | undefined): PrReadJsonField[] | undefined {
return validateJsonFields("gh pr read/view", requested, PR_READ_JSON_FIELDS);
}
function isIssueReadCommand(sub: string | undefined): boolean {
@@ -618,7 +653,8 @@ function parseOptions(args: string[]): GitHubOptions {
head: optionValue(args, "--head"),
jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined,
issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined,
prJsonFields: top === "pr" && (isPrReadCommand(sub) || sub === "list") ? parsePrJsonFields(`gh pr ${sub}`, requestedJsonFields) : undefined,
prListJsonFields: top === "pr" && sub === "list" ? parsePrListJsonFields(requestedJsonFields) : undefined,
prJsonFields: top === "pr" && isPrReadCommand(sub) ? parsePrReadJsonFields(requestedJsonFields) : undefined,
listState: parseIssueListState(args),
mode: parseBodyUpdateMode(args),
expectUpdatedAt: optionValue(args, "--expect-updated-at"),
@@ -1258,6 +1294,63 @@ async function githubRequest<T>(
return parsed as T;
}
async function githubGraphqlRequest<T>(
token: string,
query: string,
variables: Record<string, unknown>,
): Promise<T | GitHubErrorPayload> {
let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
response = await fetch(`${GITHUB_API}/graphql`, {
method: "POST",
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify({ query, variables }),
});
} catch (error) {
return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method: "POST", path: "/graphql" } });
} finally {
clearTimeout(timeout);
}
const parsed = await parseGitHubResponse(response);
if (!response.ok) {
const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
return errorPayload(classifyHttpStatus(response.status, message, "/graphql", response), message, {
status: response.status,
details: sanitizedErrorDetails(parsed),
scopes: {
accepted: response.headers.get("x-accepted-oauth-scopes"),
token: response.headers.get("x-oauth-scopes"),
},
request: { method: "POST", path: "/graphql" },
});
}
if (!isRecord(parsed)) {
return errorPayload("invalid-response", "GitHub GraphQL response was not an object", { details: parsed });
}
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
const firstError = parsed.errors[0];
const message = isRecord(firstError) && typeof firstError.message === "string" ? firstError.message : "GitHub GraphQL query failed";
return errorPayload("validation-failed", message, {
details: sanitizedErrorDetails(parsed),
request: { method: "POST", path: "/graphql" },
});
}
const data = parsed.data;
if (!isRecord(data)) {
return errorPayload("invalid-response", "GitHub GraphQL response did not include an object data payload", { details: sanitizedErrorDetails(parsed) });
}
return data as T;
}
function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null {
if (!tokenProbe.present) {
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) {
@@ -3327,18 +3420,76 @@ function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
author: pr.user?.login ?? null,
head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null },
base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null },
headRefName: pr.head?.ref ?? null,
baseRefName: pr.base?.ref ?? null,
createdAt: pr.created_at ?? null,
updatedAt: pr.updated_at ?? null,
};
}
function selectedPrJson(pr: GitHubPullRequest, fields: PrJsonField[]): Record<string, unknown> {
const summary = prSummary(pr) as Record<PrJsonField, unknown>;
function selectedPrJson(summary: Record<string, unknown>, fields: readonly string[]): Record<string, unknown> {
const selected: Record<string, unknown> = {};
for (const field of fields) selected[field] = summary[field];
return selected;
}
function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> {
return {
mergeable: metadata.mergeable ?? null,
mergeStateStatus: metadata.mergeStateStatus ?? null,
statusCheckRollup: metadata.statusCheckRollup ?? null,
headRefName: metadata.headRefName ?? null,
baseRefName: metadata.baseRefName ?? null,
};
}
function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean {
if (fields === undefined) return false;
return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup");
}
async function prGraphqlMetadata(repo: string, token: string, number: number): Promise<GitHubPullRequestGraphqlMetadata | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
const query = `
query PullRequestCloseoutMetadata($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
mergeable
mergeStateStatus
headRefName
baseRefName
statusCheckRollup {
state
contexts(first: 100) {
nodes {
__typename
... on CheckRun {
name
status
conclusion
}
... on StatusContext {
context
state
targetUrl
description
}
}
}
}
}
}
}
`;
const response = await githubGraphqlRequest<{ repository?: { pullRequest?: GitHubPullRequestGraphqlMetadata | null } }>(token, query, { owner, name, number });
if (isGitHubError(response)) return response;
const pullRequest = response.repository?.pullRequest;
if (pullRequest === null || pullRequest === undefined) {
return errorPayload("invalid-response", "GitHub GraphQL PR metadata response was missing repository.pullRequest", { details: response });
}
return pullRequest;
}
function repoSummary(repo: GitHubRepository): Record<string, unknown> {
return {
id: repo.id ?? null,
@@ -4578,11 +4729,11 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> {
};
}
async function prList(repo: string, token: string, limit: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> {
async function prList(repo: string, token: string, limit: number, jsonFields: PrListJsonField[] | undefined): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=all&per_page=${limit}`);
if (isGitHubError(prs)) return commandError("pr list", repo, prs);
const fields = jsonFields ?? PR_JSON_FIELDS.slice();
const fields = jsonFields ?? PR_LIST_JSON_FIELDS.slice();
return {
ok: true,
command: "pr list",
@@ -4590,24 +4741,28 @@ async function prList(repo: string, token: string, limit: number, jsonFields: Pr
limit,
count: prs.length,
jsonFields: fields,
pullRequests: prs.map((pr) => selectedPrJson(pr, fields)),
pullRequests: prs.map((pr) => selectedPrJson(prSummary(pr), fields)),
};
}
async function prRead(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined, commandName = "pr read"): Promise<GitHubCommandResult> {
async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read"): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
const summary = prSummary(pr);
const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null;
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [] });
const selectionSummary = metadata === null ? summary : { ...summary, ...prMetadataSummary(metadata) };
return {
ok: true,
command: commandName,
repo,
pullRequest: prSummary(pr),
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(pr, jsonFields) }),
pullRequest: summary,
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(selectionSummary, jsonFields) }),
};
}
async function prView(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> {
async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined): Promise<GitHubCommandResult> {
return prRead(repo, token, number, jsonFields, "pr view");
}
@@ -4639,7 +4794,7 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
"bun scripts/cli.ts gh pr read <number> [--repo owner/name] [--json body,title,state,head,base,draft]",
"bun scripts/cli.ts gh pr read <number> [--repo owner/name] [--json body,title,state,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup]",
"bun scripts/cli.ts gh pr view <number> [--repo owner/name] [compatibility alias for pr read]",
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title title] [--repo owner/name] [--dry-run]",
@@ -4671,7 +4826,8 @@ export function ghHelp(): unknown {
"issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"PR read is the canonical read path; view remains a compatibility alias. PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
"PR read is the canonical read path; view remains a compatibility alias. PR read/view supports closeout fields headRefName, baseRefName, mergeable, mergeStateStatus, and statusCheckRollup; mergeability and status rollup are fetched through GitHub GraphQL only when requested.",
"PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
],
};
}
@@ -4901,7 +5057,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return prList(options.repo, token, options.limit, options.prJsonFields);
if (sub === "list") return prList(options.repo, token, options.limit, options.prListJsonFields);
if (sub === "read") return prRead(options.repo, token, parseNumber(third, "pr read"), options.prJsonFields);
return prView(options.repo, token, parseNumber(third, "pr view"), options.prJsonFields);
}