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:
+174
-18
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user