feat: automate HWLAB G14 PR rollout monitoring
This commit is contained in:
+115
-25
@@ -21,7 +21,7 @@ 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_LIST_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", "headRefName", "baseRefName"] as const;
|
||||
const PR_READ_JSON_FIELDS = ["body", "title", "state", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
|
||||
const PR_CLOSEOUT_JSON_FIELDS = ["mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
|
||||
const PR_CLOSEOUT_VIEW_JSON = "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
|
||||
@@ -30,7 +30,7 @@ const BODY_UPDATE_MODES = ["replace", "append"] as const;
|
||||
const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const;
|
||||
const BOARD_GITHUB_STATUSES = ["OPEN", "CLOSED"] as const;
|
||||
const GH_VALUE_OPTIONS = new Set(["--repo", "--limit", "--board-issue", "--known-meta-issue", "--ignore-issue", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field", "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--number"]);
|
||||
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat"]);
|
||||
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch"]);
|
||||
const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
|
||||
const ISSUE_SCAN_MAX_FINDINGS = 60;
|
||||
const ISSUE_BODY_PROFILES = {
|
||||
@@ -61,6 +61,7 @@ type PrReadJsonField = typeof PR_READ_JSON_FIELDS[number];
|
||||
type IssueListState = typeof ISSUE_LIST_STATES[number];
|
||||
type PrListState = typeof ISSUE_LIST_STATES[number];
|
||||
type BodyUpdateMode = typeof BODY_UPDATE_MODES[number];
|
||||
type PullRequestMergeMethod = "merge" | "squash" | "rebase";
|
||||
type BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number];
|
||||
type BoardGithubStatus = typeof BOARD_GITHUB_STATUSES[number];
|
||||
type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES;
|
||||
@@ -339,6 +340,8 @@ interface GitHubOptions {
|
||||
boardMoveTo?: BoardMutationSection;
|
||||
boardGithubStatus?: BoardGithubStatus;
|
||||
boardRowUpsertValues: BoardRowUpsertValues;
|
||||
mergeMethod: PullRequestMergeMethod;
|
||||
deleteBranch: boolean;
|
||||
}
|
||||
|
||||
interface GitHubShorthandReference {
|
||||
@@ -411,7 +414,7 @@ interface GitHubPullRequest {
|
||||
html_url: string;
|
||||
draft?: boolean;
|
||||
user?: { login?: string };
|
||||
head?: { ref?: string; sha?: string };
|
||||
head?: { ref?: string; sha?: string; repo?: { full_name?: string | null } | null };
|
||||
base?: { ref?: string; sha?: string };
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
@@ -643,6 +646,12 @@ function parseBodyUpdateMode(args: string[]): BodyUpdateMode {
|
||||
throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`);
|
||||
}
|
||||
|
||||
function parsePullRequestMergeMethod(args: string[]): PullRequestMergeMethod {
|
||||
const selected = ["merge", "squash", "rebase"].filter((method) => hasFlag(args, `--${method}`));
|
||||
if (selected.length > 1) throw new Error("choose only one PR merge method flag: --merge, --squash, or --rebase");
|
||||
return (selected[0] ?? "merge") as PullRequestMergeMethod;
|
||||
}
|
||||
|
||||
function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
|
||||
const raw = optionValue(args, "--body-profile") ?? "auto";
|
||||
if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw;
|
||||
@@ -772,6 +781,8 @@ function parseOptions(args: string[]): GitHubOptions {
|
||||
boardMoveTo: parseBoardMutationSection(args, "--to"),
|
||||
boardGithubStatus: parseBoardGithubStatus(args),
|
||||
boardRowUpsertValues: parseBoardRowUpsertValues(args),
|
||||
mergeMethod: parsePullRequestMergeMethod(args),
|
||||
deleteBranch: hasFlag(args, "--delete-branch"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4066,12 +4077,11 @@ function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<s
|
||||
|
||||
function prMergeBoundary(): Record<string, unknown> {
|
||||
return {
|
||||
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"],
|
||||
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close", "pr merge after explicit command authorization and preflight success"],
|
||||
ordinaryRunnerFinalActionAllowed: true,
|
||||
commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"],
|
||||
hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"],
|
||||
unideskCliMergeSupported: false,
|
||||
degradedReason: "unsupported-command",
|
||||
hostAllowedToolsAfterReview: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"],
|
||||
unideskCliMergeSupported: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4267,11 +4277,11 @@ function prPreflightPolicy(repo: string, number: number): Record<string, unknown
|
||||
createsPr: false,
|
||||
comments: false,
|
||||
mergesPr: false,
|
||||
mergeCommandSupported: false,
|
||||
unsupportedMergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo}`,
|
||||
unideskCliMergeSupported: false,
|
||||
mergeCommandSupported: true,
|
||||
mergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo} --merge`,
|
||||
unideskCliMergeSupported: true,
|
||||
ordinaryRunnerFinalActionAllowed: true,
|
||||
note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; the UniDesk REST CLI still never merges PRs.",
|
||||
note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; use gh pr merge only after this command reports ready and the task boundary allows merge.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4343,11 +4353,95 @@ function prCloseoutSummary(
|
||||
blockers,
|
||||
pending,
|
||||
commanderAction: readyForCommanderMerge
|
||||
? "review and merge through a repo-owned GitHub path when task boundaries allow; UniDesk REST gh pr merge remains unsupported"
|
||||
? "review and merge through bun scripts/cli.ts gh pr merge when task boundaries allow"
|
||||
: "resolve blockers or rerun after GitHub finishes computing mergeability/status checks",
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteHeadBranchAfterMerge(repo: string, token: string, pr: GitHubPullRequest): Promise<Record<string, unknown>> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
const headRepo = pr.head?.repo?.full_name ?? null;
|
||||
const headRef = pr.head?.ref ?? null;
|
||||
if (headRepo !== repo || headRef === null || headRef.length === 0) {
|
||||
return {
|
||||
attempted: false,
|
||||
skippedReason: "head-repo-differs-or-ref-missing",
|
||||
headRepo,
|
||||
headRef,
|
||||
};
|
||||
}
|
||||
const encodedRef = encodeURIComponent(`heads/${headRef}`);
|
||||
const deleted = await githubRequest<unknown>(token, "DELETE", `/repos/${owner}/${name}/git/refs/${encodedRef}`);
|
||||
if (isGitHubError(deleted)) {
|
||||
return {
|
||||
attempted: true,
|
||||
ok: false,
|
||||
headRepo,
|
||||
headRef,
|
||||
error: deleted,
|
||||
};
|
||||
}
|
||||
return {
|
||||
attempted: true,
|
||||
ok: true,
|
||||
headRepo,
|
||||
headRef,
|
||||
};
|
||||
}
|
||||
|
||||
async function prMerge(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||||
if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" });
|
||||
const summary = prSummary(pr);
|
||||
const metadata = await prGraphqlMetadata(repo, token, number);
|
||||
if (isGitHubError(metadata)) return commandError("pr merge", repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary });
|
||||
const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, false);
|
||||
const mergeability = prCloseoutSummary(summary, prMetadataSummary(metadata), statusChecks);
|
||||
if (mergeability.readyForCommanderMerge !== true) {
|
||||
return validationError("pr merge", repo, "PR is not ready for merge; preflight blockers or pending states remain", {
|
||||
number,
|
||||
pullRequest: preflightPullRequestSummary(summary),
|
||||
mergeability,
|
||||
statusChecks,
|
||||
closeoutMetadata: prCloseoutMetadata(metadata),
|
||||
});
|
||||
}
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
command: "pr merge",
|
||||
repo,
|
||||
number,
|
||||
dryRun: true,
|
||||
wouldMerge: true,
|
||||
method: options.mergeMethod,
|
||||
deleteBranch: options.deleteBranch,
|
||||
pullRequest: preflightPullRequestSummary(summary),
|
||||
mergeability,
|
||||
statusChecks,
|
||||
};
|
||||
}
|
||||
const merged = await githubRequest<Record<string, unknown>>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, {
|
||||
merge_method: options.mergeMethod,
|
||||
});
|
||||
if (isGitHubError(merged)) return commandError("pr merge", repo, merged, { number, phase: "merge", method: options.mergeMethod, pullRequest: summary });
|
||||
const after = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||||
if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged });
|
||||
const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "delete-branch-not-requested" };
|
||||
return {
|
||||
ok: true,
|
||||
command: "pr merge",
|
||||
repo,
|
||||
number,
|
||||
method: options.mergeMethod,
|
||||
mergeResult: merged,
|
||||
pullRequest: prSummary(after),
|
||||
branchDeletion,
|
||||
rest: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise<GitHubCommandResult> {
|
||||
const auth = await authStatus(repo);
|
||||
const authCapability = compactAuthCapability(auth);
|
||||
@@ -5807,6 +5901,7 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
|
||||
],
|
||||
defaults: { repo: DEFAULT_REPO },
|
||||
@@ -5842,7 +5937,7 @@ export function ghHelp(): unknown {
|
||||
"PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and --number N as a compatibility alias for the positional PR number; shorthand derives --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
|
||||
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
|
||||
"PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and the explicit UniDesk REST CLI no-merge policy. Use --full or --raw to include all fetched status contexts.",
|
||||
"PR create/edit/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
|
||||
"PR merge is a guarded write operation: it first reads closeout metadata, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Use --dry-run to see the exact merge plan without writing.",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -6141,20 +6236,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false);
|
||||
}
|
||||
if (sub === "merge") {
|
||||
return unsupportedCommand(
|
||||
"pr merge",
|
||||
options.repo,
|
||||
"PR merge is intentionally unsupported by the UniDesk REST CLI; PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks using repo-owned GitHub paths, while high-risk or ambiguous PRs stay commander-reviewed.",
|
||||
{
|
||||
closeoutBoundary: {
|
||||
...prMergeBoundary(),
|
||||
readOnlyCloseoutCommand: `bun scripts/cli.ts gh pr view ${third ?? "<number>"} --repo ${options.repo} --json ${PR_CLOSEOUT_VIEW_JSON}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const number = parseNumberForCommand(options.repo, third, "pr merge");
|
||||
if (typeof number !== "number") return number;
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(options.repo, "pr merge", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr merge", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return prMerge(options.repo, token, number, options);
|
||||
}
|
||||
if (sub !== "list" && !isPrReadCommand(sub)) {
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete.");
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/delete, and unsupported delete.");
|
||||
}
|
||||
if (sub === "read" || sub === "view") {
|
||||
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);
|
||||
|
||||
Reference in New Issue
Block a user