Files
pikasTech-unidesk/scripts/src/gh.ts
T
2026-06-05 00:18:29 +00:00

6999 lines
307 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { coreInternalFetch } from "./microservices";
const DEFAULT_REPO = "pikasTech/unidesk";
const GITHUB_API = (process.env.UNIDESK_GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/u, "");
const USER_AGENT = "unidesk-cli-gh";
const PREVIEW_CHARS = 240;
const REQUEST_TIMEOUT_MS = 20_000;
const MIN_SAFE_ISSUE_BODY_CHARS = 20;
const MAX_INLINE_ISSUE_COMMENT_BODY_CHARS = 1000;
const GITHUB_REST_PAGE_SIZE = 100;
const MAX_ISSUE_LIST_LIMIT = 1000;
const DEFAULT_STALE_CLOSE_INACTIVE_HOURS = 48;
const MAX_STALE_CLOSE_INACTIVE_HOURS = 24 * 365 * 10;
const ISSUE_LIFECYCLE_PREVIEW_LIMIT = 80;
const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL = "http://backend-core:8080/api/microservices/claudeqq/proxy";
const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID = "645275593";
const CODE_QUEUE_BOARD_TARGET_ISSUE = 20;
const COMMANDER_BRIEF_TARGET_ISSUE = 24;
const MAX_PR_FILES_LIMIT = 3000;
const DEFAULT_BOARD_KNOWN_META_ISSUES = [CODE_QUEUE_BOARD_TARGET_ISSUE, COMMANDER_BRIEF_TARGET_ISSUE] as const;
const BOARD_AUDIT_REQUIRED_COLUMNS = ["branch", "acceptance", "relatedTask", "progress"] as const;
const BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks", "focus"] as const;
const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const;
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "closed", "closedAt", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "closed", "closedAt", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const PR_LIST_JSON_FIELDS = [
"body",
"title",
"state",
"number",
"url",
"author",
"head",
"base",
"draft",
"createdAt",
"updatedAt",
"closedAt",
"merged",
"mergedAt",
"mergeCommit",
"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";
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;
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", "--pr",
"--search", "--inactive-hours", "--comment", "--comment-file",
]);
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 = {
"code-queue-board": {
label: "Code Queue long board issue #20",
issueNumber: CODE_QUEUE_BOARD_TARGET_ISSUE,
requiredHeadings: ["## 看板(OPEN"],
},
"commander-brief": {
label: "Commander brief body-only issue (#24 legacy or daily rolling brief)",
legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE,
requiredHeadings: ["## 常驻观察与长期建议"],
},
} as const;
const DAILY_COMMANDER_BRIEF_TITLE_PATTERN = /^\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)$/u;
const DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)\s*$/u;
const LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+指挥简报\s*$/u;
const COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS = [
/^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u,
/^##\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间指挥更新\s*$/u,
/^###\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+CST[:].*$/u,
] as const;
type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number];
type IssueListJsonField = typeof ISSUE_LIST_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 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;
type IssueBodyProfileOption = "auto" | IssueBodyProfileName;
type EscapeFindingClassification = "suspected-pollution" | "explanatory-mention" | "risk";
type EscapeFindingSeverity = "low" | "medium" | "high";
type EscapeBodyKind = "issue-body" | "comment-body";
type CleanupSuggestionAction = "rewrite-issue-body-with-body-file" | "review-comment-manually" | "review-body-length" | "no-cleanup-needed";
type BoardSectionKind = "open" | "closed";
type BoardRequiredColumn = typeof BOARD_AUDIT_REQUIRED_COLUMNS[number];
type BoardColumnKind = BoardRequiredColumn | "focus" | "githubStatus" | "category" | "summary";
type BoardRowField = typeof BOARD_ROW_FIELDS[number];
type BoardRowUpsertTextField = typeof BOARD_ROW_UPSERT_TEXT_FIELDS[number];
type BoardRowUpsertField = BoardRowUpsertTextField | "status";
type BoardIgnoreReason = "known-meta" | "ignored" | "brief-index-managed";
type GitHubDegradedReason =
| "missing-binary"
| "missing-token"
| "auth-failed"
| "egress-failed"
| "github-transient"
| "network-proxy-failed"
| "permission-denied"
| "repo-not-found"
| "repo-forbidden"
| "issue-not-found"
| "pr-not-found"
| "scope-insufficient"
| "validation-failed"
| "invalid-response"
| "unsupported-command";
type RunnerDisposition = "infra-blocked" | "business-failed";
type ClaudeQqTargetType = "private" | "group";
type CommanderBriefDiffMode = "identical" | "append-only" | "heading-diff" | "unreliable";
interface CommanderBriefDiff {
ok: boolean;
mode: CommanderBriefDiffMode;
message: string;
chars: number;
sections: string[];
sectionCount: number;
skippedReason?: string;
}
interface ClaudeQqConfig {
enabled: boolean;
baseUrl: string;
targetType: ClaudeQqTargetType;
userId?: string;
groupId?: string;
timeoutMs: number;
}
interface ClaudeQqSendResult {
ok: boolean;
attempted: boolean;
skipped?: boolean;
skippedReason?: string;
endpoint?: string;
status?: number;
degradedReason?: string;
message?: string;
response?: unknown;
target: Record<string, unknown>;
}
interface ClaudeQqEndpointResult {
ok: boolean;
endpoint: string;
status?: number;
degradedReason?: string;
message?: string;
response?: unknown;
}
interface CommanderBriefSection {
heading: string;
text: string;
startLine: number;
}
interface EscapePatternDefinition {
kind: string;
pattern: RegExp;
description: string;
}
interface EscapeMatchFinding {
type: "issue" | "comment";
bodyKind: EscapeBodyKind;
bodyId: string;
issueNumber: number;
issueId: number;
commentId?: number;
url: string;
kind: string;
classification: EscapeFindingClassification;
severity: EscapeFindingSeverity;
reason: string;
snippet: string;
lineNumber: number;
column: number;
match: string;
bodyChars: number | null;
bodyTrimmedChars: number | null;
cleanupPreview?: {
before: string;
after: string;
};
}
interface EscapeCleanupSuggestion {
type: EscapeBodyKind;
bodyId: string;
issueNumber: number;
issueId: number;
commentId?: number;
url: string;
classification: EscapeFindingClassification;
action: CleanupSuggestionAction;
reason: string;
findings: Array<Pick<EscapeMatchFinding, "kind" | "classification" | "severity" | "lineNumber" | "column" | "snippet" | "match">>;
plannedCommand?: string;
bodyFileHint?: string;
preview?: {
before: string;
after: string;
};
}
interface EscapeScanErrorFinding {
type: "comment-scan-error";
bodyKind: EscapeBodyKind;
bodyId: string;
issueNumber: number;
issueId: number;
url: string;
degradedReason: GitHubDegradedReason;
runnerDisposition: RunnerDisposition;
details: GitHubErrorPayload;
}
type EscapeScanEntry = EscapeMatchFinding | EscapeScanErrorFinding;
interface BoardTableRow {
section: BoardSectionKind;
lineNumber: number;
raw: string;
cells: string[];
issueNumbers: number[];
issueNumber: number | null;
title: string | null;
columns: Partial<Record<BoardRequiredColumn, string>>;
}
interface BoardTableSection {
kind: BoardSectionKind;
heading: string;
headingLine: number;
headerLine: number;
headers: string[];
rows: BoardTableRow[];
}
interface BoardIssueEntry {
number: number;
title: string;
state: string;
url: string;
}
interface BoardIgnoredIssue {
number: number;
reason: BoardIgnoreReason;
title?: string;
state?: string;
url?: string;
}
interface BoardRowValidationWarning {
issueNumber: number | null;
section: BoardSectionKind;
lineNumber: number;
kind: string;
message: string;
missingColumns?: BoardRequiredColumn[];
columns?: Partial<Record<BoardRequiredColumn, string>>;
rowPreview: string;
}
interface BoardRowLookup {
ok: true;
section: BoardTableSection;
row: BoardTableRow;
matches: Array<{ section: BoardTableSection; row: BoardTableRow }>;
}
interface BoardRowUpdatePlanResult {
ok: true;
plan: Record<string, unknown>;
newBody: string;
}
interface BoardRowMutationPlanResult {
ok: true;
plan: Record<string, unknown>;
newBody: string;
}
interface BoardRowUpsertValues {
category?: string;
branch?: string;
tasks?: string;
summary?: string;
focus?: string;
validation?: string;
progress?: string;
status?: BoardGithubStatus;
}
interface GitHubCommandResult {
ok: boolean;
repo: string;
command: string;
degradedReason?: GitHubDegradedReason;
degraded?: GitHubDegradedReason[];
runnerDisposition?: RunnerDisposition;
[key: string]: unknown;
}
type GitHubCommandFailure = GitHubCommandResult & { ok: false };
interface GitHubTokenProbe {
present: boolean;
source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null;
ghFallbackAttempted: boolean;
ghBinaryFound?: boolean;
ghAuthTokenAvailable?: boolean | null;
}
interface GitHubOptions {
repo: string;
dryRun: boolean;
raw: boolean;
full: boolean;
limit: number;
inactiveHours: number;
boardIssue: number;
knownMetaIssues: number[];
ignoredIssues: number[];
draft: boolean;
notifyClaudeQqBriefDiff: boolean;
allowShortBody: boolean;
labels: string[];
search?: string;
title?: string;
body?: string;
bodyFile?: string;
comment?: string;
commentFile?: string;
base?: string;
head?: string;
jsonFields?: IssueViewJsonField[];
issueListJsonFields?: IssueListJsonField[];
prListJsonFields?: PrListJsonField[];
prJsonFields?: PrReadJsonField[];
listState: IssueListState;
prListState: PrListState;
mode: BodyUpdateMode;
expectUpdatedAt?: string;
expectBodySha?: string;
bodyProfile: IssueBodyProfileOption;
boardRowField?: BoardRowField;
boardRowValue?: string;
boardRowFile?: string;
boardSection?: BoardMutationSection;
boardMoveTo?: BoardMutationSection;
boardGithubStatus?: BoardGithubStatus;
boardRowUpsertValues: BoardRowUpsertValues;
mergeMethod: PullRequestMergeMethod;
deleteBranch: boolean;
}
interface GitHubShorthandReference {
input: string;
repo: string;
number: number;
source?: "owner-repo-number" | "github-url" | "number-option";
urlKind?: "issue" | "pr";
standardCommand?: string;
}
interface GitHubResolvedNumberReference {
repo: string;
number: number;
shorthand?: GitHubShorthandReference;
}
interface IssueProfileValidationContext {
issueTitle?: string | null;
issueBody?: string | null;
issueMetadataFetched?: boolean;
}
interface GitHubErrorPayload {
ok: false;
degradedReason: GitHubDegradedReason;
runnerDisposition: RunnerDisposition;
status?: number;
message: string;
retryable?: boolean;
commanderAction?: string;
details?: unknown;
scopes?: {
accepted: string | null;
token: string | null;
};
request?: {
method: string;
path: string;
};
}
interface GitHubIssue {
id: number;
number: number;
title: string;
body: string | null;
state: string;
html_url: string;
comments: number;
user?: { login?: string };
labels?: Array<string | { name?: string; color?: string; description?: string | null }>;
pull_request?: unknown;
created_at?: string;
updated_at?: string;
closed_at?: string | null;
}
interface GitHubComment {
id: number;
body: string | null;
html_url: string;
user?: { login?: string };
created_at?: string;
updated_at?: string;
}
interface GitHubIssueSearchResponse {
total_count?: number;
incomplete_results?: boolean;
items?: GitHubIssue[];
}
interface GitHubIssueListPage {
path: string;
rawCount: number;
issueCount: number;
}
interface GitHubIssueListResult {
items: GitHubIssue[];
rawCount: number;
fetchedPages: number;
pageSize: number;
exhausted: boolean;
hasMore: boolean;
pages: GitHubIssueListPage[];
searchTotalCount?: number;
searchIncomplete?: boolean;
}
interface GitHubPullRequest {
id: number;
number: number;
title: string;
body: string | null;
state: string;
html_url: string;
draft?: boolean;
user?: { login?: string };
head?: { ref?: string; sha?: string; repo?: { full_name?: string | null } | null };
base?: { ref?: string; sha?: string };
additions?: number;
deletions?: number;
changed_files?: number;
commits?: number;
closed_at?: string | null;
merged?: boolean | null;
merged_at?: string | null;
merge_commit_sha?: string | null;
mergeable?: string | null;
merge_state_status?: string | null;
created_at?: string;
updated_at?: string;
}
interface GitHubPullRequestFile {
sha?: string;
filename: string;
status?: string;
additions?: number;
deletions?: number;
changes?: number;
blob_url?: string;
raw_url?: string;
contents_url?: string;
previous_filename?: string;
patch?: 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;
private?: boolean;
default_branch?: string;
permissions?: Record<string, boolean>;
}
interface GitHubBranch {
name?: string;
commit?: { sha?: string };
}
interface GitHubCompare {
status?: string;
ahead_by?: number;
behind_by?: number;
total_commits?: number;
html_url?: string;
base_commit?: { sha?: string };
merge_base_commit?: { sha?: string };
}
function optionValue(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
const value = args[index + 1];
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function optionValues(args: string[], name: string): string[] {
const values: string[] = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] !== name) continue;
const value = args[index + 1];
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
values.push(value);
index += 1;
}
return values;
}
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
function optionWasProvided(args: string[], name: string): boolean {
return args.includes(name);
}
function commaListOption(args: string[], name: string): string[] | undefined {
const raw = optionValue(args, name);
if (raw === undefined) return undefined;
const values = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
if (values.length === 0) throw new Error(`${name} requires at least one comma-separated field`);
return values;
}
function labelsOption(args: string[]): string[] {
const rawValues = optionValues(args, "--label");
const labels: string[] = [];
const seen = new Set<string>();
for (const raw of rawValues) {
const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
if (parts.length === 0) throw new Error("--label requires at least one non-empty label");
for (const label of parts) {
if (seen.has(label)) continue;
labels.push(label);
seen.add(label);
}
}
return labels;
}
function positiveIntegerValuesOption(args: string[], name: string): number[] {
const values: number[] = [];
const seen = new Set<number>();
for (const raw of optionValues(args, name)) {
const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
if (parts.length === 0) throw new Error(`${name} requires at least one positive integer`);
for (const part of parts) {
const value = Number(part.replace(/^#/u, ""));
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} values must be positive issue numbers`);
if (seen.has(value)) continue;
values.push(value);
seen.add(value);
}
}
return values;
}
function positiveIntegerSingleOption(args: string[], name: string, defaultValue: number): number {
const raw = optionValue(args, name);
if (raw === undefined) return defaultValue;
const value = Number(raw.replace(/^#/u, ""));
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive issue number`);
return value;
}
function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const raw = optionValue(args, name);
if (raw === undefined) return defaultValue;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return Math.min(value, maxValue);
}
function positiveNumberOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const raw = optionValue(args, name);
if (raw === undefined) return defaultValue;
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
return Math.min(value, maxValue);
}
function validateEnumValue<T extends string>(name: string, raw: string, allowedValues: readonly T[]): T {
if ((allowedValues as readonly string[]).includes(raw)) return raw as T;
throw new Error(`unsupported ${name} ${raw}; supported values: ${allowedValues.join(",")}`);
}
function validateJsonFields<T extends string>(command: string, requested: string[] | undefined, allowedFields: readonly T[]): T[] | undefined {
if (requested === undefined) return undefined;
const allowed = new Set<string>(allowedFields);
const unsupported = requested.filter((field) => !allowed.has(field));
if (unsupported.length > 0) {
throw new Error(`unsupported ${command} --json field(s): ${unsupported.join(", ")}; supported fields: ${allowedFields.join(",")}`);
}
return requested as T[];
}
function parseIssueViewJsonFields(requested: string[] | undefined): IssueViewJsonField[] | undefined {
return validateJsonFields("gh issue read/view", requested, ISSUE_VIEW_JSON_FIELDS);
}
function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined {
return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS);
}
function parsePrListJsonFields(requested: string[] | undefined): PrListJsonField[] | undefined {
if (requested !== undefined) {
const listFields = new Set<string>(PR_LIST_JSON_FIELDS);
const closeoutFields = requested.filter((field) => (PR_CLOSEOUT_JSON_FIELDS as readonly string[]).includes(field));
const unsupported = requested.filter((field) => !listFields.has(field));
if (closeoutFields.length > 0) {
throw new Error(`unsupported gh pr list --json closeout field(s): ${closeoutFields.join(", ")}; use gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON} for mergeability/statusCheckRollup metadata; pr list supported fields: ${PR_LIST_JSON_FIELDS.join(",")}`);
}
if (unsupported.length > 0) {
throw new Error(`unsupported gh pr list --json field(s): ${unsupported.join(", ")}; supported fields: ${PR_LIST_JSON_FIELDS.join(",")}; closeout metadata requires gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON}`);
}
}
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 {
return sub === "read" || sub === "view";
}
function isPrReadCommand(sub: string | undefined): boolean {
return sub === "read" || sub === "view";
}
function allowsNumberTargetAlias(top: string | undefined, sub: string | undefined, third: string | undefined): boolean {
if (top === "preflight") return true;
if (top === "issue") {
if (sub === "read" || sub === "view" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "delete") return true;
if (sub === "comment") return true;
if (sub === "board-row" && ["get", "update", "add", "move", "delete", "upsert"].includes(third ?? "")) return true;
return false;
}
if (top === "pr") {
if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true;
if (sub === "comment") return true;
}
return false;
}
function parseIssueListState(args: string[]): IssueListState {
const raw = optionValue(args, "--state") ?? "open";
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState;
throw new Error(`unsupported gh issue list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`);
}
function parsePrListState(args: string[]): PrListState {
const raw = optionValue(args, "--state") ?? "all";
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as PrListState;
throw new Error(`unsupported gh pr list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`);
}
function parseBodyUpdateMode(args: string[]): BodyUpdateMode {
const raw = optionValue(args, "--mode") ?? "replace";
if ((BODY_UPDATE_MODES as readonly string[]).includes(raw)) return raw as 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;
throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`);
}
function parseBoardMutationSection(args: string[], name: "--section" | "--to"): BoardMutationSection | undefined {
const raw = optionValue(args, name);
if (raw === undefined) return undefined;
return validateEnumValue(name, raw, BOARD_MUTATION_SECTIONS);
}
function parseBoardGithubStatus(args: string[]): BoardGithubStatus | undefined {
const raw = optionValue(args, "--status");
if (raw === undefined) return undefined;
return validateEnumValue("--status", raw, BOARD_GITHUB_STATUSES);
}
function parseBoardRowField(args: string[]): BoardRowField | undefined {
const raw = optionValue(args, "--field");
if (raw === undefined) return undefined;
return validateEnumValue("--field", raw, BOARD_ROW_FIELDS);
}
function parseBoardRowUpsertValues(args: string[]): BoardRowUpsertValues {
return {
category: optionValue(args, "--category"),
branch: optionValue(args, "--branch"),
tasks: optionValue(args, "--tasks"),
summary: optionValue(args, "--summary"),
focus: optionValue(args, "--focus"),
validation: optionValue(args, "--validation"),
progress: optionValue(args, "--progress"),
status: parseBoardGithubStatus(args),
};
}
function validateKnownOptions(args: string[]): void {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) continue;
if (GH_FLAG_OPTIONS.has(arg)) continue;
if (GH_VALUE_OPTIONS.has(arg)) {
index += 1;
continue;
}
throw new Error(`unknown gh option: ${arg}`);
}
}
function positionalArgs(args: string[]): string[] {
const positionals: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (arg.startsWith("--")) {
if (GH_VALUE_OPTIONS.has(arg)) index += 1;
continue;
}
positionals.push(arg);
}
return positionals;
}
function isOwnerRepo(value: string): boolean {
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value);
}
function resolveRepoOption(args: string[]): string {
const [top, sub] = args;
const explicitRepo = optionValue(args, "--repo");
if ((top === "issue" || top === "pr") && sub === "list") {
const positionals = positionalArgs(args.slice(2));
if (positionals.length > 1) {
throw new Error(`gh ${top} list accepts at most one positional owner/repo; use --repo owner/name`);
}
const positionalRepo = positionals[0];
if (positionalRepo !== undefined) {
if (!isOwnerRepo(positionalRepo)) {
throw new Error(`gh ${top} list positional argument must be owner/repo; use --repo owner/name`);
}
if (explicitRepo !== undefined && explicitRepo !== positionalRepo) {
throw new Error(`gh ${top} list received positional repo ${positionalRepo} and --repo ${explicitRepo}; use one repo target, for example --repo ${positionalRepo}`);
}
return positionalRepo;
}
}
return explicitRepo ?? DEFAULT_REPO;
}
function parseOptions(args: string[]): GitHubOptions {
validateKnownOptions(args);
const [top, sub] = args;
const requestedJsonFields = commaListOption(args, "--json");
const limitMax = top === "pr" && (sub === "files" || sub === "diff")
? MAX_PR_FILES_LIMIT
: top === "issue" && (sub === "list" || sub === "stale-close" || sub === "scan-escape" || sub === "cleanup-plan")
? MAX_ISSUE_LIST_LIMIT
: 100;
return {
repo: resolveRepoOption(args),
dryRun: hasFlag(args, "--dry-run"),
raw: hasFlag(args, "--raw"),
full: hasFlag(args, "--full"),
limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : top === "issue" && sub === "stale-close" ? MAX_ISSUE_LIST_LIMIT : 30, limitMax),
inactiveHours: positiveNumberOption(args, "--inactive-hours", DEFAULT_STALE_CLOSE_INACTIVE_HOURS, MAX_STALE_CLOSE_INACTIVE_HOURS),
boardIssue: positiveIntegerSingleOption(args, "--board-issue", CODE_QUEUE_BOARD_TARGET_ISSUE),
knownMetaIssues: positiveIntegerValuesOption(args, "--known-meta-issue"),
ignoredIssues: positiveIntegerValuesOption(args, "--ignore-issue"),
draft: hasFlag(args, "--draft"),
notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"),
allowShortBody: hasFlag(args, "--allow-short-body"),
labels: labelsOption(args),
search: optionValue(args, "--search"),
title: optionValue(args, "--title"),
body: optionValue(args, "--body"),
bodyFile: optionValue(args, "--body-file"),
comment: optionValue(args, "--comment"),
commentFile: optionValue(args, "--comment-file"),
base: optionValue(args, "--base"),
head: optionValue(args, "--head"),
jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined,
issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined,
prListJsonFields: top === "pr" && sub === "list" ? parsePrListJsonFields(requestedJsonFields) : undefined,
prJsonFields: top === "pr" && isPrReadCommand(sub) ? parsePrReadJsonFields(requestedJsonFields) : undefined,
listState: top === "issue" && (sub === "list" || sub === "board-row" && args[2] === "list") ? parseIssueListState(args) : "open",
prListState: top === "pr" && sub === "list" ? parsePrListState(args) : "all",
mode: parseBodyUpdateMode(args),
expectUpdatedAt: optionValue(args, "--expect-updated-at"),
expectBodySha: optionValue(args, "--expect-body-sha"),
bodyProfile: parseIssueBodyProfile(args),
boardRowField: parseBoardRowField(args),
boardRowValue: optionValue(args, "--value"),
boardRowFile: optionValue(args, "--row-file"),
boardSection: parseBoardMutationSection(args, "--section"),
boardMoveTo: parseBoardMutationSection(args, "--to"),
boardGithubStatus: parseBoardGithubStatus(args),
boardRowUpsertValues: parseBoardRowUpsertValues(args),
mergeMethod: parsePullRequestMergeMethod(args),
deleteBranch: hasFlag(args, "--delete-branch"),
};
}
function parseNumber(raw: string | undefined, label: string): number {
if (raw === undefined) throw new Error(`${label} requires a number`);
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`);
return value;
}
function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult {
try {
return parseNumber(raw, label);
} catch (error) {
return validationError(label, repo, error instanceof Error ? error.message : String(error));
}
}
function parsePositionalNumberForCommand(repo: string, args: string[], startIndex: number, label: string): number | GitHubCommandResult {
const targets = positionalArgs(args.slice(startIndex));
if (targets.length !== 1) {
return validationError(label, repo, `${label} requires exactly one positive integer positional argument`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${repo}`,
`bun scripts/cli.ts gh ${label} --repo ${repo} <number>`,
],
});
}
return parseNumberForCommand(repo, targets[0], label);
}
function resolvedNumberOptionReference(kind: "issue" | "pr", command: string, repo: string, raw: string): GitHubResolvedNumberReference | GitHubCommandResult {
const number = parseNumberForCommand(repo, raw, command);
if (typeof number !== "number") return number;
return {
repo,
number,
shorthand: {
input: `--number ${number}`,
repo,
number,
source: "number-option",
standardCommand: `bun scripts/cli.ts gh ${command} ${number} --repo ${repo}`,
urlKind: kind,
},
};
}
function resolvePositionalNumberReference(kind: "issue" | "pr", args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult {
const targets = positionalArgs(args.slice(startIndex));
const numberOption = optionValue(args, "--number");
if (targets.length > 0 && numberOption !== undefined) {
return validationError(label, options.repo, `${label} accepts either a positional numeric target or --number, not both`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
if (numberOption !== undefined) return resolvedNumberOptionReference(kind, label, options.repo, numberOption);
if (targets.length !== 1) {
return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
const shorthand = parseOwnerRepoNumberShorthand(targets[0]);
if (shorthand !== null) {
const explicitRepo = optionValue(args, "--repo");
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, {
shorthand,
explicitRepo,
});
}
return { repo: shorthand.repo, number: shorthand.number, shorthand };
}
const number = parseNumberForCommand(options.repo, targets[0], label);
if (typeof number !== "number") return number;
return { repo: options.repo, number };
}
function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult {
const targets = positionalArgs(args.slice(startIndex));
const prOption = optionValue(args, "--pr");
const numberOption = optionValue(args, "--number");
if (prOption !== undefined && numberOption !== undefined) {
return validationError(label, options.repo, `${label} accepts either --pr or --number as a compatibility alias, not both`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
if (targets.length > 0 && (prOption !== undefined || numberOption !== undefined)) {
return validationError(label, options.repo, `${label} accepts either a positional PR target or a compatibility number option, not both`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
if (numberOption !== undefined) return resolvedNumberOptionReference("pr", label, options.repo, numberOption);
const effectiveTargets = prOption !== undefined ? [prOption] : targets;
if (effectiveTargets.length !== 1) {
return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
const shorthand = parseOwnerRepoNumberShorthand(effectiveTargets[0]);
if (shorthand !== null) {
const explicitRepo = optionValue(args, "--repo");
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, {
shorthand,
explicitRepo,
});
}
return { repo: shorthand.repo, number: shorthand.number, shorthand };
}
const number = parseNumberForCommand(options.repo, effectiveTargets[0], label);
if (typeof number !== "number") return number;
return { repo: options.repo, number };
}
function resolvePositionalIssueReference(args: string[], startIndex: number, label: string, options: GitHubOptions, allowNumberOption = true): GitHubResolvedNumberReference | GitHubCommandResult {
const targets = positionalArgs(args.slice(startIndex));
const numberOption = allowNumberOption ? optionValue(args, "--number") : undefined;
if (targets.length > 0 && numberOption !== undefined) {
return validationError(label, options.repo, `${label} accepts either a positional issue target or --number, not both`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
if (numberOption !== undefined) return resolvedNumberOptionReference("issue", label, options.repo, numberOption);
if (targets.length !== 1) {
return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
...(allowNumberOption ? [`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`] : []),
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
const shorthand = parseOwnerRepoNumberShorthand(targets[0]);
if (shorthand !== null) {
const explicitRepo = optionValue(args, "--repo");
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, {
shorthand,
explicitRepo,
});
}
return { repo: shorthand.repo, number: shorthand.number, shorthand };
}
const number = parseNumberForCommand(options.repo, targets[0], label);
if (typeof number !== "number") return number;
return { repo: options.repo, number };
}
function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthandReference | null {
if (raw === undefined) return null;
const match = /^([^/#\s]+)\/([^/#\s]+)#([1-9]\d*)$/u.exec(raw);
if (match === null) return null;
return {
input: raw,
repo: `${match[1]}/${match[2]}`,
number: Number(match[3]),
source: "owner-repo-number",
};
}
function parseGitHubIssueOrPrUrl(raw: string | undefined): GitHubShorthandReference | null {
if (raw === undefined) return null;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return null;
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
if (parsed.hostname.toLowerCase() !== "github.com" && parsed.hostname.toLowerCase() !== "www.github.com") return null;
const parts = parsed.pathname.split("/").filter((part) => part.length > 0);
if (parts.length < 4) return null;
const [owner, repoName, kind, numberRaw] = parts;
if (!owner || !repoName || (kind !== "issues" && kind !== "pull")) return null;
if (!/^[1-9]\d*$/u.test(numberRaw ?? "")) return null;
return {
input: raw,
repo: `${owner}/${repoName}`,
number: Number(numberRaw),
source: "github-url",
urlKind: kind === "issues" ? "issue" : "pr",
};
}
function parseReadViewTarget(raw: string | undefined): GitHubShorthandReference | null {
return parseOwnerRepoNumberShorthand(raw) ?? parseGitHubIssueOrPrUrl(raw);
}
function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] {
return [
`bun scripts/cli.ts gh ${kind} view ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`,
`bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`,
`bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)} [compatibility alias]`,
];
}
function readViewSupportedJsonFields(kind: "issue" | "pr"): string {
return kind === "issue"
? ISSUE_VIEW_JSON_FIELDS.join(",")
: "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
}
function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "view", _raw: string | undefined, options: GitHubOptions, args: string[]): GitHubResolvedNumberReference | GitHubCommandResult {
const command = `${kind} ${sub}`;
const targets = positionalArgs(args.slice(2));
if (targets.length > 1) {
return validationError(command, options.repo, `${command} accepts one positional target: number, GitHub ${kind} URL, or owner/repo#number`, {
supportedCommands: [
`bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
`bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`,
`bun scripts/cli.ts gh ${kind} view owner/name#<number> --raw`,
],
});
}
const raw = targets[0];
if (optionWasProvided(args, "--number")) {
const numberAliasRaw = optionValue(args, "--number");
const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command);
if (typeof aliasNumber !== "number") return aliasNumber;
if (raw !== undefined) {
const parsedRaw = parseNumberForCommand(options.repo, raw, command);
if (typeof parsedRaw !== "number") return parsedRaw;
if (parsedRaw !== aliasNumber) {
return validationError(command, options.repo, `${command} positional number ${parsedRaw} conflicts with --number ${aliasNumber}; use one target number.`, {
positionalNumber: parsedRaw,
numberAlias: aliasNumber,
supportedCommands: readViewSupportedCommands(kind, options.repo, aliasNumber),
});
}
}
return {
repo: options.repo,
number: aliasNumber,
shorthand: {
input: `--number ${aliasNumber}`,
repo: options.repo,
number: aliasNumber,
source: "number-option",
standardCommand: `bun scripts/cli.ts gh ${kind} view ${aliasNumber} --repo ${options.repo}`,
},
};
}
const shorthand = parseReadViewTarget(raw);
if (shorthand !== null) {
if (shorthand.urlKind !== undefined && shorthand.urlKind !== kind) {
return validationError(command, shorthand.repo, `${command} target ${shorthand.input} is a GitHub ${shorthand.urlKind} URL, not a ${kind} URL.`, {
shorthand,
supportedCommands: readViewSupportedCommands(shorthand.urlKind, shorthand.repo, shorthand.number),
});
}
const explicitRepo = optionValue(args, "--repo");
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
const message = `${command} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided. Use either the shorthand or a matching --repo, not both.`;
return validationError(command, explicitRepo, message, {
message,
shorthand,
explicitRepo,
supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number),
});
}
return { repo: shorthand.repo, number: shorthand.number, shorthand };
}
const parsed = parseNumberForCommand(options.repo, raw, command);
if (typeof parsed !== "number") {
return {
...parsed,
supportedCommands: [
`bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
`bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`,
`bun scripts/cli.ts gh ${kind} view owner/name#<number> --raw`,
],
};
}
return { repo: options.repo, number: parsed };
}
function issueReadJsonFields(options: GitHubOptions): IssueViewJsonField[] | undefined {
return options.raw || options.full ? ISSUE_VIEW_JSON_FIELDS.slice() : options.jsonFields;
}
function prReadJsonFields(options: GitHubOptions): PrReadJsonField[] | undefined {
return options.raw || options.full ? PR_READ_JSON_FIELDS.slice() : options.prJsonFields;
}
function readDisclosureOptions(options: GitHubOptions, shorthand: GitHubShorthandReference | undefined): Record<string, unknown> | null {
if (!options.raw && !options.full && shorthand === undefined) return null;
return {
...(options.raw ? { raw: true } : {}),
...(options.full ? { full: true } : {}),
fullDisclosure: options.raw || options.full,
shorthand: shorthand ?? null,
...(shorthand?.source === "number-option" ? {
compatibilityHint: "--number is accepted for low-friction compatibility; standard gh syntax uses a positional number or URL.",
standardCommand: shorthand.standardCommand,
} : {}),
};
}
function numberOptionCompatibilityHint(resolved: GitHubResolvedNumberReference): Record<string, unknown> | null {
if (resolved.shorthand?.source !== "number-option") return null;
return {
acceptedOption: "--number",
compatibility: true,
message: "--number is accepted for low-friction compatibility; prefer the standard positional number target shown in standardCommand.",
standardCommand: resolved.shorthand.standardCommand ?? null,
};
}
async function withNumberOptionHint(result: GitHubCommandResult | Promise<GitHubCommandResult>, resolved: GitHubResolvedNumberReference): Promise<GitHubCommandResult> {
const output = await result;
const hint = numberOptionCompatibilityHint(resolved);
if (hint === null) return output;
return {
...output,
standardSyntaxHint: hint,
};
}
function isGitHubCommandResult(value: GitHubResolvedNumberReference | GitHubCommandResult): value is GitHubCommandResult {
return typeof (value as { ok?: unknown }).ok === "boolean";
}
function unknownGhOptionDetails(args: string[], option: string): Record<string, unknown> {
const [top, sub, third] = args;
const details: Record<string, unknown> = {
unsupportedOption: option,
helpCommand: "bun scripts/cli.ts gh help",
};
if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) {
const shorthand = parseReadViewTarget(third);
const repo = shorthand?.repo ?? optionValue(args, "--repo") ?? "owner/name";
const number = shorthand?.number ?? (third !== undefined && /^\d+$/u.test(third) ? Number(third) : 0);
details.supportedCommands = number > 0
? readViewSupportedCommands(top, repo, number)
: [
`bun scripts/cli.ts gh ${top} view <number> --repo owner/name --json ${readViewSupportedJsonFields(top)}`,
`bun scripts/cli.ts gh ${top} view owner/name#<number> --raw`,
];
}
return details;
}
function readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource: Record<string, unknown> } {
if (path === "-") {
return { body: readFileSync(0, "utf8"), bodySource: { kind: "stdin", path: "-" } };
}
if (!existsSync(path)) throw new Error(`body file not found: ${path}`);
return { body: readFileSync(path, "utf8"), bodySource: { kind: "body-file", path } };
}
function readOptionalMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file or --body`);
if (options.bodyFile !== undefined) {
return readMarkdownBodyFileOrStdin(options.bodyFile);
}
if (options.body !== undefined) {
return {
body: options.body,
bodySource: {
kind: "inline",
warning: options.body.includes("\n") ? "inline body contains real newlines; --body-file is safer for generated Markdown" : "inline body is intended only for short single-line text",
},
};
}
return null;
}
function readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } {
const body = readOptionalMarkdownBody(options, command);
if (body !== null) return body;
throw new Error(`${command} requires --body-file <file> or --body <text>`);
}
function secretLikeInlineFindings(body: string): string[] {
const findings: string[] = [];
if (/\bgh[pousr]_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-token-like-value");
if (/\bgithub_pat_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-pat-like-value");
if (/\b(?:token|password|passwd|secret|api[_-]?key)\s*[:=]\s*["']?[^"'\s,;]{6,}/iu.test(body)) findings.push("credential-assignment-like-value");
if (/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/iu.test(body)) findings.push("bearer-token-like-value");
return findings;
}
function readIssueCommentBody(options: GitHubOptions): { body: string; bodySource: Record<string, unknown> } {
if (options.bodyFile !== undefined && options.body !== undefined) {
throw new Error("issue comment create accepts only one body source: --body-file or --body");
}
if (options.bodyFile !== undefined) {
return readMarkdownBodyFileOrStdin(options.bodyFile);
}
if (options.body === undefined) throw new Error("issue comment create requires --body-file <file> or --body <text>");
const body = options.body;
const trimmed = body.trim();
const shellPollution = shellPollutionEvidence(body);
const secretLike = secretLikeInlineFindings(body);
if (trimmed.length === 0) {
throw new Error("issue comment create --body must not be blank; use --body-file for reviewed Markdown");
}
if (body.length > MAX_INLINE_ISSUE_COMMENT_BODY_CHARS) {
throw new Error(`issue comment create --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-file for long Markdown`);
}
if (body.includes("\n") || body.includes("\r")) {
throw new Error("issue comment create --body supports short single-line text only; use --body-file for multiline Markdown");
}
if (shellPollution.length > 0) {
throw new Error(`issue comment create --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-file with reviewed Markdown bytes`);
}
if (secretLike.length > 0) {
throw new Error(`issue comment create --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`);
}
return {
body,
bodySource: {
kind: "inline",
maxInlineBodyChars: MAX_INLINE_ISSUE_COMMENT_BODY_CHARS,
warning: "inline issue comments are intended only for short single-line text; use --body-file for Markdown or generated content",
},
};
}
function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
if (options.comment === undefined && options.commentFile === undefined) return null;
if (options.comment !== undefined && options.commentFile !== undefined) {
throw new Error(`${command} --comment and --comment-file are mutually exclusive`);
}
if (options.body !== undefined || options.bodyFile !== undefined) {
throw new Error(`${command} --comment or --comment-file cannot be combined with --body or --body-file`);
}
if (options.commentFile !== undefined) {
return readIssueCommentBody({ ...options, body: undefined, bodyFile: options.commentFile });
}
return readIssueCommentBody({ ...options, body: options.comment, bodyFile: undefined });
}
function tokenFromEnvironment(): GitHubTokenProbe {
if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) {
return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false };
}
if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.length > 0) {
return { present: true, source: "GITHUB_TOKEN", ghFallbackAttempted: false };
}
return { present: false, source: null, ghFallbackAttempted: false };
}
function ghBinaryPath(): string | null {
try {
const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
return output.length > 0 ? output : null;
} catch {
return null;
}
}
function ghAuthToken(): string | null {
try {
const output = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
return output.length > 0 ? output : null;
} catch {
return null;
}
}
function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } {
const envProbe = tokenFromEnvironment();
if (envProbe.present) {
const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null;
return { token, probe: envProbe };
}
if (!allowGhFallback) return { token: null, probe: envProbe };
const ghPath = ghBinaryPath();
if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
const token = ghAuthToken();
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } };
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
}
function repoParts(repo: string): { owner: string; name: string } {
const [owner, name, extra] = repo.split("/");
if (!owner || !name || extra !== undefined) throw new Error("--repo must be in owner/name format");
return { owner, name };
}
function preview(text: string): string {
return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}...` : text;
}
function previewLines(text: string, maxLines = 12): string[] {
return text.split(/\r?\n/).slice(0, maxLines).map((line) => preview(line));
}
function bodySha(text: string): string {
return createHash("sha256").update(text, "utf8").digest("hex");
}
function normalizeExpectedSha(raw: string): string {
const value = raw.replace(/^sha256:/iu, "").toLowerCase();
if (!/^[a-f0-9]{64}$/u.test(value)) throw new Error("--expect-body-sha must be a 64-character SHA-256 hex value, optionally prefixed with sha256:");
return value;
}
function shellPollutionEvidence(body: string): string[] {
const evidence: string[] = [];
if (body.includes("\\n")) evidence.push("literal-backslash-n");
if (body.includes("\\t")) evidence.push("literal-backslash-t");
if (body.includes("\\`")) evidence.push("escaped-backtick");
if (/\\x1b|\\u001b|\u001b\[/u.test(body)) evidence.push("ansi-escape");
if (/\r(?!\n)/u.test(body)) evidence.push("bare-carriage-return");
return evidence;
}
function bodySafetySignals(body: string): Record<string, unknown> {
const evidence = shellPollutionEvidence(body);
return {
bodyChars: body.length,
bodyTrimmedChars: body.trim().length,
bodyLines: body.length === 0 ? 0 : body.split(/\r?\n/).length,
bodySha: bodySha(body),
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body),
shellPollution: {
suspected: evidence.length > 0,
evidence,
},
};
}
function dryRunBody(repo: string, title: string | undefined, body: string): Record<string, unknown> {
return {
repo,
...(title === undefined ? {} : { title }),
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
...bodySafetySignals(body),
};
}
function normalizeNewlines(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
function isTimelineHeading(line: string): boolean {
return /^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u.test(line.trimEnd());
}
function isCommanderBriefUpdateHeading(line: string): boolean {
const trimmed = line.trimEnd();
return COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS.some((pattern) => pattern.test(trimmed));
}
function commanderBriefUpdateHeadings(body: string): string[] {
return normalizeNewlines(body)
.split("\n")
.map((line) => line.trimEnd())
.filter((line) => isCommanderBriefUpdateHeading(line));
}
function codeQueueBoardHwlabProductRoutingFindings(body: string | undefined): Array<Record<string, unknown>> {
if (body === undefined) return [];
const findings: Array<Record<string, unknown>> = [];
const directIssuePatterns = [
{ kind: "hwlab-repo-issue-url", pattern: /(?:https?:\/\/github\.com\/)?pikasTech\/HWLAB\/issues\/\d+/iu },
{ kind: "hwlab-repo-issue-short-ref", pattern: /\bpikasTech\/HWLAB#\d+\b/iu },
{ kind: "hwlab-short-issue-ref", pattern: /\bHWLAB#\d+\b/iu },
];
const hwlabContextPattern = /\bHWLAB\b|pikasTech\/HWLAB|hwlab-/iu;
const strongProductPattern = /hwlab-cloud-web|Cloud Workbench|DEV-LIVE|res_boxsimu|hwlab-patch-panel|patch panel|M3\s*(?:虚拟硬件|可信闭环)/iu;
const genericProductPattern = /用户反馈|user feedback/iu;
const governanceContextPattern = /\b(?:commander|Code Queue|CLI|infra|governance|guard|guardrail|routing|misfile|board)\b|治理|调度|基础设施|守卫|边界|分流|看板/iu;
const lines = normalizeNewlines(body).split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const isTableRow = line.trim().startsWith("|") && !isMarkdownTableSeparator(line);
const strongProductSignal = strongProductPattern.test(line);
const genericProductSignal = genericProductPattern.test(line);
const governanceContext = governanceContextPattern.test(line);
for (const definition of directIssuePatterns) {
const match = definition.pattern.exec(line);
if (match !== null && !governanceContext && (isTableRow || strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) {
findings.push({
kind: definition.kind,
lineNumber: index + 1,
match: match[0],
snippet: preview(line.trim()),
});
}
}
if (isTableRow && !governanceContext && (strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) {
const match = strongProductSignal ? strongProductPattern.exec(line) : genericProductPattern.exec(line);
findings.push({
kind: "hwlab-product-signal",
lineNumber: index + 1,
match: match?.[0] ?? "HWLAB product signal",
snippet: preview(line.trim()),
});
}
if (findings.length >= 12) break;
}
return findings;
}
function codeQueueBoardHwlabProductRoutingHint(boardIssueNumber: number, body?: string): Record<string, unknown> | null {
if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null;
const findings = codeQueueBoardHwlabProductRoutingFindings(body);
return {
warning: "#20 is only for UniDesk commander/Code Queue/CLI/infra governance; HWLAB user/product issues belong in pikasTech/HWLAB.",
route: "Create or update the corresponding issue in pikasTech/HWLAB; keep #20 rows limited to UniDesk governance or infrastructure support work.",
forbiddenPatterns: [
"pikasTech/HWLAB#<N>",
"github.com/pikasTech/HWLAB/issues/<N>",
"HWLAB#<N>",
"HWLAB product/live validation rows",
],
allowedScope: ["commander governance", "Code Queue supervision", "UniDesk CLI guardrails", "UniDesk infrastructure"],
detected: findings.length > 0,
findings,
};
}
function codeQueueBoardHintHasHwlabProductRouting(hint: Record<string, unknown> | null): boolean {
if (hint === null) return false;
const routing = hint.hwlabProductRouting;
return typeof routing === "object" && routing !== null && (routing as { detected?: unknown }).detected === true;
}
function codeQueueBoardHintHasCommanderBriefUpdates(hint: Record<string, unknown> | null): boolean {
if (hint === null) return false;
const commanderBrief = hint.commanderBrief;
return typeof commanderBrief === "object" && commanderBrief !== null && (commanderBrief as { detected?: unknown }).detected === true;
}
function codeQueueBoardCommanderBriefHint(boardIssueNumber: number, body?: string): Record<string, unknown> | null {
if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null;
const headings = body === undefined ? [] : commanderBriefUpdateHeadings(body);
const hwlabProductRouting = codeQueueBoardHwlabProductRoutingHint(boardIssueNumber, body);
const commanderBriefDetected = headings.length > 0;
const hwlabProductDetected = codeQueueBoardHintHasHwlabProductRouting({ hwlabProductRouting });
return {
warning: "#20 is the long-term UniDesk commander/Code Queue/CLI/infra governance board only; do not write daily commander brief updates or HWLAB product/user issue tracking into #20.",
route: commanderBriefDetected
? "Move daily progress notes to the daily rolling commander brief issue referenced in #20's 指挥简报索引, using --body-profile commander-brief."
: hwlabProductDetected
? "Move HWLAB product/user issue tracking to pikasTech/HWLAB; keep #20 limited to UniDesk commander/Code Queue/CLI/infra governance."
: "Use #20 only for UniDesk commander/Code Queue/CLI/infra governance rows.",
forbiddenHeadings: ["## 更新 YYYY-MM-DD HH:mm 北京时间", "## YYYY-MM-DD HH:mm 北京时间指挥更新", "### YYYY-MM-DD HH:mm CST..."],
suggestedCommand: "bun scripts/cli.ts gh issue update <daily-brief-issue> --mode replace --body-file <file> --body-profile commander-brief --expect-body-sha <sha>",
detected: commanderBriefDetected || hwlabProductDetected,
detectedHeadings: headings,
commanderBrief: {
detected: commanderBriefDetected,
detectedHeadings: headings,
},
hwlabProductRouting,
};
}
function extractTimelineSections(markdown: string): CommanderBriefSection[] {
const normalized = normalizeNewlines(markdown);
const lines = normalized.split("\n");
const sections: CommanderBriefSection[] = [];
let currentStart = -1;
for (let index = 0; index < lines.length; index += 1) {
if (!isTimelineHeading(lines[index])) continue;
if (currentStart !== -1) {
sections.push({
heading: lines[currentStart].trimEnd(),
text: lines.slice(currentStart, index).join("\n").trimEnd(),
startLine: currentStart + 1,
});
}
currentStart = index;
}
if (currentStart !== -1) {
sections.push({
heading: lines[currentStart].trimEnd(),
text: lines.slice(currentStart).join("\n").trimEnd(),
startLine: currentStart + 1,
});
}
return sections.filter((section) => section.text.length > 0);
}
export function commanderBriefDiff(oldBodyRaw: string, newBodyRaw: string): CommanderBriefDiff {
const oldBody = normalizeNewlines(oldBodyRaw);
const newBody = normalizeNewlines(newBodyRaw);
if (oldBody === newBody) {
return {
ok: false,
mode: "identical",
message: "",
chars: 0,
sections: [],
sectionCount: 0,
skippedReason: "issue body is unchanged",
};
}
if (newBody.startsWith(oldBody)) {
const suffix = newBody.slice(oldBody.length);
const sections = extractTimelineSections(suffix).map((section) => section.text);
const message = sections.join("\n\n").trim();
if (message.length === 0) {
return {
ok: false,
mode: "append-only",
message: "",
chars: 0,
sections: [],
sectionCount: 0,
skippedReason: "append-only suffix contains no new commander brief update section",
};
}
return { ok: true, mode: "append-only", message, chars: message.length, sections, sectionCount: sections.length };
}
const oldSections = new Set(extractTimelineSections(oldBody).map((section) => section.text));
const allNewSections = extractTimelineSections(newBody);
const newSections = allNewSections.map((section) => section.text).filter((section) => !oldSections.has(section));
if (newSections.length === 0) {
return {
ok: false,
mode: "heading-diff",
message: "",
chars: 0,
sections: [],
sectionCount: 0,
skippedReason: "no new commander brief update section found; non-timeline edits are not notified",
};
}
const duplicateNewHeadings = allNewSections.some((section, index) => allNewSections.findIndex((candidate) => candidate.heading === section.heading) !== index);
const oldHeadings = new Set(extractTimelineSections(oldBody).map((section) => section.heading));
const ambiguousChangedHeadings = allNewSections.some((section) => oldHeadings.has(section.heading) && !oldSections.has(section.text));
if (duplicateNewHeadings || ambiguousChangedHeadings) {
return {
ok: false,
mode: "unreliable",
message: "",
chars: 0,
sections: [],
sectionCount: 0,
skippedReason: duplicateNewHeadings
? "duplicate update headings make commander brief diff unreliable"
: "an existing update section changed; cannot reliably isolate only new timeline content",
};
}
const message = newSections.join("\n\n").trim();
return { ok: true, mode: "heading-diff", message, chars: message.length, sections: newSections, sectionCount: newSections.length };
}
function envEnabled(name: string, defaultValue: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return defaultValue;
return !["0", "false", "no", "off", "disabled"].includes(raw.toLowerCase());
}
function positiveEnvInteger(name: string, defaultValue: number): number {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return defaultValue;
const value = Number(raw);
return Number.isInteger(value) && value > 0 ? value : defaultValue;
}
function commanderBriefClaudeQqConfig(): ClaudeQqConfig {
const targetTypeRaw = (process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE ?? "private").toLowerCase();
const targetType: ClaudeQqTargetType = targetTypeRaw === "group" ? "group" : "private";
return {
enabled: envEnabled("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED", true),
baseUrl: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL,
targetType,
userId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID,
groupId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID,
timeoutMs: positiveEnvInteger("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS", 15_000),
};
}
function maskedTarget(config: ClaudeQqConfig): Record<string, unknown> {
if (config.targetType === "group") return { targetType: "group", groupId: config.groupId === undefined ? null : maskId(config.groupId) };
return { targetType: "private", userId: config.userId === undefined ? null : maskId(config.userId) };
}
function maskId(value: string): string {
if (value.length <= 4) return "*".repeat(value.length);
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
function sanitizeUrlForOutput(value: string): string {
try {
const parsed = new URL(value);
parsed.username = parsed.username.length > 0 ? "***" : "";
parsed.password = parsed.password.length > 0 ? "***" : "";
parsed.search = parsed.search.length > 0 ? "?..." : "";
parsed.hash = "";
return parsed.toString();
} catch {
return value.includes("?") ? `${value.split("?")[0]}?...` : value;
}
}
function claudeQqPayload(config: ClaudeQqConfig, message: string): Record<string, unknown> {
if (config.targetType === "group") return { targetType: "group", groupId: config.groupId ?? "", message };
return { targetType: "private", userId: config.userId ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, message };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function proxiedClaudeQqBasePath(baseUrl: string): string | null {
try {
const parsed = new URL(baseUrl);
const path = parsed.pathname.replace(/\/$/u, "");
if (parsed.hostname === "backend-core" && path === "/api/microservices/claudeqq/proxy") return path;
if ((parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.port === "8080" && path === "/api/microservices/claudeqq/proxy") return path;
return null;
} catch {
return baseUrl === "/api/microservices/claudeqq/proxy" ? baseUrl : null;
}
}
function normalizeClaudeQqEndpoint(basePath: string, endpoint: string): string {
return `${basePath.replace(/\/$/u, "")}${endpoint}`;
}
function claudeQqResponseOk(response: unknown): boolean {
if (!isRecord(response)) return false;
if (response.ok === false) return false;
if (typeof response.status === "number" && (response.status < 200 || response.status >= 300)) return false;
const body = response.body;
if (isRecord(body) && (body.ok === false || body.success === false)) return false;
return response.ok === true || typeof response.status === "number";
}
function summarizeClaudeQqProxyFailure(response: unknown, endpoint: string): ClaudeQqEndpointResult {
if (!isRecord(response)) return { ok: false, endpoint, degradedReason: "invalid-response", message: "ClaudeQQ proxy returned a non-object response", response };
const status = typeof response.status === "number" ? response.status : undefined;
const stdoutTail = typeof response.stdoutTail === "string" ? response.stdoutTail : "";
let parsedTail: unknown = null;
if (stdoutTail.length > 0) {
try {
parsedTail = JSON.parse(stdoutTail.trim()) as unknown;
} catch {
parsedTail = null;
}
}
return {
ok: false,
endpoint,
status,
degradedReason: response.ok === false ? "microservice-proxy-failed" : "invalid-response",
message: typeof response.error === "string"
? response.error
: typeof response.stderrTail === "string" && response.stderrTail.length > 0
? response.stderrTail.slice(-500)
: "ClaudeQQ proxy request failed",
response: parsedTail ?? response,
};
}
async function sendClaudeQqEndpoint(config: ClaudeQqConfig, endpoint: string, payload: Record<string, unknown>): Promise<ClaudeQqEndpointResult> {
const basePath = proxiedClaudeQqBasePath(config.baseUrl);
if (basePath !== null) {
const response = coreInternalFetch(normalizeClaudeQqEndpoint(basePath, endpoint), {
method: "POST",
body: payload,
maxResponseBytes: 240_000,
timeoutMs: config.timeoutMs,
});
if (claudeQqResponseOk(response)) return { ok: true, endpoint, status: isRecord(response) && typeof response.status === "number" ? response.status : 200, response };
return summarizeClaudeQqProxyFailure(response, endpoint);
}
return {
ok: false,
endpoint,
degradedReason: "notification-path-unavailable",
message: "ClaudeQQ notifications must use backend-core /api/microservices/claudeqq/proxy; local skill, powershell, direct host, and ad-hoc ClaudeQQ URLs are not supported.",
response: {
baseUrl: sanitizeUrlForOutput(config.baseUrl),
requiredBaseUrl: DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL,
recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw",
},
};
}
async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: string): Promise<ClaudeQqSendResult> {
const target = maskedTarget(config);
if (!config.enabled) {
return { ok: true, attempted: false, skipped: true, skippedReason: "UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED disabled", target };
}
if (config.targetType === "group" && (config.groupId === undefined || config.groupId.length === 0)) {
return { ok: false, attempted: false, skipped: true, skippedReason: "group target requires UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID", target };
}
const payload = claudeQqPayload(config, message);
const first = await sendClaudeQqEndpoint(config, "/api/push/text", payload);
if (first.ok) return { ok: true, attempted: true, endpoint: first.endpoint, status: first.status, response: first.response, target };
const second = await sendClaudeQqEndpoint(config, "/api/send/text", payload);
if (second.ok) return { ok: true, attempted: true, endpoint: second.endpoint, status: second.status, response: second.response, target };
return {
ok: false,
attempted: true,
endpoint: second.endpoint,
status: second.status ?? first.status,
degradedReason: second.degradedReason ?? first.degradedReason ?? "claudeqq-send-failed",
message: second.message ?? first.message ?? "ClaudeQQ send failed",
response: { attempts: [first, second] },
target,
};
}
function commanderBriefNotificationPlan(issueNumber: number, body: string, diff: CommanderBriefDiff, config: ClaudeQqConfig): Record<string, unknown> {
const proxyBasePath = proxiedClaudeQqBasePath(config.baseUrl);
return {
enabled: config.enabled,
issueNumber,
bodyChars: body.length,
diff: {
ok: diff.ok,
mode: diff.mode,
chars: diff.chars,
sectionCount: diff.sectionCount,
skippedReason: diff.skippedReason ?? null,
preview: diff.ok ? preview(diff.message) : "",
previewLines: diff.ok ? previewLines(diff.message) : [],
containsLiteralBackslashN: diff.message.includes("\\n"),
containsBackticks: diff.message.includes("`"),
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message),
},
claudeqq: {
dryRun: true,
wouldSend: config.enabled && diff.ok && proxyBasePath !== null,
baseUrl: sanitizeUrlForOutput(config.baseUrl),
proxyBasePath,
servicePath: proxyBasePath === null ? null : normalizeClaudeQqEndpoint(proxyBasePath, "/api/push/text"),
target: maskedTarget(config),
timeoutMs: config.timeoutMs,
...(proxyBasePath === null
? {
blockedReason: "notification-path-unavailable",
recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw",
}
: {}),
},
};
}
function writeBodyPlan(command: "issue create" | "issue comment create" | "pr create" | "pr comment", repo: string, body: string, bodySource: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
const isIssueWrite = command === "issue create" || command === "issue comment create";
const source = String(bodySource.kind ?? "unknown");
const requestBody: Record<string, unknown> = {
bodyChars: body.length,
bodySource,
};
if (command === "issue create" && typeof extra.title === "string") requestBody.title = extra.title;
if (command === "issue create" && Array.isArray(extra.labels)) requestBody.labels = extra.labels;
return {
repo,
bodySource,
source,
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
...bodySafetySignals(body),
request: {
method: "POST",
path: isIssueWrite
? (command === "issue create" ? "/repos/{owner}/{repo}/issues" : "/repos/{owner}/{repo}/issues/{issue_number}/comments")
: (command === "pr create" ? "/repos/{owner}/{repo}/pulls" : "/repos/{owner}/{repo}/issues/{issue_number}/comments"),
body: requestBody,
},
validation: {
source,
rawText: source === "body-file"
? "read from file bytes without shell interpolation"
: source === "stdin"
? "read from stdin bytes"
: "short inline argument accepted only by command-specific safety rules",
},
...extra,
};
}
function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition {
if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed";
return "infra-blocked";
}
function sanitizedErrorDetails(parsed: unknown): unknown {
if (typeof parsed !== "object" || parsed === null) return parsed;
const source = parsed as Record<string, unknown>;
const details: Record<string, unknown> = {};
if ("message" in source) details.message = source.message;
if ("documentation_url" in source) details.documentationUrl = source.documentation_url;
if ("status" in source) details.status = source.status;
if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5);
if ("raw" in source) details.raw = source.raw;
return details;
}
function errorProperty(value: unknown, key: string): string | null {
if (!isRecord(value)) return null;
const item = value[key];
return typeof item === "string" && item.length > 0 ? item : null;
}
function errorChainText(error: unknown): string {
const parts: string[] = [];
let current: unknown = error;
const seen = new Set<unknown>();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
if (current instanceof Error) {
parts.push(current.name, current.message);
current = (current as Error & { cause?: unknown }).cause;
continue;
}
if (isRecord(current)) {
for (const key of ["name", "message", "code", "errno", "syscall", "hostname", "host"]) {
const value = errorProperty(current, key);
if (value !== null) parts.push(value);
}
current = current.cause;
continue;
}
parts.push(String(current));
break;
}
return parts.join(" ").replace(/\s+/gu, " ").trim();
}
function firstErrorProperty(error: unknown, key: string): string | null {
let current: unknown = error;
const seen = new Set<unknown>();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
const value = errorProperty(current, key);
if (value !== null) return value;
current = isRecord(current) ? current.cause : current instanceof Error ? (current as Error & { cause?: unknown }).cause : null;
}
return null;
}
function classifyGitHubFetchFailure(error: unknown): {
reason: GitHubDegradedReason;
message: string;
retryable?: boolean;
commanderAction?: string;
details: Record<string, unknown>;
} {
const text = errorChainText(error);
const lower = text.toLowerCase();
const isProxyFailure = /\bproxy\b/u.test(lower) && /could not resolve|connect|tunnel|proxy/i.test(text);
const isGitHubTransient = !isProxyFailure && (
/temporary failure in name resolution/iu.test(text)
|| /\b(eai_again|enotfound|etimedout|econnreset|econnrefused|ehostunreach|enetunreach)\b/iu.test(text)
|| /getaddrinfo|could not resolve host|name or service not known|failed to connect|connection timed out|connection reset|connection closed|other side closed|network is unreachable|socket connection/iu.test(text)
);
if (!isGitHubTransient) {
return {
reason: "network-proxy-failed",
message: text.length > 0 ? text : "GitHub request failed before receiving an HTTP response",
details: {
scope: "github-api",
transient: false,
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
errorCode: firstErrorProperty(error, "code"),
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
},
};
}
return {
reason: "github-transient",
message: text.length > 0 ? text : "GitHub DNS/API connection failed before receiving an HTTP response",
retryable: true,
commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh",
details: {
scope: "github-api",
transient: true,
retryable: true,
authFailure: false,
semanticFailure: false,
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
errorCode: firstErrorProperty(error, "code"),
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
commanderAction: "retry/backoff; if the Code Queue task heartbeat or trace is fresh, keep supervising instead of closing or requeueing business work",
valuesPrinted: false,
},
};
}
function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload {
return {
ok: false,
degradedReason: reason,
runnerDisposition: runnerDisposition(reason),
message,
...extra,
};
}
function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record<string, unknown> = {}): GitHubCommandResult {
return {
ok: false,
command,
repo,
degradedReason: error.degradedReason,
runnerDisposition: error.runnerDisposition,
details: error,
...extra,
};
}
function validationError(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
const error = errorPayload("validation-failed", message, { details: extra });
return commandError(command, repo, error, extra);
}
function unsupportedCommand(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
const error = errorPayload("unsupported-command", message, { details: extra });
return commandError(command, repo, error, { message, ...extra });
}
async function parseGitHubResponse(response: Response): Promise<unknown> {
const text = await response.text();
if (text.length === 0) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return { raw: preview(text) };
}
}
function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason {
const lower = message.toLowerCase();
const acceptedScopes = response.headers.get("x-accepted-oauth-scopes");
if (status === 401) return "auth-failed";
if (status === 403) {
if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient";
if (path.startsWith("/repos/")) return "repo-forbidden";
return "permission-denied";
}
if (status === 404) {
if (/\/pulls\/\d+/u.test(path)) return "pr-not-found";
if (/\/issues\/\d+/u.test(path)) return "issue-not-found";
if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed";
return "repo-not-found";
}
if (status === 422) return "validation-failed";
return "invalid-response";
}
async function githubRequest<T>(
token: string,
method: string,
path: string,
body?: 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}${path}`, {
method,
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: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
const classified = classifyGitHubFetchFailure(error);
return errorPayload(classified.reason, classified.message, {
request: { method, path },
details: classified.details,
retryable: classified.retryable,
commanderAction: classified.commanderAction,
});
} 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, path, 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, path },
});
}
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) {
const classified = classifyGitHubFetchFailure(error);
return errorPayload(classified.reason, classified.message, {
request: { method: "POST", path: "/graphql" },
details: classified.details,
retryable: classified.retryable,
commanderAction: classified.commanderAction,
});
} 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) {
return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
}
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) {
return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
}
return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), {
degraded: ["missing-token"],
token: tokenProbe,
});
}
return null;
}
function isGitHubError(value: unknown): value is GitHubErrorPayload {
return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value;
}
function issueBodyReadCommands(repo: string, issueNumber: number): Record<string, string> {
return {
body: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json body`,
full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
};
}
function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> {
return {
comments: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json comments`,
full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
};
}
function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> {
const explicitFullDisclosure = options.raw || options.full;
return {
defaultCompact: dryRun || !explicitFullDisclosure,
explicitFullDisclosure,
fullBodyIncluded: explicitFullDisclosure && !dryRun,
bodyOmitted: dryRun || !explicitFullDisclosure,
dryRunBoundedPreview: dryRun,
note: explicitFullDisclosure && !dryRun
? "The returned issue object includes the full body because --full or --raw was explicitly requested."
: "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.",
readCommands: issueBodyReadCommands(repo, issueNumber),
};
}
function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; previewLineCount?: number } = {}): Record<string, unknown> {
const body = issue.body ?? "";
const includeBody = options.includeBody ?? true;
const summary: Record<string, unknown> = {
id: issue.id,
number: issue.number,
title: issue.title,
state: issue.state,
closed: issue.state === "closed",
closedAt: issue.closed_at ?? null,
url: issue.html_url,
author: issue.user?.login ?? null,
createdAt: issue.created_at ?? null,
updatedAt: issue.updated_at ?? null,
commentCount: issue.comments ?? null,
bodyChars: body.length,
bodySha: bodySha(body),
};
if (includeBody) {
summary.body = body;
} else {
summary.bodyOmitted = true;
summary.fullBodyIncluded = false;
summary.bodyPreview = preview(body);
summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8);
}
return summary;
}
function issueLifecycleSummary(issue: GitHubIssue): Record<string, unknown> {
return {
id: issue.id,
number: issue.number,
title: issue.title,
state: issue.state,
closed: issue.state === "closed",
closedAt: issue.closed_at ?? null,
url: issue.html_url,
author: issue.user?.login ?? null,
createdAt: issue.created_at ?? null,
updatedAt: issue.updated_at ?? null,
commentCount: issue.comments ?? null,
labels: issueLabelNames(issue),
bodyChars: (issue.body ?? "").length,
bodySha: bodySha(issue.body ?? ""),
bodyOmitted: true,
fullBodyIncluded: false,
};
}
function issueLifecycleDisclosure(repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> {
return {
defaultCompact: true,
explicitFullDisclosure: false,
fullBodyIncluded: false,
bodyOmitted: true,
dryRunBoundedPreview: dryRun,
note: "Issue lifecycle write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.",
readCommands: issueBodyReadCommands(repo, issueNumber),
};
}
function issueLifecycleBatchSummary(issues: GitHubIssue[]): Record<string, unknown> {
const visible = issues.slice(0, ISSUE_LIFECYCLE_PREVIEW_LIMIT);
return {
count: issues.length,
returned: visible.length,
omitted: Math.max(0, issues.length - visible.length),
numbers: visible.map((issue) => issue.number),
issues: visible.map(issueLifecycleSummary),
};
}
function labelSummary(label: string | { name?: string; color?: string; description?: string | null }): Record<string, unknown> {
if (typeof label === "string") return { name: label, color: null, description: null };
return {
name: label.name ?? "",
color: label.color ?? null,
description: label.description ?? null,
};
}
function issueLabelNames(issue: GitHubIssue): string[] {
return (issue.labels ?? []).map((label) => typeof label === "string" ? label : label.name ?? "").filter((label) => label.length > 0);
}
function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Record<string, unknown> {
const summary: Record<IssueListJsonField, unknown> = {
number: issue.number,
title: issue.title,
state: issue.state,
closed: issue.state === "closed",
closedAt: issue.closed_at ?? null,
url: issue.html_url,
updatedAt: issue.updated_at ?? null,
createdAt: issue.created_at ?? null,
author: issue.user?.login ?? null,
labels: (issue.labels ?? []).map(labelSummary),
};
const selected: Record<string, unknown> = {};
for (const field of fields) selected[field] = summary[field];
return selected;
}
function boardIssueEntry(issue: GitHubIssue): BoardIssueEntry {
return {
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
};
}
function sortedIssueEntries(issues: BoardIssueEntry[]): BoardIssueEntry[] {
return issues.slice().sort((a, b) => a.number - b.number);
}
function mergedKnownMetaIssues(options: GitHubOptions): number[] {
const seen = new Set<number>();
const values: number[] = [];
for (const issueNumber of [...DEFAULT_BOARD_KNOWN_META_ISSUES, ...options.knownMetaIssues]) {
if (seen.has(issueNumber)) continue;
seen.add(issueNumber);
values.push(issueNumber);
}
return values;
}
function boardIgnoreMap(options: GitHubOptions, issues: BoardIssueEntry[] = []): Map<number, BoardIgnoreReason> {
const ignored = new Map<number, BoardIgnoreReason>();
for (const issueNumber of mergedKnownMetaIssues(options)) ignored.set(issueNumber, "known-meta");
for (const issueNumber of options.ignoredIssues) ignored.set(issueNumber, "ignored");
if (options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE) {
for (const issue of issues) {
if (!ignored.has(issue.number) && isDailyCommanderBriefTitle(issue.title)) ignored.set(issue.number, "brief-index-managed");
}
}
return ignored;
}
function stripMarkdownInline(text: string): string {
return text
.replace(/\[[^\]]*]\(([^)]*)\)/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/[*_`~]/g, "")
.trim();
}
function normalizeBoardHeader(header: string): string {
return stripMarkdownInline(header).toLowerCase().replace(/\s+/g, "");
}
function boardHeaderColumnKind(header: string): BoardColumnKind | null {
const normalized = normalizeBoardHeader(header);
if (["category", "类别", "分类", "类型", "任务类型"].includes(normalized)) return "category";
if (["branch", "分支", "目标分支", "工作分支"].includes(normalized)) return "branch";
if (["acceptance", "验收", "验收状态", "验收结果", "验收标准"].includes(normalized)) return "acceptance";
if (["relatedtask", "task", "codequeue", "codequeuetask", "相关任务", "任务", "codequeue任务", "cq任务", "相关codequeue任务", "相关codequeuetask"].includes(normalized)) return "relatedTask";
if (["githubstatus", "githubstate", "github状态", "githubissue状态", "issuestate", "issue状态", "issuegithub状态", "开闭状态", "openclosed", "openclosed状态"].includes(normalized)) return "githubStatus";
if (["summary", "摘要", "概述", "任务摘要", "任务概述", "标题"].includes(normalized)) return "summary";
if (["focus", "currentfocus", "关注点", "当前关注点", "当前focus"].includes(normalized)) return "focus";
if (["progress", "进度", "状态", "当前进度"].includes(normalized)) return "progress";
return null;
}
function boardColumnIndexMap(headers: string[]): Map<BoardColumnKind, number> {
const map = new Map<BoardColumnKind, number>();
headers.forEach((header, headerIndex) => {
const kind = boardHeaderColumnKind(header);
if (kind !== null && !map.has(kind)) map.set(kind, headerIndex);
});
return map;
}
function boardRequiredColumnIndexMap(headers: string[]): Map<BoardRequiredColumn, number> {
const map = new Map<BoardRequiredColumn, number>();
const allColumns = boardColumnIndexMap(headers);
for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) {
const index = allColumns.get(column);
if (index !== undefined) map.set(column, index);
}
return map;
}
function boardColumnKindForField(field: BoardRowField): BoardColumnKind {
if (field === "branch") return "branch";
if (field === "tasks") return "relatedTask";
if (field === "focus") return "focus";
if (field === "progress") return "progress";
return "acceptance";
}
function boardIssueColumnIndex(headers: string[]): number | null {
for (let index = 0; index < headers.length; index += 1) {
const normalized = normalizeBoardHeader(headers[index]);
if (["issue", "issue#", "issuenumber", "issueid", "issue编号", "issue号", "编号"].includes(normalized)) return index;
}
return null;
}
function markdownCells(line: string): string[] {
const trimmed = line.trim();
const withoutOuterPipes = trimmed.replace(/^\|/u, "").replace(/\|$/u, "");
const cells: string[] = [];
let current = "";
for (let index = 0; index < withoutOuterPipes.length; index += 1) {
const char = withoutOuterPipes[index];
const previous = withoutOuterPipes[index - 1];
if (char === "|" && previous !== "\\") {
cells.push(current.trim().replace(/\\\|/g, "|"));
current = "";
continue;
}
current += char;
}
cells.push(current.trim().replace(/\\\|/g, "|"));
return cells;
}
function isMarkdownTableSeparator(line: string): boolean {
const cells = markdownCells(line);
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/u.test(cell.trim()));
}
function boardSectionKindFromHeading(line: string): BoardSectionKind | null {
const normalized = line.toLowerCase();
if (!line.trimStart().startsWith("#")) return null;
if (normalized.includes("open") || normalized.includes("看板(open") || normalized.includes("看板(open)") || normalized.includes("开放")) return "open";
if (normalized.includes("closed") || normalized.includes("看板(closed") || normalized.includes("看板(closed)") || normalized.includes("关闭") || normalized.includes("已关闭")) return "closed";
return null;
}
function extractIssueNumbers(text: string): number[] {
const numbers: number[] = [];
const seen = new Set<number>();
const patterns = [/#(\d+)/g, /\/issues\/(\d+)/g];
for (const pattern of patterns) {
pattern.lastIndex = 0;
let match = pattern.exec(text);
while (match !== null) {
const value = Number(match[1]);
if (Number.isInteger(value) && value > 0 && !seen.has(value)) {
seen.add(value);
numbers.push(value);
}
match = pattern.exec(text);
}
}
return numbers;
}
function primaryBoardIssueNumberFromIssueCell(issueCell: string | undefined): number | null {
if (issueCell === undefined) return null;
const markdownLinkPattern = /\[[^\]]*]\(([^)]*)\)/g;
let match = markdownLinkPattern.exec(issueCell);
while (match !== null) {
const issueNumber = /\/issues\/(\d+)/u.exec(match[1] ?? "")?.[1];
if (issueNumber !== undefined) {
const value = Number(issueNumber);
if (Number.isInteger(value) && value > 0) return value;
}
match = markdownLinkPattern.exec(issueCell);
}
const leadingIssueReference = /^\s*#(\d+)\b/u.exec(issueCell)?.[1];
if (leadingIssueReference !== undefined) {
const value = Number(leadingIssueReference);
if (Number.isInteger(value) && value > 0) return value;
}
return null;
}
function boardRowFromCells(section: BoardTableSection, raw: string, cells: string[], lineNumber: number): BoardTableRow {
const columnMap = boardRequiredColumnIndexMap(section.headers);
const issueColumnIndex = boardIssueColumnIndex(section.headers);
const issueCell = issueColumnIndex === null ? undefined : cells[issueColumnIndex];
const primaryIssueNumber = issueColumnIndex === null ? null : primaryBoardIssueNumberFromIssueCell(issueCell);
const issueNumbers = issueColumnIndex === null
? extractIssueNumbers(raw)
: (primaryIssueNumber === null ? extractIssueNumbers(issueCell ?? "") : [primaryIssueNumber]);
const columns: Partial<Record<BoardRequiredColumn, string>> = {};
for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) {
const cellIndex = columnMap.get(column);
if (cellIndex !== undefined) columns[column] = cells[cellIndex] ?? "";
}
return {
section: section.kind,
lineNumber,
raw,
cells,
issueNumbers,
issueNumber: issueNumbers[0] ?? null,
title: issueColumnIndex === null
? (cells.find((cell) => extractIssueNumbers(cell).length > 0) ?? cells[0] ?? null)
: (issueCell ?? cells[0] ?? null),
columns,
};
}
function normalizedBoardCellPlaceholder(value: string): { spaced: string; compact: string } {
const spaced = stripMarkdownInline(value).toLowerCase().replace(/\s+/g, " ").trim();
return { spaced, compact: spaced.replace(/\s+/g, "") };
}
function isRelatedTaskNoTaskPlaceholder(value: string): boolean {
const normalized = normalizedBoardCellPlaceholder(value);
return [
"-",
"",
"",
"n/a",
"na",
"none",
"no task",
"no-task",
"no code queue task",
"not applicable",
].includes(normalized.spaced) || [
"",
"无任务",
"无关联任务",
"无相关任务",
"codequeue任务",
"codequeuetask",
"不适用",
].includes(normalized.compact);
}
function cellHasMeaningfulValue(value: string | undefined, column: BoardRequiredColumn): boolean {
if (value === undefined) return false;
const stripped = stripMarkdownInline(value);
if (stripped.length === 0) return false;
if (column === "relatedTask" && isRelatedTaskNoTaskPlaceholder(value)) return true;
return !["-", "", "n/a", "na", "todo", "tbd", "待补", "未填", ""].includes(stripped.toLowerCase());
}
function parseBoardTables(body: string): { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] } {
const lines = normalizeNewlines(body).split("\n");
const sections: BoardTableSection[] = [];
const warnings: BoardRowValidationWarning[] = [];
let currentKind: BoardSectionKind | null = null;
let currentHeading = "";
let currentHeadingLine = 0;
for (let index = 0; index < lines.length; index += 1) {
const headingKind = boardSectionKindFromHeading(lines[index]);
if (headingKind !== null) {
currentKind = headingKind;
currentHeading = lines[index].trim();
currentHeadingLine = index + 1;
continue;
}
if (currentKind === null) continue;
if (!lines[index].trim().startsWith("|")) continue;
const separator = lines[index + 1];
if (separator === undefined || !separator.trim().startsWith("|") || !isMarkdownTableSeparator(separator)) continue;
const headers = markdownCells(lines[index]);
const rows: BoardTableRow[] = [];
let rowIndex = index + 2;
while (rowIndex < lines.length && lines[rowIndex].trim().startsWith("|")) {
if (!isMarkdownTableSeparator(lines[rowIndex])) {
const cells = markdownCells(lines[rowIndex]);
const row = boardRowFromCells(
{ kind: currentKind, heading: currentHeading, headingLine: currentHeadingLine, headerLine: index + 1, headers, rows: [] },
lines[rowIndex],
cells,
rowIndex + 1,
);
rows.push(row);
if (row.issueNumbers.length === 0) {
warnings.push({
issueNumber: null,
section: currentKind,
lineNumber: row.lineNumber,
kind: "missing-issue-reference",
message: "Board table row does not contain a GitHub issue reference such as #36.",
rowPreview: preview(row.raw),
});
}
if (row.issueNumbers.length > 1) {
warnings.push({
issueNumber: row.issueNumber,
section: currentKind,
lineNumber: row.lineNumber,
kind: "multiple-issue-references",
message: boardIssueColumnIndex(headers) === null
? "Board table row contains multiple issue references; audit uses the first number as the row key."
: "Board table row contains multiple issue references in the Issue column; audit uses the first number as the row key.",
rowPreview: preview(row.raw),
});
}
const missingColumns = BOARD_AUDIT_REQUIRED_COLUMNS.filter((column) => !cellHasMeaningfulValue(row.columns[column], column));
if (missingColumns.length > 0) {
warnings.push({
issueNumber: row.issueNumber,
section: currentKind,
lineNumber: row.lineNumber,
kind: "missing-required-columns",
message: "Board table row is missing branch, acceptance, related task, or progress information.",
missingColumns,
columns: row.columns,
rowPreview: preview(row.raw),
});
}
}
rowIndex += 1;
}
sections.push({
kind: currentKind,
heading: currentHeading,
headingLine: currentHeadingLine,
headerLine: index + 1,
headers,
rows,
});
index = rowIndex - 1;
}
return { sections, warnings };
}
function boardRowsByIssue(rows: BoardTableRow[]): Map<number, BoardTableRow[]> {
const map = new Map<number, BoardTableRow[]>();
for (const row of rows) {
if (row.issueNumber === null) continue;
const list = map.get(row.issueNumber);
if (list === undefined) map.set(row.issueNumber, [row]);
else list.push(row);
}
return map;
}
function boardRowFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowField, string | null> {
const columnMap = boardColumnIndexMap(section.headers);
const valueFor = (kind: BoardColumnKind): string | null => {
const index = columnMap.get(kind);
return index === undefined ? null : row.cells[index] ?? "";
};
const acceptance = valueFor("acceptance");
return {
progress: valueFor("progress"),
status: acceptance,
validation: acceptance,
branch: valueFor("branch"),
tasks: valueFor("relatedTask"),
focus: valueFor("focus"),
};
}
function boardRowUpsertFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowUpsertField, string | null> {
const columnMap = boardColumnIndexMap(section.headers);
const valueFor = (kind: BoardColumnKind): string | null => {
const index = columnMap.get(kind);
return index === undefined ? null : row.cells[index] ?? "";
};
const acceptance = valueFor("acceptance");
return {
category: valueFor("category"),
branch: valueFor("branch"),
tasks: valueFor("relatedTask"),
summary: valueFor("summary"),
focus: valueFor("focus"),
validation: acceptance,
progress: valueFor("progress"),
status: valueFor("githubStatus"),
};
}
function boardRowSummary(section: BoardTableSection, row: BoardTableRow): Record<string, unknown> {
return {
issueNumber: row.issueNumber,
section: row.section,
lineNumber: row.lineNumber,
heading: section.heading,
headers: section.headers,
title: row.title,
cells: row.cells,
fields: boardRowFieldValues(section, row),
raw: row.raw,
rowPreview: preview(row.raw),
};
}
function boardRowsExactMatch(
sections: BoardTableSection[],
issueNumber: number,
): Array<{ section: BoardTableSection; row: BoardTableRow }> {
return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber);
}
function boardRowsForState(sections: BoardTableSection[], state: IssueListState): Array<{ section: BoardTableSection; row: BoardTableRow }> {
return sections
.filter((section) => state === "all" || section.kind === state)
.flatMap((section) => section.rows.map((row) => ({ section, row })));
}
function boardDuplicateRows(
sections: BoardTableSection[],
issueNumber: number,
): Array<{ section: BoardTableSection; row: BoardTableRow }> {
return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber);
}
function targetBoardSection(
repo: string,
sections: BoardTableSection[],
kind: BoardMutationSection,
commandName: string,
): { ok: true; section: BoardTableSection } | GitHubCommandFailure {
const matches = sections.filter((section) => section.kind === kind);
if (matches.length === 0) {
return validationError(commandName, repo, `board ${kind.toUpperCase()} table was not found`, {
section: kind,
availableSections: sections.map((section) => ({ kind: section.kind, heading: section.heading, headerLine: section.headerLine })),
}) as GitHubCommandFailure;
}
if (matches.length > 1) {
return validationError(commandName, repo, `board ${kind.toUpperCase()} table is ambiguous`, {
section: kind,
matches: matches.map((section) => ({ heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine })),
}) as GitHubCommandFailure;
}
return { ok: true, section: matches[0] };
}
function findBoardRow(
repo: string,
sections: BoardTableSection[],
issueNumber: number,
state: IssueListState = "all",
): BoardRowLookup | GitHubCommandFailure {
const matches = boardRowsForState(sections, state).filter(({ row }) => row.issueNumber === issueNumber);
if (matches.length === 0) {
return validationError("issue board-row", repo, `board row for issue #${issueNumber} was not found`, {
issueNumber,
state,
availableIssueNumbers: boardRowsForState(sections, state)
.map(({ row }) => row.issueNumber)
.filter((value): value is number => value !== null)
.sort((a, b) => a - b),
}) as GitHubCommandFailure;
}
if (matches.length > 1) {
return validationError("issue board-row", repo, `board row for issue #${issueNumber} is ambiguous`, {
issueNumber,
state,
matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}) as GitHubCommandFailure;
}
return { ok: true, ...matches[0], matches };
}
function escapeMarkdownTableCell(value: string): string {
return value
.replace(/\r\n/g, " ")
.replace(/[\r\n]+/g, " ")
.replace(/\|/g, "\\|")
.trim();
}
function boardRowValuePollutionEvidence(value: string): string[] {
return shellPollutionEvidence(value).filter((item) => item !== "bare-carriage-return");
}
function renderMarkdownTableRow(cells: string[]): string {
return `| ${cells.map(escapeMarkdownTableCell).join(" | ")} |`;
}
function validateMarkdownTableRowLine(line: string): string | null {
if (!line.trim().startsWith("|") || !line.trim().endsWith("|")) return "row-file must contain a Markdown table row starting and ending with |";
if (isMarkdownTableSeparator(line)) return "row-file must contain a data row, not a Markdown table separator";
return null;
}
function readBoardRowFile(path: string | undefined, command: string): { path: string; raw: string; cells: string[] } {
if (path === undefined) throw new Error(`${command} requires --row-file <markdown-row-file>`);
if (!existsSync(path)) throw new Error(`row file not found: ${path}`);
const normalized = normalizeNewlines(readFileSync(path, "utf8")).trim();
if (normalized.length === 0) throw new Error("row-file must contain exactly one non-empty Markdown table row");
const lines = normalized.split("\n");
if (lines.length !== 1) throw new Error("row-file must contain exactly one Markdown table row");
const line = lines[0];
const lineError = validateMarkdownTableRowLine(line);
if (lineError !== null) throw new Error(lineError);
return { path, raw: line, cells: markdownCells(line) };
}
function replaceBoardBodyLine(body: string, lineNumber: number, newLine: string): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`);
lines[lineNumber - 1] = newLine;
return lines.join("\n");
}
function removeBoardBodyLine(body: string, lineNumber: number): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`);
lines.splice(lineNumber - 1, 1);
return lines.join("\n");
}
function insertBoardBodyLineAfter(body: string, lineNumber: number, newLine: string): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (lineNumber < 0 || lineNumber > lines.length) throw new Error(`insert line ${lineNumber} is outside the board body`);
lines.splice(lineNumber, 0, newLine);
return lines.join("\n");
}
function boardSectionInsertAfterLine(section: BoardTableSection): number {
if (section.rows.length === 0) return section.headerLine + 1;
return section.rows[section.rows.length - 1].lineNumber;
}
function boardBodySafetyIssue(repo: string, boardIssueNumber: number, body: string, options: GitHubOptions): GitHubCommandResult | null {
const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile };
return validateIssueBodyGuard(repo, boardIssueNumber, body, guardOptions);
}
function boardBodyGuardSummary(boardIssueNumber: number, body: string, options: GitHubOptions): Record<string, unknown> {
const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile };
return issueEditGuardSummary(boardIssueNumber, body, guardOptions);
}
function boardRowUpdatePlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
field: BoardRowField,
value: string,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowUpdatePlanResult | GitHubCommandFailure {
const found = findBoardRow(repo, parsed.sections, issueNumber);
if (found.ok === false) return found;
const { section, row } = found;
const targetColumn = boardColumnKindForField(field);
const columnMap = boardColumnIndexMap(section.headers);
const columnIndex = columnMap.get(targetColumn);
if (columnIndex === undefined) {
return validationError("issue board-row update", repo, `board table does not contain a column for --field ${field}`, {
issueNumber,
field,
targetColumn,
section: section.kind,
headers: section.headers,
supportedFields: BOARD_ROW_FIELDS.slice(),
}) as GitHubCommandFailure;
}
const oldCells = row.cells.slice();
const newCells = oldCells.slice();
while (newCells.length < section.headers.length) newCells.push("");
newCells[columnIndex] = value;
const newRow = renderMarkdownTableRow(newCells);
const oldBody = boardIssue.body ?? "";
let newBody: string;
try {
newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow);
} catch (error) {
return validationError("issue board-row update", repo, error instanceof Error ? error.message : String(error), {
issueNumber,
field,
lineNumber: row.lineNumber,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
field,
targetColumn,
targetColumnIndex: columnIndex,
section: section.kind,
lineNumber: row.lineNumber,
oldRow: row.raw,
newRow,
oldCells,
newCells,
oldValue: oldCells[columnIndex] ?? "",
newValue: value,
row: {
old: boardRowSummary(section, row),
new: {
...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }),
updatedField: field,
},
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function expectedBoardGithubStatus(section: BoardMutationSection): BoardGithubStatus {
return section === "open" ? "OPEN" : "CLOSED";
}
function normalizeBoardGithubStatusCell(value: string | undefined): BoardGithubStatus | null {
const normalized = stripMarkdownInline(value ?? "").toUpperCase().replace(/\s+/g, "");
if (normalized === "OPEN") return "OPEN";
if (normalized === "CLOSED") return "CLOSED";
return null;
}
function boardHeaderShape(headers: string[]): string[] {
return headers.map((header) => normalizeBoardHeader(header));
}
function sameBoardHeaderShape(a: string[], b: string[]): boolean {
const left = boardHeaderShape(a);
const right = boardHeaderShape(b);
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function boardRowDuplicateFailure(
repo: string,
commandName: string,
sections: BoardTableSection[],
issueNumber: number,
): GitHubCommandFailure | null {
const duplicates = boardDuplicateRows(sections, issueNumber);
if (duplicates.length === 0) return null;
return validationError(commandName, repo, `board row for issue #${issueNumber} already exists; refusing duplicate row`, {
issueNumber,
matches: duplicates.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}) as GitHubCommandFailure;
}
function boardStatusOptionFailure(
repo: string,
commandName: string,
targetSection: BoardMutationSection,
status: BoardGithubStatus | undefined,
): GitHubCommandFailure | null {
if (status === undefined) return null;
const expected = expectedBoardGithubStatus(targetSection);
if (status === expected) return null;
return validationError(commandName, repo, `--status ${status} conflicts with --to ${targetSection}; expected ${expected}`, {
targetSection,
status,
expectedStatus: expected,
}) as GitHubCommandFailure;
}
function boardRowStatusConsistency(
repo: string,
commandName: string,
section: BoardTableSection,
row: BoardTableRow,
role: "source" | "target",
): { ok: true; githubStatusColumnIndex: number; actualStatus: BoardGithubStatus | null; expectedStatus: BoardGithubStatus } | GitHubCommandFailure {
const columnMap = boardColumnIndexMap(section.headers);
const githubStatusColumnIndex = columnMap.get("githubStatus");
if (githubStatusColumnIndex === undefined) {
return validationError(commandName, repo, `${role} board table does not contain a GitHub 状态 column`, {
section: section.kind,
headers: section.headers,
lineNumber: row.lineNumber,
rowPreview: preview(row.raw),
}) as GitHubCommandFailure;
}
const expectedStatus = expectedBoardGithubStatus(section.kind);
const actualStatus = normalizeBoardGithubStatusCell(row.cells[githubStatusColumnIndex]);
if (actualStatus !== expectedStatus) {
return validationError(commandName, repo, `${role} row GitHub 状态 ${actualStatus ?? "missing"} conflicts with ${section.kind.toUpperCase()} section; expected ${expectedStatus}`, {
section: section.kind,
lineNumber: row.lineNumber,
rowPreview: preview(row.raw),
actualStatus,
expectedStatus,
githubStatusColumnIndex,
headers: section.headers,
}) as GitHubCommandFailure;
}
return { ok: true, githubStatusColumnIndex, actualStatus, expectedStatus };
}
function boardRowUpsertValueEntries(values: BoardRowUpsertValues): Array<{ field: BoardRowUpsertField; value: string }> {
const entries: Array<{ field: BoardRowUpsertField; value: string }> = [];
for (const field of BOARD_ROW_UPSERT_TEXT_FIELDS) {
const value = values[field];
if (value !== undefined) entries.push({ field, value });
}
if (values.status !== undefined) entries.push({ field: "status", value: values.status });
return entries;
}
function boardColumnKindForUpsertField(field: BoardRowUpsertField): BoardColumnKind {
if (field === "category") return "category";
if (field === "branch") return "branch";
if (field === "tasks") return "relatedTask";
if (field === "summary") return "summary";
if (field === "focus") return "focus";
if (field === "validation") return "acceptance";
if (field === "status") return "githubStatus";
return "progress";
}
function renderBoardIssueCell(repo: string, issueNumber: number, summary: string | undefined): string {
const trimmed = (summary ?? "").trim();
if (trimmed.length === 0) return `#${issueNumber}`;
const targetHref = `https://github.com/${repo}/issues/${issueNumber}`;
const firstMarkdownIssue = /^\s*\[[^\]]*\]\(([^)]*)\)/u.exec(trimmed)?.[1] ?? "";
if (new RegExp(`/issues/${issueNumber}(?:\\b|$)`, "u").test(firstMarkdownIssue)) return trimmed;
return `[#${issueNumber}](${targetHref}) ${trimmed}`;
}
function boardRowUpsertTargetColumn(
repo: string,
commandName: string,
section: BoardTableSection,
issueNumber: number,
field: BoardRowUpsertField,
): { ok: true; columnIndex: number; targetColumn: BoardColumnKind | "issue"; usesIssueCell: boolean } | GitHubCommandFailure {
const columnMap = boardColumnIndexMap(section.headers);
if (field === "summary") {
const summaryIndex = columnMap.get("summary");
if (summaryIndex !== undefined) return { ok: true, columnIndex: summaryIndex, targetColumn: "summary", usesIssueCell: false };
const issueColumnIndex = boardIssueColumnIndex(section.headers);
if (issueColumnIndex !== null) return { ok: true, columnIndex: issueColumnIndex, targetColumn: "issue", usesIssueCell: true };
}
const targetColumn = boardColumnKindForUpsertField(field);
const columnIndex = columnMap.get(targetColumn);
if (columnIndex === undefined) {
return validationError(commandName, repo, `board table does not contain a column for --${field}`, {
issueNumber,
field,
targetColumn,
section: section.kind,
headers: section.headers,
}) as GitHubCommandFailure;
}
return { ok: true, columnIndex, targetColumn, usesIssueCell: false };
}
function boardRowUpsertRenderedValue(
repo: string,
issueNumber: number,
field: BoardRowUpsertField,
value: string,
targetColumn: BoardColumnKind | "issue",
): string {
if (field === "summary" && targetColumn === "issue") return renderBoardIssueCell(repo, issueNumber, value);
return value;
}
function boardRowUpsertPollutionFailure(
repo: string,
commandName: string,
issueNumber: number,
values: BoardRowUpsertValues,
): GitHubCommandFailure | null {
const findings = BOARD_ROW_UPSERT_TEXT_FIELDS
.map((field) => {
const value = values[field];
if (value === undefined) return null;
const evidence = boardRowValuePollutionEvidence(value);
return evidence.length === 0 ? null : { field, valuePreview: preview(value), evidence };
})
.filter((item): item is { field: BoardRowUpsertTextField; valuePreview: string; evidence: string[] } => item !== null);
if (findings.length === 0) return null;
return validationError(commandName, repo, "issue board-row upsert field value contains literal shell escape text that would pollute a board cell; pass real newlines or plain text instead", {
issueNumber,
shellPollution: { suspected: true, findings },
}) as GitHubCommandFailure;
}
function boardRowUpsertUpdatePlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
found: { section: BoardTableSection; row: BoardTableRow },
values: BoardRowUpsertValues,
requestedSection: BoardMutationSection | undefined,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row upsert";
const { section, row } = found;
if (requestedSection !== undefined && requestedSection !== section.kind) {
return validationError(commandName, repo, `--section ${requestedSection} conflicts with existing row in ${section.kind}; use gh issue board-row move for section migration`, {
issueNumber,
requestedSection,
existingSection: section.kind,
moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${requestedSection}`,
}) as GitHubCommandFailure;
}
if (values.status !== undefined) {
const expectedStatus = expectedBoardGithubStatus(section.kind);
if (values.status !== expectedStatus) {
return validationError(commandName, repo, `--status ${values.status} conflicts with existing ${section.kind.toUpperCase()} section; use gh issue board-row move for section migration`, {
issueNumber,
existingSection: section.kind,
requestedStatus: values.status,
expectedStatus,
moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${values.status === "OPEN" ? "open" : "closed"} --status ${values.status}`,
}) as GitHubCommandFailure;
}
}
const entries = boardRowUpsertValueEntries(values);
if (entries.length === 0) {
return validationError(commandName, repo, "issue board-row upsert found an existing row but no upsert fields were provided", {
issueNumber,
existingSection: section.kind,
supportedFields: ["--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--status"],
}) as GitHubCommandFailure;
}
if (row.cells.length !== section.headers.length) {
return validationError(commandName, repo, "existing row column count does not match the board table header", {
issueNumber,
section: section.kind,
expectedColumns: section.headers.length,
actualColumns: row.cells.length,
headers: section.headers,
lineNumber: row.lineNumber,
rowPreview: preview(row.raw),
}) as GitHubCommandFailure;
}
const oldCells = row.cells.slice();
const newCells = oldCells.slice();
const changedFields: Record<string, unknown>[] = [];
for (const entry of entries) {
const target = boardRowUpsertTargetColumn(repo, commandName, section, issueNumber, entry.field);
if (target.ok === false) return target;
const newValue = boardRowUpsertRenderedValue(repo, issueNumber, entry.field, entry.value, target.targetColumn);
const oldValue = oldCells[target.columnIndex] ?? "";
newCells[target.columnIndex] = newValue;
changedFields.push({
field: entry.field,
targetColumn: target.targetColumn,
targetColumnIndex: target.columnIndex,
requestedValue: entry.value,
oldValue,
newValue,
changed: oldValue !== newValue,
});
}
const newRow = renderMarkdownTableRow(newCells);
const oldBody = boardIssue.body ?? "";
let newBody: string;
try {
newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
lineNumber: row.lineNumber,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
operation: "update",
action: "update",
section: section.kind,
lineNumber: row.lineNumber,
requestedSection: requestedSection ?? null,
providedFields: entries.map((entry) => entry.field),
changedFields,
oldRow: row.raw,
newRow,
oldCells,
newCells,
row: {
old: boardRowSummary(section, row),
new: {
...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }),
upsertFields: boardRowUpsertFieldValues(section, { ...row, raw: newRow, cells: newCells }),
},
},
linePlan: {
action: "replace",
section: section.kind,
lineNumber: row.lineNumber,
},
sectionPlan: {
kind: section.kind,
heading: section.heading,
headerLine: section.headerLine,
rowCount: section.rows.length,
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function boardRowUpsertAddPlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
sectionKind: BoardMutationSection,
values: BoardRowUpsertValues,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row upsert";
const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName);
if (targetLookup.ok === false) return targetLookup;
const target = targetLookup.section;
const issueColumnIndex = boardIssueColumnIndex(target.headers);
if (issueColumnIndex === null) {
return validationError(commandName, repo, "board table does not contain an Issue column required for generated upsert rows", {
boardIssue: boardIssue.number,
section: sectionKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
const columnMap = boardColumnIndexMap(target.headers);
const githubStatusColumnIndex = columnMap.get("githubStatus");
if (githubStatusColumnIndex === undefined) {
return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for generated upsert rows", {
boardIssue: boardIssue.number,
section: sectionKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
const expectedStatus = expectedBoardGithubStatus(sectionKind);
const requestedStatus = values.status ?? expectedStatus;
if (requestedStatus !== expectedStatus) {
return validationError(commandName, repo, `--status ${requestedStatus} conflicts with --section ${sectionKind}; expected ${expectedStatus}`, {
issueNumber,
section: sectionKind,
requestedStatus,
expectedStatus,
}) as GitHubCommandFailure;
}
const appliedFields = new Set<BoardRowUpsertField>();
const missingFields: BoardRowUpsertTextField[] = [];
const unsupportedColumns: Array<{ index: number; header: string }> = [];
const summaryColumnIndex = columnMap.get("summary");
const cells = target.headers.map((header, index) => {
if (index === issueColumnIndex) {
if (summaryColumnIndex === undefined && values.summary !== undefined) appliedFields.add("summary");
return summaryColumnIndex === undefined ? renderBoardIssueCell(repo, issueNumber, values.summary) : `#${issueNumber}`;
}
const kind = boardHeaderColumnKind(header);
if (kind === "githubStatus") {
if (values.status !== undefined) appliedFields.add("status");
return requestedStatus;
}
const valueFor = (field: BoardRowUpsertTextField, value: string | undefined): string => {
if (value === undefined) {
missingFields.push(field);
return "";
}
appliedFields.add(field);
return value;
};
if (kind === "category") return valueFor("category", values.category);
if (kind === "branch") return valueFor("branch", values.branch);
if (kind === "relatedTask") return valueFor("tasks", values.tasks);
if (kind === "summary") return valueFor("summary", values.summary);
if (kind === "focus") return valueFor("focus", values.focus);
if (kind === "acceptance") return valueFor("validation", values.validation);
if (kind === "progress") return valueFor("progress", values.progress);
unsupportedColumns.push({ index, header });
return "";
});
if (unsupportedColumns.length > 0) {
return validationError(commandName, repo, "target board table contains unsupported column(s) for generated upsert rows", {
issueNumber,
section: sectionKind,
unsupportedColumns,
headers: target.headers,
}) as GitHubCommandFailure;
}
const unappliedFields = boardRowUpsertValueEntries(values)
.map((entry) => entry.field)
.filter((field) => !appliedFields.has(field));
if (unappliedFields.length > 0) {
return validationError(commandName, repo, "target board table does not contain column(s) for supplied upsert field(s)", {
issueNumber,
section: sectionKind,
unappliedFields,
headers: target.headers,
}) as GitHubCommandFailure;
}
const uniqueMissingFields = Array.from(new Set(missingFields));
if (uniqueMissingFields.length > 0) {
return validationError(commandName, repo, "issue board-row upsert add requires values for every generated row column", {
issueNumber,
section: sectionKind,
missingFields: uniqueMissingFields,
requiredOptions: uniqueMissingFields.map((field) => `--${field}`),
headers: target.headers,
}) as GitHubCommandFailure;
}
const newRow = renderMarkdownTableRow(cells);
const row = boardRowFromCells(target, newRow, cells, 0);
if (row.issueNumber !== issueNumber) {
return validationError(commandName, repo, "generated Issue column does not match the requested issue number", {
issueNumber,
issueColumnIndex,
issueCell: cells[issueColumnIndex] ?? "",
detectedIssueNumber: row.issueNumber,
rowPreview: preview(newRow),
}) as GitHubCommandFailure;
}
const actualStatus = normalizeBoardGithubStatusCell(cells[githubStatusColumnIndex]);
if (actualStatus !== expectedStatus) {
return validationError(commandName, repo, "generated GitHub 状态 column does not match the target section", {
issueNumber,
section: sectionKind,
expectedStatus,
actualStatus,
statusColumnIndex: githubStatusColumnIndex,
statusCell: cells[githubStatusColumnIndex] ?? "",
rowPreview: preview(newRow),
}) as GitHubCommandFailure;
}
const oldBody = boardIssue.body ?? "";
const insertAfterLine = boardSectionInsertAfterLine(target);
let newBody: string;
try {
newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, newRow);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
section: sectionKind,
insertAfterLine,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
operation: "add",
action: "add",
section: sectionKind,
insertAfterLine,
providedFields: boardRowUpsertValueEntries(values).map((entry) => entry.field),
newRow,
newCells: cells,
row: {
old: null,
new: {
...boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }),
upsertFields: boardRowUpsertFieldValues(target, { ...row, lineNumber: insertAfterLine + 1 }),
},
},
linePlan: {
action: "insert",
section: sectionKind,
insertAfterLine,
newLineNumber: insertAfterLine + 1,
},
sectionPlan: {
kind: sectionKind,
heading: target.heading,
headerLine: target.headerLine,
rowCount: target.rows.length,
},
validation: {
issueColumnIndex,
githubStatusColumnIndex,
expectedStatus,
actualStatus,
columnCount: cells.length,
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function boardRowUpsertPlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
options: GitHubOptions,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row upsert";
const pollutionFailure = boardRowUpsertPollutionFailure(repo, commandName, issueNumber, options.boardRowUpsertValues);
if (pollutionFailure !== null) return pollutionFailure;
const matches = boardRowsExactMatch(parsed.sections, issueNumber);
if (matches.length > 1) {
return validationError(commandName, repo, `board row for issue #${issueNumber} is ambiguous; refusing upsert`, {
issueNumber,
matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}) as GitHubCommandFailure;
}
if (matches.length === 1) {
return boardRowUpsertUpdatePlan(repo, boardIssue, issueNumber, matches[0], options.boardRowUpsertValues, options.boardSection, parsed);
}
if (options.boardSection === undefined) {
return validationError(commandName, repo, "issue board-row upsert add requires --section open|closed when the row does not already exist", {
issueNumber,
supportedSections: BOARD_MUTATION_SECTIONS.slice(),
}) as GitHubCommandFailure;
}
return boardRowUpsertAddPlan(repo, boardIssue, issueNumber, options.boardSection, options.boardRowUpsertValues, parsed);
}
function boardRowAddPlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
sectionKind: BoardMutationSection,
rowFile: { path: string; raw: string; cells: string[] },
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row add";
const duplicate = boardRowDuplicateFailure(repo, commandName, parsed.sections, issueNumber);
if (duplicate !== null) return duplicate;
const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName);
if (targetLookup.ok === false) return targetLookup;
const target = targetLookup.section;
const issueColumnIndex = boardIssueColumnIndex(target.headers);
if (issueColumnIndex === null) {
return validationError(commandName, repo, "board table does not contain an Issue column required for row-file validation", {
boardIssue: boardIssue.number,
section: sectionKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
const columnMap = boardColumnIndexMap(target.headers);
const githubStatusColumnIndex = columnMap.get("githubStatus");
if (githubStatusColumnIndex === undefined) {
return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for add section validation", {
boardIssue: boardIssue.number,
section: sectionKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
if (rowFile.cells.length !== target.headers.length) {
return validationError(commandName, repo, "row-file column count does not match the target board table header", {
boardIssue: boardIssue.number,
section: sectionKind,
expectedColumns: target.headers.length,
actualColumns: rowFile.cells.length,
headers: target.headers,
rowFile: rowFile.path,
rowPreview: preview(rowFile.raw),
}) as GitHubCommandFailure;
}
const row = boardRowFromCells(target, rowFile.raw, rowFile.cells, 0);
if (row.issueNumber !== issueNumber) {
return validationError(commandName, repo, "row-file Issue column does not match the requested issue number", {
issueNumber,
issueColumnIndex,
issueCell: rowFile.cells[issueColumnIndex] ?? "",
detectedIssueNumber: row.issueNumber,
rowPreview: preview(rowFile.raw),
}) as GitHubCommandFailure;
}
const expectedStatus = expectedBoardGithubStatus(sectionKind);
const actualStatus = normalizeBoardGithubStatusCell(rowFile.cells[githubStatusColumnIndex]);
if (actualStatus !== expectedStatus) {
return validationError(commandName, repo, "row-file GitHub 状态 column does not match the target section", {
issueNumber,
section: sectionKind,
expectedStatus,
actualStatus,
statusColumnIndex: githubStatusColumnIndex,
statusCell: rowFile.cells[githubStatusColumnIndex] ?? "",
rowPreview: preview(rowFile.raw),
}) as GitHubCommandFailure;
}
const oldBody = boardIssue.body ?? "";
const insertAfterLine = boardSectionInsertAfterLine(target);
let newBody: string;
try {
newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, rowFile.raw);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
section: sectionKind,
insertAfterLine,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
action: "add",
section: sectionKind,
insertAfterLine,
rowFile: rowFile.path,
newRow: rowFile.raw,
newCells: rowFile.cells,
row: {
old: null,
new: boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }),
},
linePlan: {
action: "insert",
section: sectionKind,
insertAfterLine,
newLineNumber: insertAfterLine + 1,
},
sectionPlan: {
kind: sectionKind,
heading: target.heading,
headerLine: target.headerLine,
rowCount: target.rows.length,
},
validation: {
issueColumnIndex,
githubStatusColumnIndex,
expectedStatus,
actualStatus,
columnCount: rowFile.cells.length,
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function moveBoardBodyRow(body: string, sourceLineNumber: number, insertAfterLine: number, newLine: string): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (sourceLineNumber <= 0 || sourceLineNumber > lines.length) throw new Error(`line ${sourceLineNumber} is outside the board body`);
if (insertAfterLine < 0 || insertAfterLine > lines.length) throw new Error(`insert line ${insertAfterLine} is outside the board body`);
lines.splice(sourceLineNumber - 1, 1);
const adjustedInsertAfterLine = sourceLineNumber <= insertAfterLine ? insertAfterLine - 1 : insertAfterLine;
lines.splice(adjustedInsertAfterLine, 0, newLine);
return lines.join("\n");
}
function boardRowMovePlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
targetKind: BoardMutationSection,
status: BoardGithubStatus | undefined,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row move";
const statusFailure = boardStatusOptionFailure(repo, commandName, targetKind, status);
if (statusFailure !== null) return statusFailure;
const found = findBoardRow(repo, parsed.sections, issueNumber);
if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure;
const sourceConsistency = boardRowStatusConsistency(repo, commandName, found.section, found.row, "source");
if (sourceConsistency.ok === false) return sourceConsistency;
const targetLookup = targetBoardSection(repo, parsed.sections, targetKind, commandName);
if (targetLookup.ok === false) return targetLookup;
const target = targetLookup.section;
const { section: source, row } = found;
if (!sameBoardHeaderShape(source.headers, target.headers)) {
return validationError(commandName, repo, "source and target board tables have different headers; refusing to move a raw row across mismatched columns", {
issueNumber,
source: { section: source.kind, headers: source.headers, headerLine: source.headerLine },
target: { section: target.kind, headers: target.headers, headerLine: target.headerLine },
}) as GitHubCommandFailure;
}
if (row.cells.length !== target.headers.length) {
return validationError(commandName, repo, "source row column count does not match the target board table header", {
issueNumber,
source: { section: source.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw), cells: row.cells.length },
target: { section: target.kind, headers: target.headers, columns: target.headers.length },
}) as GitHubCommandFailure;
}
const columnMap = boardColumnIndexMap(target.headers);
const githubStatusColumnIndex = columnMap.get("githubStatus");
if (githubStatusColumnIndex === undefined) {
return validationError(commandName, repo, "target board table does not contain a GitHub 状态 column required for section migration", {
issueNumber,
targetSection: targetKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
const oldCells = row.cells.slice();
const newCells = oldCells.slice();
const expectedStatus = expectedBoardGithubStatus(targetKind);
if (githubStatusColumnIndex !== undefined) newCells[githubStatusColumnIndex] = status ?? expectedStatus;
const newRow = renderMarkdownTableRow(newCells);
const oldBody = boardIssue.body ?? "";
const insertAfterLine = boardSectionInsertAfterLine(target);
let newBody: string;
try {
newBody = source.kind === target.kind
? replaceBoardBodyLine(oldBody, row.lineNumber, newRow)
: moveBoardBodyRow(oldBody, row.lineNumber, insertAfterLine, newRow);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
sourceLineNumber: row.lineNumber,
insertAfterLine,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
action: source.kind === target.kind ? "move-within-section" : "move",
from: source.kind,
to: target.kind,
sourceLineNumber: row.lineNumber,
insertAfterLine,
oldRow: row.raw,
newRow,
oldCells,
newCells,
row: {
old: boardRowSummary(source, row),
new: boardRowSummary(target, { ...row, section: target.kind, raw: newRow, cells: newCells, lineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1 }),
},
linePlan: {
action: source.kind === target.kind ? "replace" : "move",
sourceLineNumber: row.lineNumber,
insertAfterLine,
newLineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1,
},
sectionPlan: {
from: source.kind,
to: target.kind,
sourceHeading: source.heading,
targetHeading: target.heading,
},
status: {
requested: status ?? null,
targetDefault: expectedStatus,
githubStatusColumnIndex: githubStatusColumnIndex ?? null,
old: oldCells[githubStatusColumnIndex] ?? "",
new: newCells[githubStatusColumnIndex] ?? "",
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function boardRowDeletePlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row delete";
const found = findBoardRow(repo, parsed.sections, issueNumber);
if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure;
const { section, row } = found;
const sourceConsistency = boardRowStatusConsistency(repo, commandName, section, row, "source");
if (sourceConsistency.ok === false) return sourceConsistency;
const oldBody = boardIssue.body ?? "";
let newBody: string;
try {
newBody = removeBoardBodyLine(oldBody, row.lineNumber);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
lineNumber: row.lineNumber,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
action: "delete",
section: section.kind,
lineNumber: row.lineNumber,
oldRow: row.raw,
oldCells: row.cells,
row: {
old: boardRowSummary(section, row),
new: null,
},
linePlan: {
action: "remove",
section: section.kind,
lineNumber: row.lineNumber,
},
sectionPlan: {
kind: section.kind,
heading: section.heading,
headerLine: section.headerLine,
rowCount: section.rows.length,
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
async function issueBoardRowMutation(
repo: string,
token: string,
issueNumber: number,
options: GitHubOptions,
commandName: "issue board-row add" | "issue board-row move" | "issue board-row delete" | "issue board-row upsert",
mutationKey: "add" | "move" | "delete" | "upsert",
buildPlan: (boardIssue: GitHubIssue, parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }) => BoardRowMutationPlanResult | GitHubCommandFailure,
): Promise<GitHubCommandResult> {
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo };
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber });
const parsed = parseBoardTables(boardIssue.body ?? "");
const planned = buildPlan(boardIssue, parsed);
if (planned.ok === false) return planned;
const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options);
const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options);
const hasConcurrencyGuard = options.expectBodySha !== undefined || options.expectUpdatedAt !== undefined;
const effectiveDryRun = options.dryRun || !hasConcurrencyGuard;
const base = {
ok: true,
command: commandName,
repo,
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody),
issueNumber,
operation: typeof planned.plan.operation === "string" ? planned.plan.operation : mutationKey,
dryRun: effectiveDryRun,
planned: true,
[mutationKey]: planned.plan,
guard,
bodyOnlySafety: {
oldBody: {
fetched: true,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
newBody: {
bodyChars: planned.newBody.length,
bodySha: bodySha(planned.newBody),
...bodySafetySignals(planned.newBody),
},
},
concurrency: {
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
note: `non-dry-run board-row ${mutationKey} requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH`,
},
};
if (guardError !== null) return { ...guardError, command: commandName, [mutationKey]: planned.plan, guard };
if (effectiveDryRun) {
return {
...base,
dryRun: true,
disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true),
readCommands: issueBodyReadCommands(repo, options.boardIssue),
wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length },
note: !hasConcurrencyGuard
? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified."
: "Dry-run only; no GitHub issue body was modified.",
};
}
const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options);
if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName };
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody });
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, [mutationKey]: planned.plan });
return {
...base,
dryRun: false,
planned: false,
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false),
readCommands: issueBodyReadCommands(repo, options.boardIssue),
concurrency: {
checked: true,
oldIssueUpdatedAt: boardIssue.updated_at ?? null,
oldBodySha: bodySha(boardIssue.body ?? ""),
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
},
rest: true,
};
}
async function issueBoardRowList(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row list";
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue });
const parsed = parseBoardTables(boardIssue.body ?? "");
const rows = boardRowsForState(parsed.sections, options.listState);
return {
ok: true,
command: commandName,
repo,
dryRun: true,
readOnly: true,
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""),
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
state: options.listState,
count: rows.length,
rows: rows.map(({ section, row }) => boardRowSummary(section, row)),
sections: parsed.sections.map((section) => ({ kind: section.kind, heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine, headers: section.headers, rows: section.rows.length })),
rowValidationWarnings: parsed.warnings,
note: "Read-only board row list; no issue body was edited.",
};
}
async function issueBoardRowGet(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row get";
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber });
const parsed = parseBoardTables(boardIssue.body ?? "");
const found = findBoardRow(repo, parsed.sections, issueNumber);
if (found.ok === false) return { ...found, command: commandName, repo, boardIssue: options.boardIssue };
return {
ok: true,
command: commandName,
repo,
dryRun: true,
readOnly: true,
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""),
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
issueNumber,
row: boardRowSummary(found.section, found.row),
rowValidationWarnings: parsed.warnings.filter((warning) => warning.issueNumber === issueNumber),
note: "Read-only board row get; no issue body was edited.",
};
}
async function issueBoardRowUpdate(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row update";
if (options.boardRowField === undefined) return validationError(commandName, repo, "issue board-row update requires --field progress|status|validation|branch|tasks|focus", { supportedFields: BOARD_ROW_FIELDS.slice() });
if (options.boardRowValue === undefined) return validationError(commandName, repo, "issue board-row update requires --value <text>", { issueNumber, field: options.boardRowField });
const valuePollutionEvidence = boardRowValuePollutionEvidence(options.boardRowValue);
if (valuePollutionEvidence.length > 0) {
return validationError(commandName, repo, "issue board-row update --value contains literal shell escape text that would pollute the board cell; pass real newlines or plain text instead", {
issueNumber,
field: options.boardRowField,
valuePreview: preview(options.boardRowValue),
shellPollution: { suspected: true, evidence: valuePollutionEvidence },
});
}
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo };
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber });
const parsed = parseBoardTables(boardIssue.body ?? "");
const planned = boardRowUpdatePlan(repo, boardIssue, issueNumber, options.boardRowField, options.boardRowValue, parsed);
if (planned.ok === false) return planned;
const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options);
const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options);
const effectiveDryRun = options.dryRun || (options.expectBodySha === undefined && options.expectUpdatedAt === undefined);
const base = {
ok: true,
command: commandName,
repo,
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody),
issueNumber,
dryRun: effectiveDryRun,
planned: true,
update: planned.plan,
guard,
bodyOnlySafety: {
oldBody: {
fetched: true,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
newBody: {
bodyChars: planned.newBody.length,
bodySha: bodySha(planned.newBody),
...bodySafetySignals(planned.newBody),
},
},
concurrency: {
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
note: "non-dry-run board-row update requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH",
},
};
if (guardError !== null) return { ...guardError, command: commandName, update: planned.plan, guard };
if (effectiveDryRun) {
return {
...base,
dryRun: true,
disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true),
readCommands: issueBodyReadCommands(repo, options.boardIssue),
wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length },
note: options.expectBodySha === undefined && options.expectUpdatedAt === undefined
? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified."
: "Dry-run only; no GitHub issue body was modified.",
};
}
const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options);
if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName };
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody });
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, update: planned.plan });
return {
...base,
dryRun: false,
planned: false,
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false),
readCommands: issueBodyReadCommands(repo, options.boardIssue),
concurrency: {
checked: true,
oldIssueUpdatedAt: boardIssue.updated_at ?? null,
oldBodySha: bodySha(boardIssue.body ?? ""),
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
},
rest: true,
};
}
async function issueBoardRowAdd(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row add";
if (options.boardSection === undefined) return validationError(commandName, repo, "issue board-row add requires --section open|closed", { issueNumber, supportedSections: BOARD_MUTATION_SECTIONS.slice() });
let rowFile: { path: string; raw: string; cells: string[] };
try {
rowFile = readBoardRowFile(options.boardRowFile, commandName);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
rowFile: options.boardRowFile ?? null,
});
}
return issueBoardRowMutation(
repo,
token,
issueNumber,
options,
commandName,
"add",
(boardIssue, parsed) => boardRowAddPlan(repo, boardIssue, issueNumber, options.boardSection as BoardMutationSection, rowFile, parsed),
);
}
async function issueBoardRowUpsert(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row upsert";
return issueBoardRowMutation(
repo,
token,
issueNumber,
options,
commandName,
"upsert",
(boardIssue, parsed) => boardRowUpsertPlan(repo, boardIssue, issueNumber, options, parsed),
);
}
async function issueBoardRowMove(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row move";
if (options.boardMoveTo === undefined) return validationError(commandName, repo, "issue board-row move requires --to open|closed", { issueNumber, supportedTargets: BOARD_MUTATION_SECTIONS.slice() });
return issueBoardRowMutation(
repo,
token,
issueNumber,
options,
commandName,
"move",
(boardIssue, parsed) => boardRowMovePlan(repo, boardIssue, issueNumber, options.boardMoveTo as BoardMutationSection, options.boardGithubStatus, parsed),
);
}
async function issueBoardRowDelete(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row delete";
return issueBoardRowMutation(
repo,
token,
issueNumber,
options,
commandName,
"delete",
(boardIssue, parsed) => boardRowDeletePlan(repo, boardIssue, issueNumber, parsed),
);
}
function ignoredIssueList(issues: BoardIssueEntry[], ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] {
return sortedIssueEntries(issues.filter((issue) => ignoreMap.has(issue.number))).map((issue) => ({
number: issue.number,
reason: ignoreMap.get(issue.number) ?? "ignored",
title: issue.title,
state: issue.state,
url: issue.url,
}));
}
function ignoredBoardOnlyIssues(issueNumbers: number[], issueMap: Map<number, BoardIssueEntry>, ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] {
return issueNumbers
.filter((issueNumber) => ignoreMap.has(issueNumber) && !issueMap.has(issueNumber))
.sort((a, b) => a - b)
.map((issueNumber) => ({ number: issueNumber, reason: ignoreMap.get(issueNumber) ?? "ignored" }));
}
function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null {
if (requested !== "auto") return requested;
if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board";
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return "commander-brief";
return null;
}
function firstMarkdownLine(body: string | null | undefined): string {
return normalizeNewlines(body ?? "").split("\n").find((line) => line.trim().length > 0)?.trim() ?? "";
}
function isDailyCommanderBriefTitle(title: string | null | undefined): boolean {
return DAILY_COMMANDER_BRIEF_TITLE_PATTERN.test((title ?? "").trim());
}
function isDailyCommanderBriefBodyHeading(line: string): boolean {
return DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim());
}
function isLegacyCommanderBriefBodyHeading(line: string): boolean {
return LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim());
}
function currentBodyLooksLikeCommanderBrief(body: string | null | undefined): boolean {
const normalized = normalizeNewlines(body ?? "");
const firstLine = firstMarkdownLine(normalized);
if (isDailyCommanderBriefBodyHeading(firstLine)) return true;
return isLegacyCommanderBriefBodyHeading(firstLine)
&& normalized.includes("## 常驻观察与长期建议")
&& extractTimelineSections(normalized).length > 0;
}
function commanderBriefIssueProfileMatch(issueNumber: number, context: IssueProfileValidationContext): { ok: boolean; reason: string } {
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return { ok: true, reason: "legacy-issue-number" };
if (isDailyCommanderBriefTitle(context.issueTitle)) return { ok: true, reason: "daily-title" };
if (currentBodyLooksLikeCommanderBrief(context.issueBody)) return { ok: true, reason: "current-body-heading" };
return {
ok: false,
reason: context.issueMetadataFetched === true ? "not-commander-brief" : "issue-metadata-unavailable",
};
}
function issueProfileNeedsMetadata(issueNumber: number, requested: IssueBodyProfileOption): boolean {
return requested === "commander-brief" && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE;
}
function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption, context: IssueProfileValidationContext = {}): Record<string, unknown> {
const profileName = issueProfileFor(issueNumber, requested);
if (profileName === null) return { profile: null, applied: false, requiredHeadings: [], missingHeadings: [], ok: true };
const profile = ISSUE_BODY_PROFILES[profileName];
const match = profileName === "commander-brief"
? commanderBriefIssueProfileMatch(issueNumber, context)
: { ok: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber, reason: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber ? "issue-number" : "wrong-issue-number" };
const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading));
return {
profile: profileName,
label: profile.label,
applied: true,
...(profileName === "code-queue-board"
? { expectedIssueNumber: ISSUE_BODY_PROFILES["code-queue-board"].issueNumber }
: { legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE }),
issueMatchesProfile: match.ok,
issueMatchReason: match.reason,
issueMetadataFetched: context.issueMetadataFetched === true,
issueTitle: context.issueTitle ?? null,
requiredHeadings: profile.requiredHeadings,
missingHeadings,
ok: match.ok && missingHeadings.length === 0,
};
}
function validationBoolean(record: Record<string, unknown>, key: string): boolean {
return record[key] === true;
}
function validationStringArray(record: Record<string, unknown>, key: string): string[] {
const value = record[key];
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): GitHubCommandResult | null {
const trimmed = body.trim();
const isLiteralNull = trimmed.toLowerCase() === "null";
const isBlank = trimmed.length === 0;
const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS;
const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body);
const boardContainsCommanderBriefUpdates = codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint);
const profileOk = validationBoolean(profileValidation, "ok");
const failures: string[] = [];
if (isLiteralNull) failures.push("literal-null-body");
if (isBlank) failures.push("blank-body");
if (isShort && !options.allowShortBody) failures.push("short-body");
if (boardContainsCommanderBriefUpdates) failures.push("code-queue-board-contains-commander-brief-updates");
if (!profileOk) {
if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch");
const missingHeadings = validationStringArray(profileValidation, "missingHeadings");
if (missingHeadings.length > 0) failures.push("profile-heading-missing");
}
if (failures.length === 0) return null;
const overrideAvailable = failures.every((failure) => failure === "short-body");
const message = overrideAvailable
? "issue edit body is shorter than the safe default; pass --allow-short-body only for intentional short writes"
: "issue edit body failed safety guard; refusing to write possible body-only issue corruption";
return validationError("issue edit", repo, message, {
issueNumber,
guard: {
ok: false,
failures,
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
allowShortBody: options.allowShortBody,
shortBodyOverrideAvailable: overrideAvailable,
bodySource: { kind: "body-file", path: options.bodyFile ?? null },
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
codeQueueBoardHint: boardBriefHint,
...bodySafetySignals(body),
isLiteralNull,
isBlank,
isShort,
profile: profileValidation,
},
});
}
function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): Record<string, unknown> {
const trimmed = body.trim();
const profile = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body);
const warnings: string[] = [];
if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) {
warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail");
}
if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk");
if (profile.ok === false) warnings.push("profile guard would fail");
if (codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint)) warnings.push("code-queue-board-contains-commander-brief-updates");
if (codeQueueBoardHintHasHwlabProductRouting(boardBriefHint)) warnings.push("code-queue-board-contains-hwlab-product-work");
const signals = bodySafetySignals(body);
const shellPollution = signals.shellPollution as Record<string, unknown>;
if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected");
return {
ok: trimmed.length > 0
&& trimmed.toLowerCase() !== "null"
&& (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody)
&& profile.ok === true
&& !codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint),
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
allowShortBody: options.allowShortBody,
warnings,
profile,
codeQueueBoardHint: boardBriefHint,
...signals,
};
}
function assertConcurrencyOptions(options: GitHubOptions, commandName: string): GitHubCommandResult | null {
if (options.expectBodySha === undefined) return null;
try {
normalizeExpectedSha(options.expectBodySha);
return null;
} catch (error) {
return validationError(commandName, options.repo, error instanceof Error ? error.message : String(error), {
expectBodySha: options.expectBodySha,
});
}
}
function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions): GitHubCommandResult | null {
const checks: Record<string, unknown> = {
issueNumber,
expectUpdatedAt: options.expectUpdatedAt ?? null,
actualUpdatedAt: oldIssue.updated_at ?? null,
expectBodySha: options.expectBodySha ?? null,
actualBodySha: bodySha(oldIssue.body ?? ""),
};
if (options.expectUpdatedAt !== undefined && oldIssue.updated_at !== options.expectUpdatedAt) {
return validationError("issue edit", repo, "--expect-updated-at did not match current GitHub issue updated_at; refusing stale body overwrite", checks);
}
if (options.expectBodySha !== undefined) {
const expected = normalizeExpectedSha(options.expectBodySha);
if (bodySha(oldIssue.body ?? "") !== expected) {
return validationError("issue edit", repo, "--expect-body-sha did not match current GitHub issue body; refusing stale body overwrite", checks);
}
}
return null;
}
function commentSummary(comment: GitHubComment): Record<string, unknown> {
return {
id: comment.id,
body: comment.body ?? "",
url: comment.html_url,
author: comment.user?.login ?? null,
createdAt: comment.created_at ?? null,
updatedAt: comment.updated_at ?? null,
};
}
function compactCommentSummary(comment: GitHubComment): Record<string, unknown> {
const body = comment.body ?? "";
return {
id: comment.id,
url: comment.html_url,
author: comment.user?.login ?? null,
createdAt: comment.created_at ?? null,
updatedAt: comment.updated_at ?? null,
bodyChars: body.length,
bodySha: bodySha(body),
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body, 4),
bodyOmitted: true,
fullBodyIncluded: false,
};
}
function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" {
if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged";
return pr.state === "closed" ? "closed" : "open";
}
function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
const stateDetail = prStateDetail(pr);
const closedAt = pr.closed_at ?? pr.merged_at ?? null;
const mergedAt = pr.merged_at ?? null;
const mergeCommitSha = pr.merge_commit_sha ?? null;
return {
id: pr.id,
number: pr.number,
title: pr.title,
body: pr.body ?? "",
state: pr.state,
stateDetail,
draft: pr.draft ?? false,
url: pr.html_url,
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,
closed: stateDetail !== "open",
closedAt,
merged: stateDetail === "merged",
mergedAt,
mergeCommit: stateDetail === "merged" && mergeCommitSha !== null ? { oid: mergeCommitSha } : null,
};
}
function numberOrNull(value: number | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function prCompactSummary(pr: GitHubPullRequest): Record<string, unknown> {
return {
id: pr.id,
number: pr.number,
title: pr.title,
state: pr.state,
draft: pr.draft ?? false,
url: pr.html_url,
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 },
createdAt: pr.created_at ?? null,
updatedAt: pr.updated_at ?? null,
};
}
function prFileSummary(file: GitHubPullRequestFile): Record<string, unknown> {
return {
filename: file.filename,
status: file.status ?? null,
additions: numberOrNull(file.additions),
deletions: numberOrNull(file.deletions),
changes: numberOrNull(file.changes),
previousFilename: file.previous_filename ?? null,
sha: file.sha ?? null,
blobUrl: file.blob_url ?? null,
contentsUrl: file.contents_url ?? null,
};
}
function sumPrFileStats(files: GitHubPullRequestFile[]): { additions: number; deletions: number; changes: number } {
return files.reduce((accumulator, file) => {
accumulator.additions += file.additions ?? 0;
accumulator.deletions += file.deletions ?? 0;
accumulator.changes += file.changes ?? ((file.additions ?? 0) + (file.deletions ?? 0));
return accumulator;
}, { additions: 0, deletions: 0, changes: 0 });
}
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 prMergeBoundary(): Record<string, unknown> {
return {
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: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"],
unideskCliMergeSupported: true,
};
}
function prCloseoutMetadata(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> {
const summary = prMetadataSummary(metadata);
const missingOrUnknownFields = PR_CLOSEOUT_JSON_FIELDS.filter((field) => {
const value = summary[field];
if (value === null || value === undefined) return true;
return typeof value === "string" && value.toUpperCase() === "UNKNOWN";
});
return {
ok: missingOrUnknownFields.length === 0,
source: "github-graphql",
missingOrUnknownFields,
advice: missingOrUnknownFields.length === 0
? "Closeout GraphQL metadata is present; still review checks, branch scope, and task boundary before final action."
: "Some closeout GraphQL metadata is missing or unknown; retry or cross-check with system gh/GitHub UI before treating the PR as merge-ready.",
mergeBoundary: prMergeBoundary(),
};
}
function prCloseoutMetadataError(error: GitHubErrorPayload): Record<string, unknown> {
return {
ok: false,
source: "github-graphql",
degradedReason: error.degradedReason,
runnerDisposition: error.runnerDisposition,
message: error.message,
advice: "Closeout GraphQL metadata was not available; retry or use a read-only system gh/GitHub UI cross-check before deciding merge readiness.",
mergeBoundary: prMergeBoundary(),
};
}
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 statusContextName(context: GitHubPullRequestGraphqlStatusContext): string {
return context.name ?? context.context ?? "(unnamed status check)";
}
function statusContextBucket(context: GitHubPullRequestGraphqlStatusContext): string {
const type = context.__typename ?? "StatusContext";
const status = (context.status ?? "").toUpperCase();
const conclusion = (context.conclusion ?? "").toUpperCase();
const state = (context.state ?? "").toUpperCase();
if (type === "CheckRun") {
if (status.length > 0 && status !== "COMPLETED") return "pending";
if (conclusion === "SUCCESS") return "success";
if (conclusion === "NEUTRAL") return "neutral";
if (conclusion === "SKIPPED") return "skipped";
if (conclusion === "CANCELLED") return "cancelled";
if (conclusion === "TIMED_OUT") return "timed-out";
if (["FAILURE", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(conclusion)) return "failure";
return "unknown";
}
if (state === "SUCCESS") return "success";
if (state === "PENDING" || state === "EXPECTED") return "pending";
if (state === "FAILURE" || state === "ERROR") return "failure";
return "unknown";
}
function compactStatusContext(context: GitHubPullRequestGraphqlStatusContext): Record<string, unknown> {
const bucket = statusContextBucket(context);
return {
name: statusContextName(context),
type: context.__typename ?? "StatusContext",
bucket,
status: context.status ?? null,
conclusion: context.conclusion ?? null,
state: context.state ?? null,
targetUrl: context.targetUrl ?? null,
description: context.description === undefined || context.description === null ? null : preview(context.description),
};
}
function statusRollupSummary(
repo: string,
number: number,
rollup: GitHubPullRequestGraphqlStatusCheckRollup | null | undefined,
includeRaw: boolean,
): Record<string, unknown> {
const contexts = rollup?.contexts?.nodes ?? [];
const compactContexts = contexts.map(compactStatusContext);
const counts: Record<string, number> = {
success: 0,
failure: 0,
pending: 0,
neutral: 0,
skipped: 0,
cancelled: 0,
"timed-out": 0,
unknown: 0,
};
for (const context of compactContexts) {
const bucket = String(context.bucket ?? "unknown");
counts[bucket] = (counts[bucket] ?? 0) + 1;
}
const nonSuccess = compactContexts.filter((context) => context.bucket !== "success");
const failing = compactContexts.filter((context) => context.bucket === "failure" || context.bucket === "cancelled" || context.bucket === "timed-out");
const pending = compactContexts.filter((context) => context.bucket === "pending" || context.bucket === "unknown");
const neutral = compactContexts.filter((context) => context.bucket === "neutral" || context.bucket === "skipped");
return {
state: rollup?.state ?? null,
totalContexts: compactContexts.length,
counts,
failingContexts: failing.slice(0, 10),
pendingContexts: pending.slice(0, 10),
neutralContexts: neutral.slice(0, 10),
nonSuccessPreview: nonSuccess.slice(0, 12),
omittedNonSuccessContexts: Math.max(0, nonSuccess.length - 12),
rawOmitted: !includeRaw,
...(includeRaw
? { contexts: compactContexts, raw: rollup ?? null }
: { fullHint: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo} --full` }),
};
}
function compactProbeOk(value: unknown): boolean | null {
if (value === "ok") return true;
if (value === "skipped") return null;
if (isRecord(value) && value.ok === true) return true;
if (isRecord(value) && value.ok === false) return false;
return null;
}
function compactAuthCapability(auth: GitHubCommandResult): Record<string, unknown> {
const token = isRecord(auth.token) ? auth.token : {};
const gh = isRecord(auth.gh) ? auth.gh : {};
const probes = isRecord(auth.probes) ? auth.probes : {};
return {
ok: auth.ok === true,
degradedReason: auth.ok === false ? auth.degradedReason ?? null : null,
runnerDisposition: auth.ok === false ? auth.runnerDisposition ?? null : null,
tokenPresent: token.present === true,
tokenSource: typeof token.source === "string" ? token.source : null,
ghFallbackAttempted: token.ghFallbackAttempted === true,
ghBinaryFound: gh.binaryFound === true,
restApi: compactProbeOk(probes.restApi),
repoVisible: compactProbeOk(probes.repo),
issueRead: compactProbeOk(probes.issueRead),
valuesPrinted: false,
};
}
function prPreflightPolicy(repo: string, number: number): Record<string, unknown> {
return {
readOnly: true,
writesRemote: false,
createsPr: false,
comments: false,
mergesPr: 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; use gh pr merge only after this command reports ready and the task boundary allows merge.",
};
}
function preflightPullRequestSummary(summary: Record<string, unknown>): Record<string, unknown> {
return {
number: summary.number ?? null,
title: summary.title ?? null,
state: summary.state ?? null,
stateDetail: summary.stateDetail ?? null,
draft: summary.draft ?? null,
url: summary.url ?? null,
author: summary.author ?? null,
head: summary.head ?? null,
base: summary.base ?? null,
headRefName: summary.headRefName ?? null,
baseRefName: summary.baseRefName ?? null,
closed: summary.closed ?? null,
merged: summary.merged ?? null,
mergedAt: summary.mergedAt ?? null,
updatedAt: summary.updatedAt ?? null,
bodyOmitted: true,
};
}
function prCloseoutSummary(
pr: Record<string, unknown>,
metadata: Record<string, unknown>,
statusChecks: Record<string, unknown>,
): Record<string, unknown> {
const blockers: string[] = [];
const pending: string[] = [];
const prState = typeof pr.state === "string" ? pr.state.toLowerCase() : "";
if (prState !== "open") blockers.push(`state:${pr.state ?? "unknown"}`);
if (pr.draft === true) blockers.push("draft:true");
const mergeable = typeof metadata.mergeable === "string" ? metadata.mergeable.toUpperCase() : null;
if (mergeable === "MERGEABLE") {
// Ready signal, combined with mergeStateStatus and checks below.
} else if (mergeable === "CONFLICTING" || mergeable === "NOT_MERGEABLE") {
blockers.push(`mergeable:${mergeable}`);
} else {
pending.push(`mergeable:${mergeable ?? "unknown"}`);
}
const mergeStateStatus = typeof metadata.mergeStateStatus === "string" ? metadata.mergeStateStatus.toUpperCase() : null;
if (mergeStateStatus === "CLEAN") {
// Ready signal.
} else if (mergeStateStatus === "UNKNOWN" || mergeStateStatus === null) {
pending.push(`mergeStateStatus:${mergeStateStatus ?? "unknown"}`);
} else {
blockers.push(`mergeStateStatus:${mergeStateStatus}`);
}
const rollupState = typeof statusChecks.state === "string" ? statusChecks.state.toUpperCase() : null;
const totalContexts = typeof statusChecks.totalContexts === "number" ? statusChecks.totalContexts : 0;
const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {};
const failureCount = Number(counts.failure ?? 0) + Number(counts.cancelled ?? 0) + Number(counts["timed-out"] ?? 0);
const pendingCount = Number(counts.pending ?? 0) + Number(counts.unknown ?? 0);
if (failureCount > 0) blockers.push(`statusChecks:failure:${failureCount}`);
if (pendingCount > 0) pending.push(`statusChecks:pending:${pendingCount}`);
if (rollupState === "SUCCESS" || rollupState === null && totalContexts === 0) {
// Ready signal.
} else if (rollupState === "PENDING" || rollupState === "EXPECTED" || rollupState === null) {
pending.push(`statusCheckRollup:${rollupState ?? "unknown"}`);
} else if (rollupState !== null) {
blockers.push(`statusCheckRollup:${rollupState}`);
}
const readyForCommanderMerge = blockers.length === 0 && pending.length === 0;
return {
readyForCommanderMerge,
conclusion: readyForCommanderMerge ? "ready" : blockers.length > 0 ? "blocked" : "pending",
blockers,
pending,
commanderAction: readyForCommanderMerge
? "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);
const policy = prPreflightPolicy(repo, number);
if (auth.ok === false) {
return {
ok: false,
command: commandName,
repo,
number,
degradedReason: auth.degradedReason,
runnerDisposition: auth.runnerDisposition,
authCapability,
policy,
details: auth,
};
}
const { token, probe } = resolveToken(true);
const missing = authRequired(repo, commandName, probe);
if (missing !== null || token === null) {
const missingResult = missing ?? authRequired(repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return {
...(missingResult ?? commandError(commandName, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required"))),
command: commandName,
number,
authCapability,
policy,
} as 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, authCapability, policy });
const metadata = await prGraphqlMetadata(repo, token, number);
const summary = prSummary(pr);
const pullRequest = preflightPullRequestSummary(summary);
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest, authCapability, policy });
const metadataSummary = prMetadataSummary(metadata);
const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, includeRaw);
const closeout = prCloseoutSummary(summary, metadataSummary, statusChecks);
return {
ok: true,
command: commandName,
canonicalCommand: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo}`,
aliases: ["bun scripts/cli.ts gh preflight <number>", "bun scripts/cli.ts gh pr closeout <number>"],
repo,
number,
readOnly: true,
writesRemote: false,
valuesPrinted: false,
authCapability,
pullRequest,
mergeability: {
mergeable: metadataSummary.mergeable,
mergeStateStatus: metadataSummary.mergeStateStatus,
...closeout,
},
statusChecks,
policy,
...(includeRaw ? { raw: { authStatus: auth, pullRequest, closeoutMetadata: metadataSummary } } : {}),
};
}
function repoSummary(repo: GitHubRepository): Record<string, unknown> {
return {
id: repo.id ?? null,
fullName: repo.full_name ?? null,
private: repo.private ?? null,
defaultBranch: repo.default_branch ?? null,
permissions: repo.permissions ?? null,
};
}
function branchSummary(branch: GitHubBranch): Record<string, unknown> {
return {
name: branch.name ?? null,
sha: branch.commit?.sha ?? null,
};
}
function compareSummary(compare: GitHubCompare): Record<string, unknown> {
return {
status: compare.status ?? null,
aheadBy: compare.ahead_by ?? null,
behindBy: compare.behind_by ?? null,
totalCommits: compare.total_commits ?? null,
htmlUrl: compare.html_url ?? null,
baseSha: compare.base_commit?.sha ?? null,
mergeBaseSha: compare.merge_base_commit?.sha ?? null,
};
}
function prUrl(repo: string, number: number): string {
return `https://github.com/${repo}/pull/${number}`;
}
function encodePathPart(value: string): string {
return encodeURIComponent(value);
}
async function repoInfo(token: string, repo: string): Promise<GitHubRepository | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`);
}
async function branchInfo(token: string, repo: string, branch: string): Promise<GitHubBranch | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubBranch>(token, "GET", `/repos/${owner}/${name}/branches/${encodePathPart(branch)}`);
}
async function compareBranches(token: string, repo: string, base: string, head: string): Promise<GitHubCompare | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubCompare>(token, "GET", `/repos/${owner}/${name}/compare/${encodePathPart(base)}...${encodePathPart(head)}`);
}
function prCreatePlannedOperation(repo: string, options: GitHubOptions, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
return {
repo,
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
bodyChars: body.length,
bodyPreview: preview(body),
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
bodySource,
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
request: {
method: "POST",
path: "/repos/{owner}/{repo}/pulls",
body: {
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
bodyChars: body.length,
},
},
validation: {
repo: "required",
base: "required",
head: "required",
title: "required",
body: body.length > 0 ? "optional" : "empty",
},
};
}
function prCommentPlannedOperation(repo: string, issueNumber: number, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
return {
repo,
issueNumber,
bodyChars: body.length,
bodyPreview: preview(body),
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
bodySource,
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
request: {
method: "POST",
path: `/repos/{owner}/{repo}/issues/${issueNumber}/comments`,
body: { bodyChars: body.length },
},
validation: {
repo: "required",
issueNumber: "required",
body: "required",
},
};
}
function bodyUpdatePlan(command: string, repo: string, number: number, mode: BodyUpdateMode, body: string, bodySource: Record<string, unknown>, existingBody: string | null): Record<string, unknown> {
const finalBody = mode === "append" ? `${existingBody ?? ""}${body}` : body;
return {
command,
repo,
number,
mode,
bodySource,
input: bodySafetySignals(body),
existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) },
finalBody: {
bodyChars: finalBody.length,
bodyPreview: preview(finalBody),
bodyPreviewLines: previewLines(finalBody),
...bodySafetySignals(finalBody),
},
};
}
function prEditBodyPlan(mode: BodyUpdateMode, input: { body: string; bodySource: Record<string, unknown> } | null, finalBody: string | undefined, existingBody: string | null): Record<string, unknown> | null {
if (input === null || finalBody === undefined) return null;
return {
mode,
bodySource: input.bodySource,
input: bodySafetySignals(input.body),
existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) },
finalBody: bodySafetySignals(finalBody),
};
}
async function prCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title <title>");
if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>");
if (options.head === undefined) return validationError("pr create", repo, "pr create requires --head <branch>");
let bodySource: { body: string; bodySource: Record<string, unknown> };
try {
bodySource = readMarkdownBody(options, "pr create");
} catch (error) {
return validationError("pr create", repo, error instanceof Error ? error.message : String(error));
}
const body = bodySource.body;
const planned = prCreatePlannedOperation(repo, options, body, bodySource.bodySource);
if (options.dryRun) {
return {
ok: true,
command: "pr create",
repo,
dryRun: true,
planned: true,
draft: options.draft,
...planned,
};
}
const repoResult = await repoInfo(token, repo);
if (isGitHubError(repoResult)) return commandError("pr create", repo, repoResult, { planned });
const baseBranch = await branchInfo(token, repo, options.base);
if (isGitHubError(baseBranch)) return commandError("pr create", repo, baseBranch, { base: options.base, planned });
const headBranch = await branchInfo(token, repo, options.head);
if (isGitHubError(headBranch)) return commandError("pr create", repo, headBranch, { head: options.head, planned });
const compare = await compareBranches(token, repo, options.base, options.head);
if (isGitHubError(compare)) return commandError("pr create", repo, compare, { base: options.base, head: options.head, planned });
const aheadBy = typeof compare.ahead_by === "number" ? compare.ahead_by : null;
if (aheadBy === null || aheadBy <= 0) {
return validationError("pr create", repo, "head branch must be ahead of base branch before creating a PR", {
base: options.base,
head: options.head,
compare: compareSummary(compare),
});
}
const { owner, name } = repoParts(repo);
const payload: Record<string, unknown> = {
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
body,
};
const pr = await githubRequest<GitHubPullRequest>(token, "POST", `/repos/${owner}/${name}/pulls`, payload);
if (isGitHubError(pr)) return commandError("pr create", repo, pr, { base: options.base, head: options.head, planned });
return {
ok: true,
command: "pr create",
repo,
pr: prSummary(pr),
validation: {
repo: repoSummary(repoResult),
base: branchSummary(baseBranch),
head: branchSummary(headBranch),
compare: compareSummary(compare),
bodySource: bodySource.bodySource,
},
request: {
method: "POST",
path: `/repos/${owner}/${name}/pulls`,
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
bodyChars: body.length,
},
rest: true,
};
}
async function prComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
let bodySource: { body: string; bodySource: Record<string, unknown> };
try {
bodySource = readMarkdownBody(options, "pr comment");
} catch (error) {
return validationError("pr comment", repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const body = bodySource.body;
const planned = prCommentPlannedOperation(repo, issueNumber, body, bodySource.bodySource);
if (options.dryRun) {
return {
ok: true,
command: "pr comment",
repo,
dryRun: true,
planned: true,
issueNumber,
...planned,
};
}
const repoResult = await repoInfo(token, repo);
if (isGitHubError(repoResult)) return commandError("pr comment", repo, repoResult, { issueNumber, planned });
const { owner, name } = repoParts(repo);
const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`);
if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned });
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned });
return {
ok: true,
command: "pr comment create",
repo,
issueNumber,
pr: prSummary(prResult),
comment: commentSummary(comment),
request: {
method: "POST",
path: `/repos/${owner}/${name}/issues/${issueNumber}/comments`,
bodyChars: body.length,
},
validation: {
repo: repoSummary(repoResult),
pr: prSummary(prResult),
bodySource: bodySource.bodySource,
},
rest: true,
};
}
async function prUpdate(repo: string, token: string, number: number, options: GitHubOptions, commandName = "pr update"): Promise<GitHubCommandResult> {
let bodySource: { body: string; bodySource: Record<string, unknown> } | null;
try {
bodySource = readOptionalMarkdownBody(options, commandName);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { number });
}
if (bodySource === null && options.title === undefined) {
return validationError(commandName, repo, `${commandName} requires --title <title>, --body-file <file|->, or --body <text>`, { number });
}
if (options.mode === "append" && bodySource === null) {
return validationError(commandName, repo, `${commandName} --mode append requires a body source`, { number });
}
const { owner, name } = repoParts(repo);
let oldPr: GitHubPullRequest | null = null;
if (token.length > 0 && options.mode === "append") {
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, phase: "read-before-update" });
oldPr = pr;
}
const finalBody = bodySource === null ? undefined : (options.mode === "append" ? `${oldPr?.body ?? ""}${bodySource.body}` : bodySource.body);
const changedFields = [
...(options.title === undefined ? [] : ["title"]),
...(bodySource === null ? [] : ["body"]),
];
const bodyPlan = prEditBodyPlan(options.mode, bodySource, finalBody, oldPr?.body ?? null);
const request = {
method: "PATCH",
path: `/repos/{owner}/{repo}/pulls/${number}`,
changedFields,
body: {
...(options.title === undefined ? {} : { title: "provided" }),
...(finalBody === undefined ? {} : { bodyChars: finalBody.length, bodySha: bodySha(finalBody) }),
},
};
if (options.dryRun) {
return {
ok: true,
command: commandName,
repo,
dryRun: true,
planned: true,
number,
url: prUrl(repo, number),
changedFields,
...(bodyPlan === null ? {} : { body: bodyPlan }),
request,
graphQl: false,
projectsClassic: false,
};
}
const payload: Record<string, unknown> = {};
if (finalBody !== undefined) payload.body = finalBody;
if (options.title !== undefined) payload.title = options.title;
const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, payload);
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, planned: { changedFields, body: bodyPlan, request } });
return {
ok: true,
command: commandName,
repo,
pr: pr.number ?? number,
number: pr.number ?? number,
changedFields,
url: pr.html_url ?? prUrl(repo, number),
...(bodyPlan === null ? {} : { body: bodyPlan }),
request: { ...request, path: `/repos/${owner}/${name}/pulls/${number}` },
rest: true,
graphQl: false,
projectsClassic: false,
};
}
async function prState(repo: string, token: string, number: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> {
const command = state === "closed" ? "pr close" : "pr reopen";
if (dryRun) return { ok: true, command, repo, dryRun: true, number, wouldPatch: { state } };
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, { state });
if (isGitHubError(pr)) return commandError(command, repo, pr, { number });
return { ok: true, command, repo, number, pullRequest: prSummary(pr), rest: true };
}
async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`);
}
function githubSearchLabelQualifier(label: string): string {
if (/^[A-Za-z0-9_.:-]+$/u.test(label)) return `label:${label}`;
return `label:"${label.replace(/["\\]/gu, "\\$&")}"`;
}
function issueListPaginationSummary(result: GitHubIssueListResult): Record<string, unknown> {
return {
pageSize: result.pageSize,
fetchedPages: result.fetchedPages,
exhausted: result.exhausted,
hasMore: result.hasMore,
pages: result.pages,
};
}
async function listIssues(token: string, repo: string, state: IssueListState, limit: number, search?: string, labels: string[] = []): Promise<GitHubIssueListResult | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
const normalizedSearch = String(search ?? "").trim();
const pageSize = GITHUB_REST_PAGE_SIZE;
const collected: GitHubIssue[] = [];
const pages: GitHubIssueListPage[] = [];
let rawCount = 0;
let fetchedPages = 0;
let exhausted = false;
let searchTotalCount: number | undefined;
let searchIncomplete: boolean | undefined;
const maxPages = Math.max(1, Math.ceil(limit / pageSize) + 10);
for (let page = 1; collected.length < limit && page <= maxPages; page += 1) {
const perPage = pageSize;
let path: string;
let rawIssueItems: GitHubIssue[];
if (normalizedSearch) {
const qualifiers = [`repo:${owner}/${name}`, "type:issue"];
if (state !== "all") qualifiers.push(`state:${state}`);
for (const label of labels) qualifiers.push(githubSearchLabelQualifier(label));
const params = new URLSearchParams({ q: `${normalizedSearch} ${qualifiers.join(" ")}`, per_page: String(perPage), page: String(page) });
path = `/search/issues?${params.toString()}`;
const response = await githubRequest<GitHubIssueSearchResponse>(token, "GET", path);
if (isGitHubError(response)) return response;
rawIssueItems = Array.isArray(response.items) ? response.items : [];
if (searchTotalCount === undefined) searchTotalCount = response.total_count;
searchIncomplete = searchIncomplete === true || response.incomplete_results === true;
} else {
const params = new URLSearchParams({ state, per_page: String(perPage), page: String(page) });
if (labels.length > 0) params.set("labels", labels.join(","));
path = `/repos/${owner}/${name}/issues?${params.toString()}`;
const response = await githubRequest<GitHubIssue[]>(token, "GET", path);
if (isGitHubError(response)) return response;
rawIssueItems = response;
}
const issueItems = rawIssueItems.filter((issue) => issue.pull_request === undefined);
rawCount += rawIssueItems.length;
fetchedPages += 1;
pages.push({ path, rawCount: rawIssueItems.length, issueCount: issueItems.length });
collected.push(...issueItems);
if (rawIssueItems.length < perPage) {
exhausted = true;
break;
}
}
const hasMore = collected.length > limit || !exhausted;
return {
items: collected.slice(0, limit),
rawCount,
fetchedPages,
pageSize,
exhausted,
hasMore,
pages,
searchTotalCount,
searchIncomplete,
};
}
async function getIssue(token: string, repo: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`);
}
function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null, fields: IssueViewJsonField[] | undefined): Record<string, unknown> | null {
if (fields === undefined) return null;
const summary = issueSummary(issue);
const selected: Record<string, unknown> = {};
for (const field of fields) {
if (field === "comments") {
selected.comments = (comments ?? []).map(commentSummary);
} else {
selected[field] = summary[field];
}
}
return selected;
}
async function issueRead(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, commandName = "issue read", disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
const needsComments = jsonFields === undefined || jsonFields.includes("comments");
const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null;
if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue) });
return {
ok: true,
command: commandName,
repo,
...(disclosure === null ? {} : { disclosure }),
issue: issueSummary(issue),
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(issueNumber, issue.body ?? ""),
...(comments === null ? {} : { comments: comments.map(commentSummary) }),
...(jsonFields === undefined ? {} : {
jsonFields,
json: selectedIssueJson(issue, comments, jsonFields),
compatibility: {
legacyJsonBodyPath: ".data.issue.body",
},
}),
};
}
async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure);
}
function shellWord(value: string): string {
return JSON.stringify(value);
}
function issueListNextCommand(repo: string, state: IssueListState, limit: number, search: string, labels: string[]): string {
const parts = [
"bun scripts/cli.ts gh issue list",
"--repo", shellWord(repo),
"--state", state,
"--limit", String(Math.min(limit * 2, MAX_ISSUE_LIST_LIMIT)),
];
if (search.length > 0) parts.push("--search", shellWord(search));
for (const label of labels) parts.push("--label", shellWord(label));
return parts.join(" ");
}
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string, labels: string[] = [], noDump = false): Promise<GitHubCommandResult> {
const normalizedSearch = String(search ?? "").trim();
const result = await listIssues(token, repo, state, limit, normalizedSearch, labels);
if (isGitHubError(result)) return commandError("issue list", repo, result, { state, limit, search: normalizedSearch || null, labels });
const issues = result.items;
const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice();
return {
ok: true,
command: "issue list",
repo,
...(noDump ? { noDump: true } : {}),
state,
limit,
search: normalizedSearch || null,
labels,
count: issues.length,
rawCount: result.rawCount,
searchTotalCount: result.searchTotalCount,
searchIncomplete: result.searchIncomplete,
pagination: issueListPaginationSummary(result),
hasMore: result.hasMore,
jsonFields: fields,
issues: issues.map((issue) => issueListSummary(issue, fields)),
...(result.hasMore && limit < MAX_ISSUE_LIST_LIMIT
? {
next: {
command: issueListNextCommand(repo, state, limit, normalizedSearch, labels),
note: "Increase --limit to continue scanning beyond the current bounded result set.",
},
}
: result.hasMore
? {
scanLimit: {
maxLimitReached: true,
maxLimit: MAX_ISSUE_LIST_LIMIT,
note: "The command reached the maximum bounded issue scan; narrow with --state/--label/--search before treating the repository as exhausted.",
},
}
: {}),
request: {
method: "GET",
path: normalizedSearch ? "/search/issues" : "/repos/{owner}/{repo}/issues",
query: normalizedSearch
? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}${labels.map((label) => ` ${githubSearchLabelQualifier(label)}`).join("")}`, per_page: GITHUB_REST_PAGE_SIZE }
: { state, labels, per_page: GITHUB_REST_PAGE_SIZE },
},
note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.",
};
}
async function issueBoardAudit(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-audit";
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue });
const parsed = parseBoardTables(boardIssue.body ?? "");
const openRows = parsed.sections.filter((section) => section.kind === "open").flatMap((section) => section.rows);
const closedRows = parsed.sections.filter((section) => section.kind === "closed").flatMap((section) => section.rows);
const body = boardIssue.body ?? "";
return {
ok: true,
command: commandName,
repo,
dryRun: true,
planned: true,
readOnly: true,
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: body.length,
bodyLines: body.length === 0 ? 0 : normalizeNewlines(body).split("\n").length,
bodySha: bodySha(body),
updatedAt: boardIssue.updated_at ?? null,
},
config: {
limit: options.limit,
knownMetaIssues: mergedKnownMetaIssues(options),
ignoredIssues: options.ignoredIssues,
briefIndexManagedIssuePattern: options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE ? "YYYY-MM-DD 指挥简报(北京时间)" : null,
openClosedCoverageValidation: false,
requiredColumns: [],
},
summary: {
openIssues: null,
closedIssues: null,
openRows: openRows.length,
closedRows: closedRows.length,
parsedSections: parsed.sections.length,
parsedRows: parsed.sections.reduce((sum, section) => sum + section.rows.length, 0),
parserWarnings: parsed.warnings.length,
missingOpenIssues: 0,
closedInOpenRows: 0,
missingClosedRows: 0,
openInClosedRows: 0,
rowValidationWarnings: 0,
ignoredIssues: 0,
boardOnlyRows: 0,
staleOpenRows: 0,
recommendedActions: 0,
},
validation: {
openClosedCoverage: {
enabled: false,
reason: "OPEN/CLOSED table coverage validation has been disabled; this command no longer compares GitHub issue state against board rows.",
},
rowRequiredColumns: {
enabled: false,
reason: "board-audit no longer reports missing required row columns; use board-row commands when intentionally maintaining a legacy OPEN/CLOSED table.",
},
},
sections: parsed.sections.map((section) => ({
kind: section.kind,
heading: section.heading,
headingLine: section.headingLine,
headerLine: section.headerLine,
headers: section.headers,
rows: section.rows.length,
issueNumbers: section.rows.flatMap((row) => row.issueNumbers),
})),
parserWarnings: parsed.warnings,
missingOpenIssues: [],
closedInOpenRows: [],
missingClosedRows: [],
openInClosedRows: [],
staleOpenRows: [],
boardOnlyRows: [],
rowValidationWarnings: [],
ignoredIssues: [],
recommendedActions: [],
request: {
method: "GET",
paths: [
`/repos/{owner}/{repo}/issues/${options.boardIssue}`,
],
query: {},
},
note: "Read-only board structure audit; OPEN/CLOSED coverage validation is disabled, no issue body was edited, no issue was closed, and no comments were written.",
};
}
async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
if (options.title === undefined) throw new Error("issue create requires --title <title>");
if (options.body !== undefined) {
return validationError("issue create", repo, "issue create does not support --body; use --body-file <file|-> for Markdown", {
title: options.title,
bodySource: "inline",
});
}
const { body, bodySource } = readMarkdownBody({
...options,
body: undefined,
}, "issue create");
const labels = options.labels;
if (options.dryRun) {
return {
ok: true,
command: "issue create",
repo,
dryRun: true,
planned: true,
title: options.title,
labels,
...writeBodyPlan("issue create", repo, body, bodySource, { title: options.title, labels }),
};
}
const { owner, name } = repoParts(repo);
const payload: Record<string, unknown> = { title: options.title, body };
if (labels.length > 0) payload.labels = labels;
const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, payload);
if (isGitHubError(issue)) return commandError("issue create", repo, issue, { title: options.title, labels });
const appliedLabels = issueLabelNames(issue);
const missingLabels = labels.filter((label) => !appliedLabels.includes(label));
if (missingLabels.length > 0) {
return validationError("issue create", repo, "GitHub created the issue but did not return all requested labels; refusing to report silent label success", {
issue: issueSummary(issue),
requestedLabels: labels,
appliedLabels,
missingLabels,
});
}
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true };
}
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> {
if (options.bodyFile === undefined) {
return validationError(commandName, repo, `${commandName} requires --body-file <file|->`, { issueNumber });
}
let bodyInput: { body: string; bodySource: Record<string, unknown> };
try {
bodyInput = readMarkdownBodyFileOrStdin(options.bodyFile);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const { body, bodySource } = bodyInput;
const needsProfileMetadata = issueProfileNeedsMetadata(issueNumber, options.bodyProfile);
if (options.mode === "replace" && !needsProfileMetadata) {
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
if (bodyGuard !== null) return bodyGuard;
}
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
if (concurrencyOptionError !== null) return concurrencyOptionError;
let oldIssue: GitHubIssue | null = null;
let briefDiff: CommanderBriefDiff | null = null;
let profileContext: IssueProfileValidationContext = {};
const claudeQqConfig = commanderBriefClaudeQqConfig();
if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) {
return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber });
}
const needsReadBeforeEdit = options.mode === "append"
|| needsProfileMetadata && token.length > 0
|| !options.dryRun;
if (needsReadBeforeEdit) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" });
oldIssue = issue;
profileContext = { issueTitle: issue.title, issueBody: issue.body, issueMetadataFetched: true };
const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options);
if (concurrencyError !== null) return concurrencyError;
if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body);
}
const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body;
if (options.mode === "append" || needsProfileMetadata) {
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options, profileContext);
if (bodyGuard !== null) return bodyGuard;
}
if (options.dryRun) {
const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null;
const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext);
let dryRunOldBody: Record<string, unknown> = {
fetched: false,
bodyChars: null,
bodySha: null,
updatedAt: null,
reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run",
};
if (oldIssue !== null) {
const oldBody = oldIssue.body ?? "";
dryRunOldBody = {
fetched: true,
bodyChars: oldBody.length,
bodySha: bodySha(oldBody),
updatedAt: oldIssue.updated_at ?? null,
};
} else if (token.length > 0) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) {
dryRunOldBody = {
fetched: false,
bodyChars: null,
bodySha: null,
updatedAt: null,
readError: {
degradedReason: issue.degradedReason,
runnerDisposition: issue.runnerDisposition,
message: issue.message,
status: issue.status ?? null,
},
};
} else {
const oldBody = issue.body ?? "";
dryRunOldBody = {
fetched: true,
bodyChars: oldBody.length,
bodySha: bodySha(oldBody),
updatedAt: issue.updated_at ?? null,
};
}
}
return {
ok: true,
command: commandName,
repo,
dryRun: true,
issueNumber,
mode: options.mode,
...dryRunBody(repo, options.title, finalBody),
disclosure: issueWriteDisclosure(options, repo, issueNumber, true),
readCommands: issueBodyReadCommands(repo, issueNumber),
guard,
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, bodySource, oldIssue?.body ?? null),
bodyOnlySafety: {
oldBody: dryRunOldBody,
newBody: {
bodyChars: finalBody.length,
bodySha: bodySha(finalBody),
bodyPreview: preview(finalBody),
bodyPreviewLines: previewLines(finalBody),
},
},
concurrency: {
dryRunNoWrite: true,
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH",
},
wouldPatch: {
issueNumber,
title: options.title ?? null,
bodySource,
mode: options.mode,
bodyChars: finalBody.length,
bodySha: bodySha(finalBody),
},
...(options.notifyClaudeQqBriefDiff
? {
commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, finalBody, dryRunDiff ?? commanderBriefDiff("", finalBody), claudeQqConfig),
dryRunOldBodySource: "not-fetched",
dryRunDiffReliability: "new-body-only preview; non-dry-run reads the GitHub issue body before PATCH and sends only sections absent from the old body",
dryRunNoWrite: true,
dryRunNoClaudeQqSend: true,
}
: {}),
};
}
const { owner, name } = repoParts(repo);
const payload: Record<string, unknown> = { body: finalBody };
if (options.title !== undefined) payload.title = options.title;
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext);
const concurrency = oldIssue === null
? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }
: { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null };
if (!options.notifyClaudeQqBriefDiff) {
return {
ok: true,
command: commandName,
repo,
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
mode: options.mode,
bodySource,
guard,
concurrency,
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
readCommands: issueBodyReadCommands(repo, issueNumber),
rest: true,
};
}
const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody);
const claudeqq = diff.ok
? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message)
: {
ok: true,
attempted: false,
skipped: true,
skippedReason: diff.skippedReason ?? "no commander brief diff to send",
target: maskedTarget(claudeQqConfig),
} satisfies ClaudeQqSendResult;
return {
ok: true,
command: commandName,
repo,
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
guard,
concurrency,
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
readCommands: issueBodyReadCommands(repo, issueNumber),
rest: true,
commanderBriefNotification: {
issueNumber,
oldIssueUpdatedAt: oldIssue?.updated_at ?? null,
diff: {
ok: diff.ok,
mode: diff.mode,
chars: diff.chars,
sectionCount: diff.sectionCount,
skippedReason: diff.skippedReason ?? null,
preview: diff.ok ? preview(diff.message) : "",
previewLines: diff.ok ? previewLines(diff.message) : [],
containsLiteralBackslashN: diff.message.includes("\\n"),
containsBackticks: diff.message.includes("`"),
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message),
},
},
claudeqq,
};
}
async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
let bodyInput: { body: string; bodySource: Record<string, unknown> };
try {
bodyInput = readIssueCommentBody(options);
} catch (error) {
return validationError("issue comment create", repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const { body, bodySource } = bodyInput;
if (options.dryRun) {
return {
ok: true,
command: "issue comment create",
repo,
dryRun: true,
planned: true,
issueNumber,
readCommands: issueCommentReadCommands(repo, issueNumber),
...writeBodyPlan("issue comment create", repo, body, bodySource, { issueNumber }),
};
}
const { owner, name } = repoParts(repo);
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
return {
ok: true,
command: "issue comment create",
repo,
issueNumber,
comment: compactCommentSummary(comment),
bodySource,
bodyChars: body.length,
bodySha: bodySha(body),
bodyPreview: preview(body),
source: String(bodySource.kind ?? "unknown"),
readCommands: issueCommentReadCommands(repo, issueNumber),
rest: true,
};
}
async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> {
const command = `${ownerKind} comment delete`;
const { owner, name } = repoParts(repo);
if (dryRun) {
return {
ok: true,
command,
repo,
dryRun: true,
planned: true,
commentId,
request: { method: "DELETE", path: `/repos/{owner}/{repo}/issues/comments/${commentId}` },
};
}
const result = await githubRequest<null>(token, "DELETE", `/repos/${owner}/${name}/issues/comments/${commentId}`);
if (isGitHubError(result)) return commandError(command, repo, result, { commentId });
return {
ok: true,
command,
repo,
commentId,
deleted: true,
request: { method: "DELETE", path: `/repos/${owner}/${name}/issues/comments/${commentId}` },
rest: true,
};
}
async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean, options?: GitHubOptions): Promise<GitHubCommandResult> {
const command = state === "closed" ? "issue close" : "issue reopen";
let lifecycleComment: { body: string; bodySource: Record<string, unknown> } | null = null;
try {
lifecycleComment = options === undefined ? null : readIssueLifecycleCommentBody(options, command);
} catch (error) {
return validationError(command, repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
if (dryRun) {
return {
ok: true,
command,
dryRun: true,
repo,
issueNumber,
disclosure: issueLifecycleDisclosure(repo, issueNumber, true),
readCommands: issueBodyReadCommands(repo, issueNumber),
comment: lifecycleComment === null
? null
: {
planned: true,
...writeBodyPlan("issue comment create", repo, lifecycleComment.body, lifecycleComment.bodySource, { issueNumber }),
},
wouldPatch: { state },
};
}
const { owner, name } = repoParts(repo);
let commentSummary: Record<string, unknown> | null = null;
if (lifecycleComment !== null) {
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body: lifecycleComment.body });
if (isGitHubError(comment)) {
return commandError(command, repo, comment, {
issueNumber,
phase: "comment",
commentBodySource: lifecycleComment.bodySource,
commentBodyChars: lifecycleComment.body.length,
commentBodySha: bodySha(lifecycleComment.body),
});
}
commentSummary = compactCommentSummary(comment);
}
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state });
if (isGitHubError(issue)) return commandError(command, repo, issue, { issueNumber, phase: "state", comment: commentSummary });
return {
ok: true,
command,
repo,
comment: commentSummary,
issue: issueLifecycleSummary(issue),
disclosure: issueLifecycleDisclosure(repo, issueNumber, false),
readCommands: issueBodyReadCommands(repo, issueNumber),
rest: true,
};
}
function parseGitHubTimestamp(value: string | undefined): number | null {
if (value === undefined) return null;
const millis = Date.parse(value);
return Number.isFinite(millis) ? millis : null;
}
function inactiveIssueCandidates(issues: GitHubIssue[], cutoffMs: number): GitHubIssue[] {
return issues.filter((issue) => {
const updatedAtMs = parseGitHubTimestamp(issue.updated_at);
return updatedAtMs !== null && updatedAtMs < cutoffMs;
});
}
async function closeIssueForBatch(repo: string, token: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state: "closed" });
}
async function issueStaleClose(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
const command = "issue stale-close";
const observedAt = new Date();
const cutoffMs = observedAt.getTime() - Math.round(options.inactiveHours * 60 * 60 * 1000);
const cutoffAt = new Date(cutoffMs).toISOString();
const result = await listIssues(token, repo, "open", options.limit, "", options.labels);
if (isGitHubError(result)) {
return commandError(command, repo, result, {
state: "open",
limit: options.limit,
inactiveHours: options.inactiveHours,
cutoffAt,
labels: options.labels,
});
}
const staleIssues = inactiveIssueCandidates(result.items, cutoffMs);
const base = {
ok: true,
command,
repo,
dryRun: options.dryRun,
state: "open",
inactiveHours: options.inactiveHours,
observedAt: observedAt.toISOString(),
cutoffAt,
limit: options.limit,
labels: options.labels,
scannedCount: result.items.length,
rawCount: result.rawCount,
staleCount: staleIssues.length,
pagination: issueListPaginationSummary(result),
hasMore: result.hasMore,
stale: issueLifecycleBatchSummary(staleIssues),
policy: {
basis: "GitHub issue updatedAt",
selectedWhen: "updatedAt is older than observedAt - inactiveHours",
commentsAndStateChangesCountAsActivity: true,
pullRequestsFiltered: true,
},
readCommands: {
dryRun: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${options.limit} --dry-run`,
openList: `bun scripts/cli.ts gh issue list --repo ${repo} --state open --limit ${options.limit} --json number,title,state,url,updatedAt`,
},
...(result.hasMore && options.limit < MAX_ISSUE_LIST_LIMIT
? {
next: {
command: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${Math.min(options.limit * 2, MAX_ISSUE_LIST_LIMIT)} --dry-run`,
note: "The scan reached the bounded --limit before GitHub pagination was exhausted; increase --limit before treating the cleanup as complete.",
},
}
: result.hasMore
? {
scanLimit: {
maxLimitReached: true,
maxLimit: MAX_ISSUE_LIST_LIMIT,
note: "The cleanup reached the maximum bounded issue scan; narrow with --label or split the policy before treating it as complete.",
},
}
: {}),
};
if (options.dryRun) {
return {
...base,
planned: true,
wouldCloseCount: staleIssues.length,
wouldCloseNumbers: staleIssues.map((issue) => issue.number),
note: staleIssues.length === 0
? "No open issues matched the inactive-hours policy; no GitHub issue would be closed."
: "Dry-run only; no GitHub issue was closed.",
};
}
const closed: GitHubIssue[] = [];
const failures: Array<Record<string, unknown>> = [];
for (const issue of staleIssues) {
const closedIssue = await closeIssueForBatch(repo, token, issue.number);
if (isGitHubError(closedIssue)) {
failures.push({
number: issue.number,
title: issue.title,
url: issue.html_url,
updatedAt: issue.updated_at ?? null,
degradedReason: closedIssue.degradedReason,
runnerDisposition: closedIssue.runnerDisposition,
message: closedIssue.message,
status: closedIssue.status ?? null,
});
continue;
}
closed.push(closedIssue);
}
return {
...base,
dryRun: false,
planned: false,
closedCount: closed.length,
failedCount: failures.length,
closed: issueLifecycleBatchSummary(closed),
failures,
rest: true,
ok: failures.length === 0,
...(failures.length > 0
? {
degradedReason: "github-transient" as GitHubDegradedReason,
runnerDisposition: "infra-blocked" as RunnerDisposition,
retryable: true,
}
: {}),
note: failures.length === 0
? "Closed all open issues that matched the inactive-hours policy."
: "Some stale issue close operations failed; rerun the same command after checking failures.",
};
}
function escapeSnippet(text: string, index: number, radius = 80): string {
const start = Math.max(0, index - radius);
const end = Math.min(text.length, index + radius + 40);
return text.slice(start, end).replace(/\n/g, "\\n");
}
function lineColumnAt(text: string, index: number): { lineNumber: number; column: number } {
let lineNumber = 1;
let column = 1;
for (let cursor = 0; cursor < index && cursor < text.length; cursor += 1) {
if (text[cursor] === "\n") {
lineNumber += 1;
column = 1;
} else {
column += 1;
}
}
return { lineNumber, column };
}
function lineTextAt(text: string, lineNumber: number): string {
const lines = text.split(/\r?\n/);
return lines[lineNumber - 1] ?? "";
}
function isInsideFencedCodeBlock(text: string, index: number): boolean {
const lines = text.slice(0, index).split(/\r?\n/);
let openFence = false;
for (const line of lines) {
const trimmed = line.trimStart();
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) openFence = !openFence;
}
return openFence;
}
function isInsideInlineCode(text: string, index: number): boolean {
const { lineNumber, column } = lineColumnAt(text, index);
const line = lineTextAt(text, lineNumber);
const before = line.slice(0, Math.max(0, column - 1));
const after = line.slice(Math.max(0, column - 1));
return (before.match(/`/g)?.length ?? 0) % 2 === 1 && (after.match(/`/g)?.length ?? 0) > 0;
}
function isExplanatoryLiteralBackslashN(text: string, index: number): boolean {
if (isInsideFencedCodeBlock(text, index) || isInsideInlineCode(text, index)) return true;
const window = text.slice(Math.max(0, index - 90), Math.min(text.length, index + 90)).toLowerCase();
return [
"字面量",
"说明",
"示例",
"举例",
"提到",
"引用",
"文本",
"字符串",
"escape",
"literal",
"example",
"mention",
"quoted",
"反斜杠",
].some((keyword) => window.includes(keyword));
}
function cleanupEscapedText(text: string): string {
return text
.replace(/\\r\\n/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\x0a/gi, "\n")
.replace(/\\x1b/gi, "")
.replace(/\\u001b/gi, "")
.replace(/\\033/g, "")
.replace(/\\`/g, "`");
}
function escapeBodyId(bodyKind: EscapeBodyKind, issueNumber: number, issueId: number, commentId?: number): string {
return bodyKind === "issue-body"
? `issue:${issueNumber}:body:${issueId}`
: `issue:${issueNumber}:comment:${commentId ?? "unknown"}`;
}
function textFindingPatterns(): EscapePatternDefinition[] {
return [
{
kind: "literal-backslash-n",
pattern: /\\n/g,
description: "literal backslash-n",
},
{
kind: "literal-backslash-t",
pattern: /\\t/g,
description: "literal backslash-t",
},
{
kind: "escaped-backtick",
pattern: /\\`/g,
description: "escaped backtick",
},
{
kind: "shell-escaped-newline",
pattern: /\\r\\n|\\012|\\x0a/gi,
description: "shell escaped newline",
},
{
kind: "ansi-escape-literal",
pattern: /\\x1b|\\u001b|\\033/gi,
description: "ANSI escape literal",
},
];
}
function bodyRiskFindings(kind: EscapeBodyKind, issueNumber: number, issueId: number, body: string | null, url: string, commentId?: number): EscapeMatchFinding[] {
const bodyChars = body === null ? null : body.length;
const bodyTrimmedChars = body === null ? null : body.trim().length;
const location = {
type: kind === "issue-body" ? "issue" as const : "comment" as const,
bodyKind: kind,
bodyId: escapeBodyId(kind, issueNumber, issueId, commentId),
issueNumber,
issueId,
...(commentId === undefined ? {} : { commentId }),
url,
bodyChars,
bodyTrimmedChars,
};
if (body === null) {
return [{
...location,
kind: "null-body",
classification: "risk",
severity: "high",
reason: "GitHub returned a null body",
snippet: "",
lineNumber: 0,
column: 0,
match: "",
}];
}
const trimmed = body.trim();
if (trimmed.toLowerCase() === "null") {
return [{
...location,
kind: "literal-null-body",
classification: "risk",
severity: "high",
reason: "body text is the literal string null",
snippet: escapeSnippet(body, 0),
lineNumber: 1,
column: 1,
match: "null",
}];
}
if (trimmed.length === 0) {
return [{
...location,
kind: "blank-body",
classification: "risk",
severity: "medium",
reason: "body is blank after trimming",
snippet: "",
lineNumber: 0,
column: 0,
match: "",
}];
}
if (trimmed.length < MIN_SAFE_BODY_SCAN_CHARS) {
return [{
...location,
kind: "short-body",
classification: "risk",
severity: "medium",
reason: `body is shorter than the safe guard threshold (${MIN_SAFE_BODY_SCAN_CHARS} chars)`,
snippet: escapeSnippet(body, 0),
lineNumber: 1,
column: 1,
match: "",
}];
}
return [];
}
function scanText(text: string, patterns: EscapePatternDefinition[], issueNumber: number, issueId: number, bodyKind: EscapeBodyKind, url: string, commentId?: number): EscapeMatchFinding[] {
const findings: EscapeMatchFinding[] = [];
const counts = new Map<string, number>();
for (const item of patterns) {
item.pattern.lastIndex = 0;
const matches = text.match(item.pattern);
counts.set(item.kind, matches === null ? 0 : matches.length);
}
for (const item of patterns) {
item.pattern.lastIndex = 0;
let match = item.pattern.exec(text);
while (match !== null) {
const { lineNumber, column } = lineColumnAt(text, match.index);
const explanatory = item.kind === "literal-backslash-n" && counts.get(item.kind) === 1 && isExplanatoryLiteralBackslashN(text, match.index);
const classification: EscapeFindingClassification = explanatory ? "explanatory-mention" : "suspected-pollution";
const severity: EscapeFindingSeverity = explanatory ? "low" : item.kind === "ansi-escape-literal" ? "high" : "medium";
findings.push({
type: bodyKind === "issue-body" ? "issue" : "comment",
bodyKind,
bodyId: escapeBodyId(bodyKind, issueNumber, issueId, commentId),
issueNumber,
issueId,
...(commentId === undefined ? {} : { commentId }),
url,
kind: item.kind,
classification,
severity,
reason: explanatory
? "literal backslash-n appears in explanatory context"
: `matched ${item.description}`,
snippet: escapeSnippet(text, match.index),
lineNumber,
column,
match: match[0],
bodyChars: text.length,
bodyTrimmedChars: text.trim().length,
...(classification === "suspected-pollution"
? { cleanupPreview: { before: escapeSnippet(text, match.index), after: escapeSnippet(cleanupEscapedText(text), match.index) } }
: {}),
});
if (findings.length >= ISSUE_SCAN_MAX_FINDINGS) return findings;
match = item.pattern.exec(text);
}
}
return findings;
}
function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupSuggestion[] {
const groups = new Map<string, EscapeMatchFinding[]>();
for (const finding of findings) {
if (finding.type === "comment-scan-error") continue;
if (finding.classification !== "suspected-pollution" && finding.kind !== "short-body" && finding.kind !== "null-body" && finding.kind !== "blank-body" && finding.kind !== "literal-null-body") continue;
const locatorKey = `${finding.bodyKind}:${finding.issueNumber}:${finding.issueId}:${finding.commentId ?? ""}`;
const list = groups.get(locatorKey);
if (list === undefined) groups.set(locatorKey, [finding]);
else list.push(finding);
}
const suggestions: EscapeCleanupSuggestion[] = [];
for (const group of groups.values()) {
const first = group[0];
const suspected = group.some((finding) => finding.classification === "suspected-pollution");
const bodyRisk = group.some((finding) => finding.classification === "risk");
suggestions.push({
type: first.bodyKind,
bodyId: first.bodyId,
issueNumber: first.issueNumber,
issueId: first.issueId,
...(first.commentId === undefined ? {} : { commentId: first.commentId }),
url: first.url,
classification: suspected ? "suspected-pollution" : "risk",
action: first.bodyKind === "issue-body"
? (suspected ? "rewrite-issue-body-with-body-file" : "review-body-length")
: "review-comment-manually",
reason: suspected
? "suspected shell escape pollution should be rewritten from a body file"
: bodyRisk
? "body length/null risk should be reviewed before any write"
: "no cleanup needed",
findings: group.map((finding) => ({
kind: finding.kind,
classification: finding.classification,
severity: finding.severity,
lineNumber: finding.lineNumber,
column: finding.column,
snippet: finding.snippet,
match: finding.match,
})),
...(first.bodyKind === "issue-body"
? {
plannedCommand: `bun scripts/cli.ts gh issue update ${first.issueNumber} --mode replace --body-file <file> --dry-run`,
bodyFileHint: "write a cleaned Markdown body to a file, then pass it with --body-file; use a quoted heredoc to stage the file instead of inline shell text",
preview: {
before: first.cleanupPreview?.before ?? first.snippet,
after: first.cleanupPreview?.after ?? first.snippet,
},
}
: {
preview: {
before: first.cleanupPreview?.before ?? first.snippet,
after: first.cleanupPreview?.after ?? first.snippet,
},
}),
});
}
return suggestions;
}
async function issueScanEscape(repo: string, token: string, limit: number, dryRun: boolean, commandName = "issue scan-escape"): Promise<GitHubCommandResult> {
const issues = await listIssues(token, repo, "all", limit);
if (isGitHubError(issues)) return commandError(commandName, repo, issues);
const issueOnly = issues.items;
const patterns = textFindingPatterns();
const findings: EscapeScanEntry[] = [];
let scannedComments = 0;
for (const issue of issueOnly) {
findings.push(...bodyRiskFindings("issue-body", issue.number, issue.id, issue.body, issue.html_url));
findings.push(...scanText(issue.body ?? "", patterns, issue.number, issue.id, "issue-body", issue.html_url));
const comments = await listIssueComments(token, repo, issue.number);
if (isGitHubError(comments)) {
findings.push({
type: "comment-scan-error",
bodyKind: "comment-body",
bodyId: escapeBodyId("comment-body", issue.number, issue.id),
issueNumber: issue.number,
issueId: issue.id,
url: issue.html_url,
degradedReason: comments.degradedReason,
runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason),
details: comments,
});
continue;
}
for (const comment of comments) {
scannedComments += 1;
findings.push(...bodyRiskFindings("comment-body", issue.number, issue.id, comment.body, comment.html_url, comment.id));
findings.push(...scanText(comment.body ?? "", patterns, issue.number, issue.id, "comment-body", comment.html_url, comment.id));
}
}
const cleanupSuggestions = summarizeCleanupSuggestion(findings);
const summary = findings.reduce((acc, finding) => {
if (finding.type === "comment-scan-error") {
acc.commentScanErrors += 1;
return acc;
}
if (finding.classification === "suspected-pollution") acc.suspectedPollution += 1;
if (finding.classification === "explanatory-mention") acc.explanatoryMention += 1;
if (finding.classification === "risk") acc.risk += 1;
if (finding.kind === "null-body" || finding.kind === "literal-null-body" || finding.kind === "blank-body" || finding.kind === "short-body") acc.bodyRisks += 1;
return acc;
}, { suspectedPollution: 0, explanatoryMention: 0, risk: 0, bodyRisks: 0, commentScanErrors: 0 });
return {
ok: true,
command: commandName,
repo,
dryRun,
planned: true,
scannedIssues: issueOnly.length,
rawIssues: issues.rawCount,
pagination: issueListPaginationSummary(issues),
scannedComments,
findings,
cleanupSuggestions,
summary: {
findings: findings.length,
suggestions: cleanupSuggestions.length,
...summary,
},
note: dryRun
? "Dry-run cleanup planning only; no issue or comment content was modified."
: "Read-only scan; no issue or comment content was modified.",
};
}
async function authStatus(repo: string): Promise<GitHubCommandResult> {
const ghPath = ghBinaryPath();
const { token, probe } = resolveToken(ghPath !== null);
const degraded: GitHubDegradedReason[] = [];
if (ghPath === null) degraded.push("missing-binary");
if (!probe.present || token === null) {
if (ghPath === null) {
return {
ok: false,
command: "auth status",
repo,
degradedReason: "missing-binary",
degraded,
runnerDisposition: runnerDisposition("missing-binary"),
gh: { binaryFound: false, path: null },
token: probe,
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
};
}
degraded.push("missing-token");
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), {
degraded,
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
});
}
const { owner, name } = repoParts(repo);
const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit");
if (isGitHubError(api)) {
return commandError("auth status", repo, api, {
degraded: [...degraded, api.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: api, repo: "skipped", issueRead: "skipped" },
});
}
const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`);
if (isGitHubError(repoProbe)) {
return commandError("auth status", repo, repoProbe, {
degraded: [...degraded, repoProbe.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" },
});
}
const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`);
if (isGitHubError(issueProbe)) {
return commandError("auth status", repo, issueProbe, {
degraded: [...degraded, issueProbe.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe },
});
}
return {
ok: true,
command: "auth status",
repo,
degraded,
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: {
restApi: "ok",
repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null },
issueRead: { ok: true, readable: true, sampleCount: issueProbe.length },
},
restFallback: true,
};
}
async function prList(repo: string, token: string, state: PrListState, limit: number, jsonFields: PrListJsonField[] | undefined, noDump = false): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=${state}&per_page=${limit}`);
if (isGitHubError(prs)) return commandError("pr list", repo, prs, { state, limit });
const fields = jsonFields ?? PR_LIST_JSON_FIELDS.slice();
return {
ok: true,
command: "pr list",
repo,
...(noDump ? { noDump: true } : {}),
state,
limit,
count: prs.length,
jsonFields: fields,
pullRequests: prs.map((pr) => selectedPrJson(prSummary(pr), fields)),
};
}
async function prFiles(repo: string, token: string, number: number, limit: number, commandName = "pr files"): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const boundedLimit = Math.min(limit, MAX_PR_FILES_LIMIT);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
const perPage = Math.max(1, Math.min(100, boundedLimit));
const files: GitHubPullRequestFile[] = [];
let page = 1;
while (files.length < boundedLimit) {
const remaining = boundedLimit - files.length;
const pageSize = Math.min(perPage, remaining);
const path = `/repos/${owner}/${name}/pulls/${number}/files?per_page=${pageSize}&page=${page}`;
const pageFiles = await githubRequest<GitHubPullRequestFile[]>(token, "GET", path);
if (isGitHubError(pageFiles)) return commandError(commandName, repo, pageFiles, { number, phase: "fetch-pr-files", filesReturned: files.length });
files.push(...pageFiles);
if (pageFiles.length < pageSize || pageFiles.length === 0) break;
page += 1;
}
const totalFiles = numberOrNull(pr.changed_files);
const listedStats = sumPrFileStats(files);
const totalAdditions = numberOrNull(pr.additions);
const totalDeletions = numberOrNull(pr.deletions);
const fullStatsAvailable = totalAdditions !== null && totalDeletions !== null;
const totalChanges = fullStatsAvailable ? totalAdditions + totalDeletions : listedStats.changes;
const truncated = totalFiles !== null ? files.length < totalFiles : files.length >= boundedLimit;
const nextLimit = totalFiles === null ? MAX_PR_FILES_LIMIT : Math.min(totalFiles, MAX_PR_FILES_LIMIT);
const nextCommand = truncated
? `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${nextLimit}`
: `bun scripts/cli.ts gh pr view ${number} --repo ${repo} --json body,title,state,head,base`;
return {
ok: true,
command: commandName,
repo,
readOnly: true,
rawDiffIncluded: false,
pullRequest: prCompactSummary(pr),
summary: {
files: totalFiles ?? files.length,
additions: totalAdditions ?? listedStats.additions,
deletions: totalDeletions ?? listedStats.deletions,
changes: totalChanges,
commits: numberOrNull(pr.commits),
source: fullStatsAvailable && totalFiles !== null ? "pull-request-rest" : "listed-files-rest",
},
files: files.map(prFileSummary),
filesReturned: files.length,
limit: boundedLimit,
truncation: {
truncated,
requestedLimit: limit,
appliedLimit: boundedLimit,
returned: files.length,
totalFiles,
maxLimit: MAX_PR_FILES_LIMIT,
},
next: {
command: nextCommand,
purpose: truncated ? "Fetch a larger bounded file summary page." : "Fetch full PR metadata/body; raw diffs remain intentionally excluded.",
},
request: {
method: "GET",
paths: [
`/repos/${owner}/${name}/pulls/${number}`,
`/repos/${owner}/${name}/pulls/${number}/files`,
],
},
};
}
async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read", disclosure: Record<string, unknown> | null = null): 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 ?? [], closeoutMetadata: prCloseoutMetadataError(metadata) });
const selectionSummary = metadata === null ? summary : { ...summary, ...prMetadataSummary(metadata) };
return {
ok: true,
command: commandName,
repo,
...(disclosure === null ? {} : { disclosure }),
pullRequest: summary,
...(metadata === null ? {} : { closeoutMetadata: prCloseoutMetadata(metadata) }),
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(selectionSummary, jsonFields) }),
};
}
async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
return prRead(repo, token, number, jsonFields, "pr view", disclosure);
}
export function ghHelp(): unknown {
return {
command: "gh",
output: "json",
usage: [
"bun scripts/cli.ts gh auth status [--repo owner/name]",
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]",
"bun scripts/cli.ts gh issue view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments] [--raw|--full]",
"bun scripts/cli.ts gh issue read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]",
"bun scripts/cli.ts gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file|-> [--title title] [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file|-> [--repo owner/name] [--number N compat] [--full|--raw] [compat alias for issue update --mode replace]",
"bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]",
"bun scripts/cli.ts gh issue comment create <number> --body-file <file|->|--body <short-text> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment <short-text>|--comment-file <file|->] [--dry-run]",
"bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]",
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]",
"bun scripts/cli.ts gh issue cleanup-plan [--repo owner/name] [--limit N] [--dry-run]",
"bun scripts/cli.ts gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]",
"bun scripts/cli.ts gh issue board-row list [--repo owner/name] --board-issue 20 [--state open|closed|all] [--dry-run]",
"bun scripts/cli.ts gh issue board-row get <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20",
"bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row add <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --section open|closed --row-file <markdown-row-file> [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] [--number N compat] --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] [--number N compat] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh preflight <prNumber|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [compatibility alias for gh pr preflight]",
"bun scripts/cli.ts gh pr list [owner/repo] [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
"bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]",
"bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]",
"bun scripts/cli.ts gh pr view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]",
"bun scripts/cli.ts gh pr read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for pr view]",
"bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options]",
"bun scripts/cli.ts gh pr closeout <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]",
"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 edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]",
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
],
defaults: { repo: DEFAULT_REPO },
notes: [
"Issue and PR create/read/update/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
"Token values are never printed; auth status reports only token source and presence.",
"issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.",
"issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
"issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
"GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.",
"issue create accepts --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.",
"issue edit is a compatibility alias for issue update --mode replace.",
"issue update --body-file accepts files or - for stdin, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).",
"issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.",
"issue comment create accepts --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.",
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text> or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-file is the recommended path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view when full text is needed.",
"issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.",
"For one-shot issue writes, pipe reviewed Markdown through stdin: cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - or gh issue comment create <number> --body-file -. When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.",
"For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue/PR Markdown writes use --body-file <file|-> for long or multiline content.",
"issue scan-escape classifies literal \\n findings as suspected-pollution, explanatory-mention, or risk, and emits cleanupSuggestions with body/comment ids plus diff-like previews. cleanup-plan is an alias that remains dry-run/read-only.",
"issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It reads only the board issue body, returns body size/SHA and parsed Markdown board sections, and no longer validates GitHub open/closed issue coverage against OPEN/CLOSED tables. The legacy coverage fields remain present as empty arrays/zero counts for compatibility.",
"issue board-row list/get reuse the board-audit table parser and are read-only. board-row update changes one table cell by issue number, returns old/new row, body SHA, body guard and request plan, and defaults to dry-run unless --expect-updated-at or --expect-body-sha is supplied for the guarded PATCH. Field aliases map status and validation to the 验收状态 column, tasks to 相关 Code Queue 任务, and focus to 当前关注点.",
"issue board-row upsert updates an existing row when the issue is already present, or generates a complete row in --section open|closed when missing. It returns operation=update or operation=add, defaults to dry-run, requires --expect-body-sha or --expect-updated-at before PATCH, and refuses section migration; use board-row move for OPEN/CLOSED migration.",
"issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation, and the shared #20 guard warns about HWLAB product/user issue routing in favor of pikasTech/HWLAB without refusing the write.",
"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 files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
"PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment delete treats --number as commentId, not a PR number. PR view/read 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 preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.",
"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 an explicit read-only policy. Use --full or --raw to include all fetched status contexts; gh pr merge is the separate guarded write path.",
"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.",
],
};
}
export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> {
const [top, sub, third] = args;
if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp();
let options: GitHubOptions;
try {
options = parseOptions(args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
const repo = optionValue(args, "--repo") ?? DEFAULT_REPO;
const unknownOption = /^unknown gh option:\s+(.+)$/u.exec(message)?.[1];
return unknownOption !== undefined
? unsupportedCommand(command, repo, message, unknownGhOptionDetails(args, unknownOption))
: validationError(command, repo, message);
}
if (options.notifyClaudeQqBriefDiff && !(top === "issue" && sub === "edit")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--notify-claudeqq-brief-diff is only supported by gh issue edit 24");
}
if (optionWasProvided(args, "--state") && !(top === "issue" && (sub === "list" || sub === "board-row" && third === "list") || top === "pr" && sub === "list")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--state is only supported by gh issue list, gh issue board-row list, and gh pr list");
}
if (optionWasProvided(args, "--json") && !(top === "issue" && (isIssueReadCommand(sub) || sub === "list"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
if (!(top === "pr" && (isPrReadCommand(sub) || sub === "list"))) {
return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list");
}
}
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "list" || sub === "update" || sub === "edit")) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "list" || sub === "preflight" || sub === "closeout")))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit, gh pr list/read/view, and gh pr preflight/closeout.", {
supportedCommands: [
"bun scripts/cli.ts gh issue list --repo owner/name --limit 200 --full",
"bun scripts/cli.ts gh issue view owner/name#<number> --raw",
"bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state,comments",
"cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full",
"bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full",
"bun scripts/cli.ts gh pr view owner/name#<number> --raw",
`bun scripts/cli.ts gh pr view <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
"bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full",
],
});
}
if (optionWasProvided(args, "--stat") && !(top === "pr" && (sub === "diff" || sub === "files"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--stat is only supported by gh pr diff <number> --stat.", {
supportedCommands: [
"bun scripts/cli.ts gh pr files <number> --repo owner/name --limit 30",
"bun scripts/cli.ts gh pr diff <number> --stat --repo owner/name --limit 30",
],
});
}
if (optionWasProvided(args, "--number") && !allowsNumberTargetAlias(top, sub, third)) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
const standardViewCommand = top === "issue" || top === "pr" ? `gh ${top} view` : "gh issue/pr view";
return validationError(command, options.repo, `--number is only a compatibility alias for single numeric target commands; standard ${standardViewCommand} uses a positional number or URL target.`, {
supportedCommands: [
"bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state",
"bun scripts/cli.ts gh issue view https://github.com/owner/name/issues/<number> --raw",
"bun scripts/cli.ts gh pr view <number> --repo owner/name --json body,title,state,head,base",
"bun scripts/cli.ts gh pr view https://github.com/owner/name/pull/<number> --raw",
],
rejectedOption: "--number",
});
}
if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && (sub === "update" || sub === "edit")))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--mode is only supported by gh issue update/edit and gh pr update/edit");
}
if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && (sub === "edit" || sub === "update" || sub === "board-row" && ["update", "add", "move", "delete", "upsert"].includes(third ?? "")))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue update/edit and gh issue board-row update/add/move/delete/upsert");
}
if ((optionWasProvided(args, "--board-issue") || optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && (sub === "board-audit" || sub === "board-row"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--board-issue, --known-meta-issue, and --ignore-issue are only supported by gh issue board-audit and --board-issue by gh issue board-row");
}
if ((optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && sub === "board-audit")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--known-meta-issue and --ignore-issue are only supported by gh issue board-audit");
}
if ((optionWasProvided(args, "--field") || optionWasProvided(args, "--value")) && !(top === "issue" && sub === "board-row" && third === "update")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--field and --value are only supported by gh issue board-row update");
}
if (optionWasProvided(args, "--row-file") && !(top === "issue" && sub === "board-row" && third === "add")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--row-file is only supported by gh issue board-row add");
}
if (optionWasProvided(args, "--section") && !(top === "issue" && sub === "board-row" && (third === "add" || third === "upsert"))) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--section is only supported by gh issue board-row add/upsert");
}
if (optionWasProvided(args, "--to") && !(top === "issue" && sub === "board-row" && third === "move")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--to is only supported by gh issue board-row move");
}
if (optionWasProvided(args, "--status") && !(top === "issue" && sub === "board-row" && (third === "move" || third === "upsert"))) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--status is only supported by gh issue board-row move/upsert");
}
if ((optionWasProvided(args, "--category") || optionWasProvided(args, "--branch") || optionWasProvided(args, "--tasks") || optionWasProvided(args, "--summary") || optionWasProvided(args, "--focus") || optionWasProvided(args, "--validation") || optionWasProvided(args, "--progress")) && !(top === "issue" && sub === "board-row" && third === "upsert")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--category, --branch, --tasks, --summary, --focus, --validation, and --progress are only supported by gh issue board-row upsert");
}
if (optionWasProvided(args, "--label") && !(top === "issue" && (sub === "create" || sub === "list" || sub === "stale-close"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--label is only supported by gh issue create, gh issue list, and gh issue stale-close");
}
if (optionWasProvided(args, "--search") && !(top === "issue" && sub === "list")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--search is only supported by gh issue list");
}
if (optionWasProvided(args, "--inactive-hours") && !(top === "issue" && sub === "stale-close")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--inactive-hours is only supported by gh issue stale-close");
}
if (optionWasProvided(args, "--comment") && !(top === "issue" && (sub === "close" || sub === "reopen"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--comment/--comment-file is only supported by gh issue close/reopen; use gh issue comment create for standalone comments");
}
if (top === "auth" && sub === "status") return authStatus(options.repo);
if (top === "preflight") {
const resolved = resolvePositionalPrReference(args, 1, "preflight", options);
if (isGitHubCommandResult(resolved)) return resolved;
return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw), resolved);
}
if (top === "issue") {
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
if (sub === "comment" && third === "delete") {
const resolved = resolvePositionalNumberReference("issue", args, 3, "issue comment delete", options);
if (isGitHubCommandResult(resolved)) return resolved;
const commentId = resolved.number;
if (typeof commentId !== "number") return commentId;
if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "issue", commentId, true), resolved);
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, "issue comment delete", probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(commentDelete(resolved.repo, token, "issue", commentId, false), resolved);
}
if (sub === "comment" && third === "create") {
const resolved = resolvePositionalIssueReference(args, 3, "issue comment create", options);
if (isGitHubCommandResult(resolved)) return resolved;
const issueNumber = resolved.number;
if (typeof issueNumber !== "number") return issueNumber;
if (options.dryRun) return withNumberOptionHint(issueComment(resolved.repo, "", issueNumber, { ...options, repo: resolved.repo }), resolved);
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, "issue comment create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(issueComment(resolved.repo, token, issueNumber, { ...options, repo: resolved.repo }), resolved);
}
if (sub === "scan-escape" || sub === "cleanup-plan") {
const { token, probe } = resolveToken(true);
const commandName = sub === "cleanup-plan" ? "issue cleanup-plan" : "issue scan-escape";
const missing = authRequired(options.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return issueScanEscape(options.repo, token, options.limit, options.dryRun || sub === "cleanup-plan", commandName);
}
if (sub === "board-audit") {
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "issue board-audit", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue board-audit", { present: false, source: null, ghFallbackAttempted: true });
return issueBoardAudit(options.repo, token, options);
}
if (sub === "board-row") {
const action = third;
const commandName = `issue board-row ${action ?? ""}`.trim();
if (action !== "list" && action !== "get" && action !== "update" && action !== "add" && action !== "move" && action !== "delete" && action !== "upsert") {
return unsupportedCommand(commandName, options.repo, "board-row supported commands are list, get, update, add, move, delete, and upsert.");
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
if (action === "list") return issueBoardRowList(options.repo, token, options);
const resolvedBoardRow = resolvePositionalIssueReference(args, 3, commandName, options);
if (isGitHubCommandResult(resolvedBoardRow)) return resolvedBoardRow;
const issueNumber = resolvedBoardRow.number;
if (typeof issueNumber !== "number") return issueNumber;
const boardRowOptions = { ...options, repo: resolvedBoardRow.repo };
if (action === "get") return withNumberOptionHint(issueBoardRowGet(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
if (action === "update") return withNumberOptionHint(issueBoardRowUpdate(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
if (action === "add") return withNumberOptionHint(issueBoardRowAdd(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
if (action === "upsert") return withNumberOptionHint(issueBoardRowUpsert(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
if (action === "move") return withNumberOptionHint(issueBoardRowMove(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
return withNumberOptionHint(issueBoardRowDelete(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
}
if (options.dryRun) {
if (sub === "create") return issueCreate(options.repo, "", options);
if (sub === "edit") {
const issueEditRef = resolvePositionalIssueReference(args, 2, "issue edit", options);
if (isGitHubCommandResult(issueEditRef)) return issueEditRef;
const issueNumber = issueEditRef.number;
const { token } = resolveToken(false);
return withNumberOptionHint(issueEdit(issueEditRef.repo, token ?? "", issueNumber, { ...options, repo: issueEditRef.repo }), issueEditRef);
}
if (sub === "update") {
const issueUpdateRef = resolvePositionalIssueReference(args, 2, "issue update", options);
if (isGitHubCommandResult(issueUpdateRef)) return issueUpdateRef;
const issueNumber = issueUpdateRef.number;
const { token } = resolveToken(false);
return withNumberOptionHint(issueEdit(issueUpdateRef.repo, token ?? "", issueNumber, { ...options, repo: issueUpdateRef.repo }, "issue update"), issueUpdateRef);
}
if (sub === "comment") {
const r = resolvePositionalIssueReference(args, 2, "issue comment", options);
if (isGitHubCommandResult(r)) return r;
return withNumberOptionHint(issueComment(r.repo, "", r.number, { ...options, repo: r.repo }), r);
}
if (sub === "close") {
const r = resolvePositionalIssueReference(args, 2, "issue close", options);
if (isGitHubCommandResult(r)) return r;
return withNumberOptionHint(issueState(r.repo, "", r.number, "closed", true, { ...options, repo: r.repo }), r);
}
if (sub === "reopen") {
const r = resolvePositionalIssueReference(args, 2, "issue reopen", options);
if (isGitHubCommandResult(r)) return r;
return withNumberOptionHint(issueState(r.repo, "", r.number, "open", true, { ...options, repo: r.repo }), r);
}
}
if (sub === "read" || sub === "view") {
const resolved = resolveReadViewNumberReference("issue", sub, third, options, args);
if (isGitHubCommandResult(resolved)) return resolved;
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, `issue ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `issue ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
const disclosure = readDisclosureOptions(options, resolved.shorthand);
if (sub === "read") return withNumberOptionHint(issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure), resolved);
return withNumberOptionHint(issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure), resolved);
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `issue ${sub ?? ""}`.trim(), { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields, options.search, options.labels, options.raw || options.full);
if (sub === "stale-close") return issueStaleClose(options.repo, token, options);
if (sub === "create") return issueCreate(options.repo, token, options);
if (sub === "edit") {
const r = resolvePositionalIssueReference(args, 2, "issue edit", options);
if (isGitHubCommandResult(r)) return r;
return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }), r);
}
if (sub === "update") {
const r = resolvePositionalIssueReference(args, 2, "issue update", options);
if (isGitHubCommandResult(r)) return r;
return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }, "issue update"), r);
}
if (sub === "comment") {
const r = resolvePositionalIssueReference(args, 2, "issue comment", options);
if (isGitHubCommandResult(r)) return r;
return withNumberOptionHint(issueComment(r.repo, token, r.number, { ...options, repo: r.repo }), r);
}
if (sub === "close") {
const r = resolvePositionalIssueReference(args, 2, "issue close", options);
if (isGitHubCommandResult(r)) return r;
return withNumberOptionHint(issueState(r.repo, token, r.number, "closed", options.dryRun, { ...options, repo: r.repo }), r);
}
if (sub === "reopen") {
const r = resolvePositionalIssueReference(args, 2, "issue reopen", options);
if (isGitHubCommandResult(r)) return r;
return withNumberOptionHint(issueState(r.repo, token, r.number, "open", options.dryRun, { ...options, repo: r.repo }), r);
}
}
if (top === "pr") {
if (sub === "diff") {
const resolved = resolvePositionalPrReference(args, 2, "pr diff", options);
if (isGitHubCommandResult(resolved)) return resolved;
if (!optionWasProvided(args, "--stat")) {
return unsupportedCommand("pr diff", options.repo, "Raw PR diff output is intentionally unsupported by UniDesk CLI; use gh pr diff <number> --stat or gh pr files for a bounded REST file/stat summary.", {
rawDiffIncluded: false,
supportedCommands: [
`bun scripts/cli.ts gh pr files ${resolved.number} --repo ${resolved.repo} --limit 30`,
`bun scripts/cli.ts gh pr diff ${resolved.number} --stat --repo ${resolved.repo} --limit 30`,
],
});
}
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, "pr diff --stat", probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr diff --stat"), resolved);
}
if (sub === "files") {
const resolved = resolvePositionalPrReference(args, 2, "pr files", options);
if (isGitHubCommandResult(resolved)) return resolved;
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, "pr files", probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr files", { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr files"), resolved);
}
if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics.");
if (sub === "preflight" || sub === "closeout") {
const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options);
if (isGitHubCommandResult(resolved)) return resolved;
return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw), resolved);
}
if (sub === "comment" && third === "delete") {
const resolved = resolvePositionalNumberReference("pr", args, 3, "pr comment delete", options);
if (isGitHubCommandResult(resolved)) return resolved;
if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "pr", resolved.number, true), resolved);
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, "pr comment delete", probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(commentDelete(resolved.repo, token, "pr", resolved.number, false), resolved);
}
if (sub === "comment" && third === "create") {
const resolved = resolvePositionalPrReference(args, 3, "pr comment create", options);
if (isGitHubCommandResult(resolved)) return resolved;
if (options.dryRun) return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved);
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, "pr comment create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment create", { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved);
}
if (sub === "create") {
if (options.dryRun) return prCreate(options.repo, "", options);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr create", { present: false, source: null, ghFallbackAttempted: true });
return prCreate(options.repo, token, options);
}
if (sub === "comment") {
if (options.dryRun) {
const resolved = resolvePositionalPrReference(args, 2, "pr comment", options);
if (isGitHubCommandResult(resolved)) return resolved;
return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved);
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr comment", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true });
const resolved = resolvePositionalPrReference(args, 2, "pr comment", options);
if (isGitHubCommandResult(resolved)) return resolved;
return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved);
}
if (sub === "update" || sub === "edit") {
const commandName = `pr ${sub}`;
const resolved = resolvePositionalPrReference(args, 2, commandName, options);
if (isGitHubCommandResult(resolved)) return resolved;
if (options.dryRun && options.mode === "replace") return withNumberOptionHint(prUpdate(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }, commandName), resolved);
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(prUpdate(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }, commandName), resolved);
}
if (sub === "close" || sub === "reopen") {
const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options);
if (isGitHubCommandResult(resolved)) return resolved;
if (options.dryRun) return withNumberOptionHint(prState(resolved.repo, "", resolved.number, sub === "close" ? "closed" : "open", true), resolved);
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(prState(resolved.repo, token, resolved.number, sub === "close" ? "closed" : "open", false), resolved);
}
if (sub === "merge") {
const resolved = resolvePositionalPrReference(args, 2, "pr merge", options);
if (isGitHubCommandResult(resolved)) return resolved;
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 withNumberOptionHint(prMerge(resolved.repo, token, resolved.number, options), resolved);
}
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, merge, comment create/delete, and unsupported delete.");
}
if (sub === "read" || sub === "view") {
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);
if (isGitHubCommandResult(resolved)) return resolved;
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
const disclosure = readDisclosureOptions(options, resolved.shorthand);
if (sub === "read") return withNumberOptionHint(prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure), resolved);
return withNumberOptionHint(prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure), resolved);
}
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.prListState, options.limit, options.prListJsonFields, options.raw || options.full);
}
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });
}