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 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", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const; const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const; const PR_LIST_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const; const PR_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"]); const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat"]); 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 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; } 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>; 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>; } 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>; rowPreview: string; } interface BoardRowLookup { ok: true; section: BoardTableSection; row: BoardTableRow; matches: Array<{ section: BoardTableSection; row: BoardTableRow }>; } interface BoardRowUpdatePlanResult { ok: true; plan: Record; newBody: string; } interface BoardRowMutationPlanResult { ok: true; plan: Record; 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; boardIssue: number; knownMetaIssues: number[]; ignoredIssues: number[]; draft: boolean; notifyClaudeQqBriefDiff: boolean; allowShortBody: boolean; labels: string[]; title?: string; body?: string; bodyFile?: 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; } interface GitHubShorthandReference { input: string; repo: string; number: number; } 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; pull_request?: unknown; created_at?: string; updated_at?: string; } interface GitHubComment { id: number; body: string | null; html_url: string; user?: { login?: string }; created_at?: string; updated_at?: string; } 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 }; 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; } 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(); 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(); 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 validateEnumValue(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(command: string, requested: string[] | undefined, allowedFields: readonly T[]): T[] | undefined { if (requested === undefined) return undefined; const allowed = new Set(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(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 --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 --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 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 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 : 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 : 30, limitMax), 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), title: optionValue(args, "--title"), body: optionValue(args, "--body"), bodyFile: optionValue(args, "--body-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), }; } 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 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]), }; } function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] { return [ `bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`, ]; } function readViewSupportedJsonFields(kind: "issue" | "pr"): string { return kind === "issue" ? "body,title,state,comments" : "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 number or owner/repo#number target; use --repo owner/name and --number N when passing options first`, { supportedCommands: [ `bun scripts/cli.ts gh ${kind} read --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} read owner/name# --raw`, ], }); } const raw = targets[0]; const numberAliasRaw = kind === "pr" ? optionValue(args, "--number") : undefined; const shorthand = parseOwnerRepoNumberShorthand(raw); if (shorthand !== null) { if (numberAliasRaw !== undefined) { const aliasNumber = parseNumberForCommand(shorthand.repo, numberAliasRaw, command); if (typeof aliasNumber !== "number") return aliasNumber; if (aliasNumber !== shorthand.number) { return validationError(command, shorthand.repo, `${command} target ${shorthand.input} conflicts with --number ${aliasNumber}; use one PR number target.`, { shorthand, numberAlias: aliasNumber, supportedCommands: readViewSupportedCommands(kind, 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 }; } if (numberAliasRaw !== undefined) { const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command); if (typeof aliasNumber !== "number") return { ...aliasNumber, supportedCommands: [ `bun scripts/cli.ts gh pr read --repo owner/name --number --json ${readViewSupportedJsonFields("pr")}`, "bun scripts/cli.ts gh pr read owner/name# --raw", ], }; 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 PR number target.`, { positionalNumber: parsedRaw, numberAlias: aliasNumber, supportedCommands: [ `bun scripts/cli.ts gh pr read ${aliasNumber} --repo ${options.repo} --json ${readViewSupportedJsonFields("pr")}`, `bun scripts/cli.ts gh pr read --repo ${options.repo} --number ${aliasNumber} --full`, ], }); } } return { repo: options.repo, number: aliasNumber }; } const parsed = parseNumberForCommand(options.repo, raw, command); if (typeof parsed !== "number") { return { ...parsed, supportedCommands: [ `bun scripts/cli.ts gh ${kind} read --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} read owner/name# --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 | 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, }; } function isGitHubCommandResult(value: GitHubResolvedNumberReference | GitHubCommandResult): value is GitHubCommandResult { return typeof (value as { ok?: unknown }).ok === "boolean"; } function unknownGhOptionDetails(args: string[], option: string): Record { const [top, sub, third] = args; const details: Record = { unsupportedOption: option, helpCommand: "bun scripts/cli.ts gh help", }; if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) { const shorthand = parseOwnerRepoNumberShorthand(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} read --repo owner/name --json ${readViewSupportedJsonFields(top)}`, `bun scripts/cli.ts gh ${top} read owner/name# --raw`, ]; } return details; } function readBodyFile(path: string | undefined, command: string): string { if (path === undefined) throw new Error(`${command} requires --body-file `); if (!existsSync(path)) throw new Error(`body file not found: ${path}`); return readFileSync(path, "utf8"); } function readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource: Record } { 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 } | 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 } { const body = readOptionalMarkdownBody(options, command); if (body !== null) return body; throw new Error(`${command} requires --body-file or --body `); } 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 } { 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) { if (options.bodyFile === "-") throw new Error("issue comment create does not support --body-file - stdin; write Markdown to a file and pass --body-file "); return readMarkdownBodyFileOrStdin(options.bodyFile); } if (options.body === undefined) throw new Error("issue comment create requires --body-file or --body "); 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 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 { 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 { 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> { if (body === undefined) return []; const findings: Array> = []; 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 | 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#", "github.com/pikasTech/HWLAB/issues/", "HWLAB#", "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 | 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 | 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 | 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 --mode replace --body-file --body-profile commander-brief --expect-body-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 { 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 { 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 { 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): Promise { 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 '' --raw", }, }; } async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: string): Promise { 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 { 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 '' --raw", } : {}), }, }; } function writeBodyPlan(command: "issue create" | "issue comment create" | "pr create" | "pr comment", repo: string, body: string, bodySource: Record, extra: Record = {}): Record { const isIssueWrite = command === "issue create" || command === "issue comment create"; const source = String(bodySource.kind ?? "unknown"); const requestBody: Record = { 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; const details: Record = {}; 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(); 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(); 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; } { 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 { return { ok: false, degradedReason: reason, runnerDisposition: runnerDisposition(reason), message, ...extra, }; } function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record = {}): 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 = {}): 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 = {}): GitHubCommandResult { const error = errorPayload("unsupported-command", message, { details: extra }); return commandError(command, repo, error, { message, ...extra }); } async function parseGitHubResponse(response: Response): Promise { 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( token: string, method: string, path: string, body?: Record, ): Promise { 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( token: string, query: string, variables: Record, ): Promise { 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 { return { body: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --json body`, full: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --full`, raw: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --raw`, }; } function issueCommentReadCommands(repo: string, issueNumber: number): Record { return { comments: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --json comments`, full: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --full`, raw: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --raw`, }; } function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: number, dryRun: boolean): Record { 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 read --json body when full text is needed.", readCommands: issueBodyReadCommands(repo, issueNumber), }; } function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; previewLineCount?: number } = {}): Record { const body = issue.body ?? ""; const includeBody = options.includeBody ?? true; const summary: Record = { id: issue.id, number: issue.number, title: issue.title, state: issue.state, 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 labelSummary(label: string | { name?: string; color?: string; description?: string | null }): Record { 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 { const summary: Record = { number: issue.number, title: issue.title, state: issue.state, 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 = {}; 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(); 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 { const ignored = new Map(); 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 { const map = new Map(); 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 { const map = new Map(); 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(); 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> = {}; 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 { const map = new Map(); 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 { 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 { 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 { 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 `); 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 { 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[] = []; 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(); 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 { 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(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 { 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 { 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 { 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 ", { 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(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 { 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 { 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 { 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 { 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): 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, ignoreMap: Map): 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 { 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, key: string): boolean { return record[key] === true; } function validationStringArray(record: Record, 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 { 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; 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 = { 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 { 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 { 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 { 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 { 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 { 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, fields: readonly string[]): Record { const selected: Record = {}; for (const field of fields) selected[field] = summary[field]; return selected; } function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record { return { mergeable: metadata.mergeable ?? null, mergeStateStatus: metadata.mergeStateStatus ?? null, statusCheckRollup: metadata.statusCheckRollup ?? null, headRefName: metadata.headRefName ?? null, baseRefName: metadata.baseRefName ?? null, }; } function prMergeBoundary(): Record { return { runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"], ordinaryRunnerFinalActionAllowed: true, commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"], hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"], unideskCliMergeSupported: false, degradedReason: "unsupported-command", }; } function prCloseoutMetadata(metadata: GitHubPullRequestGraphqlMetadata): Record { 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 { 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 { 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 { 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 { const contexts = rollup?.contexts?.nodes ?? []; const compactContexts = contexts.map(compactStatusContext); const counts: Record = { 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 { 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 { return { readOnly: true, writesRemote: false, createsPr: false, comments: false, mergesPr: false, mergeCommandSupported: false, unsupportedMergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo}`, unideskCliMergeSupported: false, ordinaryRunnerFinalActionAllowed: true, note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; the UniDesk REST CLI still never merges PRs.", }; } function preflightPullRequestSummary(summary: Record): Record { 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, metadata: Record, statusChecks: Record, ): Record { 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 a repo-owned GitHub path when task boundaries allow; UniDesk REST gh pr merge remains unsupported" : "resolve blockers or rerun after GitHub finishes computing mergeability/status checks", }; } async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise { 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(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 ", "bun scripts/cli.ts gh pr closeout "], 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 { 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 { return { name: branch.name ?? null, sha: branch.commit?.sha ?? null, }; } function compareSummary(compare: GitHubCompare): Record { 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 { const { owner, name } = repoParts(repo); return githubRequest(token, "GET", `/repos/${owner}/${name}`); } async function branchInfo(token: string, repo: string, branch: string): Promise { const { owner, name } = repoParts(repo); return githubRequest(token, "GET", `/repos/${owner}/${name}/branches/${encodePathPart(branch)}`); } async function compareBranches(token: string, repo: string, base: string, head: string): Promise { const { owner, name } = repoParts(repo); return githubRequest(token, "GET", `/repos/${owner}/${name}/compare/${encodePathPart(base)}...${encodePathPart(head)}`); } function prCreatePlannedOperation(repo: string, options: GitHubOptions, body: string, bodySource: Record): Record { 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): Record { 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, existingBody: string | null): Record { 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 } | null, finalBody: string | undefined, existingBody: string | null): Record | 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 { if (options.title === undefined) return validationError("pr create", repo, "pr create requires --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`); } async function listIssues(token: string, repo: string, state: IssueListState, limit: number): Promise<GitHubIssue[] | GitHubErrorPayload> { const { owner, name } = repoParts(repo); return githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=${state}&per_page=${limit}`); } 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); } async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined): Promise<GitHubCommandResult> { const rawIssues = await listIssues(token, repo, state, limit); if (isGitHubError(rawIssues)) return commandError("issue list", repo, rawIssues, { state, limit }); const issues = rawIssues.filter((issue) => issue.pull_request === undefined).slice(0, limit); const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice(); return { ok: true, command: "issue list", repo, state, limit, count: issues.length, rawCount: rawIssues.length, jsonFields: fields, issues: issues.map((issue) => issueListSummary(issue, fields)), request: { method: "GET", path: "/repos/{owner}/{repo}/issues", query: { state, per_page: limit }, }, 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>"); const body = readBodyFile(options.bodyFile, "issue create"); const bodySource = { kind: "body-file", path: options.bodyFile ?? null }; 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> { const body = readBodyFile(options.bodyFile, commandName); 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 && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined); 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, { kind: "body-file", path: options.bodyFile ?? null }, 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, bodyFromFile: options.bodyFile, 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, 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): Promise<GitHubCommandResult> { if (dryRun) return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", dryRun: true, repo, issueNumber, wouldPatch: { state } }; const { owner, name } = repoParts(repo); const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state }); if (isGitHubError(issue)) return commandError(state === "closed" ? "issue close" : "issue reopen", repo, issue, { issueNumber }); return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", repo, issue: issueSummary(issue), rest: true }; } 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 { owner, name } = repoParts(repo); const issues = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=all&per_page=${limit}`); if (isGitHubError(issues)) return commandError(commandName, repo, issues); const issueOnly = issues.filter((issue) => issue.pull_request === undefined).slice(0, limit); 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.length, 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): 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, 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 read ${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] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]", "bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]", "bun scripts/cli.ts gh issue view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for issue read]", "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] [--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> [--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] [--dry-run]", "bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--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] --board-issue 20", "bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] --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] --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] --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] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]", "bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]", "bun scripts/cli.ts gh preflight <prNumber> [--repo owner/name] [--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] [--limit N]", "bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--limit N] [compatibility alias for pr files; no raw diff]", "bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--number N] [--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 view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for pr read]", "bun scripts/cli.ts gh pr preflight <number> [--repo owner/name] [--full|--raw]", "bun scripts/cli.ts gh pr closeout <number> [--repo owner/name] [--full|--raw] [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] [--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] [--dry-run]", "bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr 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; supported --json fields are number,title,state,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 read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports 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 read/view/update/edit and gh pr 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.", "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 repeatable --label values and comma-separated labels; 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 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 can use --expect-updated-at or --expect-body-sha for stale-cache protection.", "issue comment create accepts --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally; use --body-file for Markdown, generated content, or long comments.", "Issue body stdin is intentionally unsupported in this CLI; issue comment create also rejects --body-file - stdin. Write generated Markdown to a file and pass --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 Markdown writes intentionally use --body-file for long or multiline content; PR edit/update also accepts --body-file - for stdin when a runner already has reviewed Markdown on stdin.", "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 read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and --number N as a compatibility alias for the positional PR number; shorthand derives --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.", "PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.", "PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and the explicit UniDesk REST CLI no-merge policy. Use --full or --raw to include all fetched status contexts.", "PR create/edit/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.", ], }; } 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 === "update" || sub === "edit")) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || 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 read/view/update/edit, gh pr read/view, and gh pr preflight/closeout.", { supportedCommands: [ "bun scripts/cli.ts gh issue read owner/name#<number> --raw", "bun scripts/cli.ts gh issue read <number> --repo owner/name --json body,title,state,comments", "bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file <file> --expect-body-sha <sha> --full", "bun scripts/cli.ts gh pr read owner/name#<number> --raw", `bun scripts/cli.ts gh pr read <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")) { 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") && !(top === "pr" && isPrReadCommand(sub))) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; return validationError(command, options.repo, "--number is only supported by gh pr read/view; use gh pr read --repo owner/name --number N.", { supportedCommands: [ "bun scripts/cli.ts gh pr read --repo owner/name --number <number> --full", "bun scripts/cli.ts gh pr read <number> --repo owner/name --json body,title,state,head,base", ], }); } 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")) { 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"); } if (top === "auth" && sub === "status") return authStatus(options.repo); if (top === "preflight") { const number = parseNumberForCommand(options.repo, sub, "preflight"); if (typeof number !== "number") return number; return prPreflight(options.repo, number, "preflight", options.full || options.raw); } 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 commentId = parseNumberForCommand(options.repo, args[3], "issue comment delete"); if (typeof commentId !== "number") return commentId; if (options.dryRun) return commentDelete(options.repo, "", "issue", commentId, true); const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, "issue comment delete", probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true }); return commentDelete(options.repo, token, "issue", commentId, false); } if (sub === "comment" && third === "create") { const issueNumber = parseNumberForCommand(options.repo, args[3], "issue comment create"); if (typeof issueNumber !== "number") return issueNumber; if (options.dryRun) return issueComment(options.repo, "", issueNumber, options); const { token, probe } = resolveToken(true); const missing = authRequired(options.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 issueComment(options.repo, token, issueNumber, options); } 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 issueNumber = parseNumberForCommand(options.repo, args[3], commandName); if (typeof issueNumber !== "number") return issueNumber; if (action === "get") return issueBoardRowGet(options.repo, token, issueNumber, options); if (action === "update") return issueBoardRowUpdate(options.repo, token, issueNumber, options); if (action === "add") return issueBoardRowAdd(options.repo, token, issueNumber, options); if (action === "upsert") return issueBoardRowUpsert(options.repo, token, issueNumber, options); if (action === "move") return issueBoardRowMove(options.repo, token, issueNumber, options); return issueBoardRowDelete(options.repo, token, issueNumber, options); } if (options.dryRun) { if (sub === "create") return issueCreate(options.repo, "", options); if (sub === "edit") { const issueNumber = parseNumber(third, "issue edit"); const { token } = resolveToken(false); return issueEdit(options.repo, token ?? "", issueNumber, options); } if (sub === "update") { const issueNumber = parseNumber(third, "issue update"); const { token } = resolveToken(false); return issueEdit(options.repo, token ?? "", issueNumber, options, "issue update"); } if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options); if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true); if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true); } 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 issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure); return issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure); } 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); if (sub === "create") return issueCreate(options.repo, token, options); if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options); if (sub === "update") return issueEdit(options.repo, token, parseNumber(third, "issue update"), options, "issue update"); if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options); if (sub === "close") return issueState(options.repo, token, parseNumber(third, "issue close"), "closed", options.dryRun); if (sub === "reopen") return issueState(options.repo, token, parseNumber(third, "issue reopen"), "open", options.dryRun); } if (top === "pr") { if (sub === "diff") { const number = parseNumberForCommand(options.repo, third, "pr diff"); if (typeof number !== "number") return number; 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 ${number} --repo ${options.repo} --limit 30`, `bun scripts/cli.ts gh pr diff ${number} --stat --repo ${options.repo} --limit 30`, ], }); } const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, "pr diff --stat", probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true }); return prFiles(options.repo, token, number, options.limit, "pr diff --stat"); } if (sub === "files") { const number = parseNumberForCommand(options.repo, third, "pr files"); if (typeof number !== "number") return number; const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, "pr files", probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr files", { present: false, source: null, ghFallbackAttempted: true }); return prFiles(options.repo, token, number, options.limit, "pr files"); } 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 number = parseNumberForCommand(options.repo, third, `pr ${sub}`); if (typeof number !== "number") return number; return prPreflight(options.repo, number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw); } if (sub === "comment" && third === "delete") { const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete"); if (typeof commentId !== "number") return commentId; if (options.dryRun) return commentDelete(options.repo, "", "pr", commentId, true); const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, "pr comment delete", probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true }); return commentDelete(options.repo, token, "pr", commentId, false); } if (sub === "comment" && third === "create") { const number = parseNumberForCommand(options.repo, args[3], "pr comment create"); if (typeof number !== "number") return number; if (options.dryRun) return prComment(options.repo, "", number, options); const { token, probe } = resolveToken(true); const missing = authRequired(options.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 prComment(options.repo, token, number, options); } 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 number = parseNumberForCommand(options.repo, third, "pr comment"); if (typeof number !== "number") return number; return prComment(options.repo, "", number, options); } 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 number = parseNumberForCommand(options.repo, third, "pr comment"); if (typeof number !== "number") return number; return prComment(options.repo, token, number, options); } if (sub === "update" || sub === "edit") { const commandName = `pr ${sub}`; const number = parseNumberForCommand(options.repo, third, commandName); if (typeof number !== "number") return number; if (options.dryRun && options.mode === "replace") return prUpdate(options.repo, "", number, options, commandName); 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 }); return prUpdate(options.repo, token, number, options, commandName); } if (sub === "close" || sub === "reopen") { const number = parseNumberForCommand(options.repo, third, `pr ${sub}`); if (typeof number !== "number") return number; if (options.dryRun) return prState(options.repo, "", number, sub === "close" ? "closed" : "open", true); 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 }); return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false); } if (sub === "merge") { return unsupportedCommand( "pr merge", options.repo, "PR merge is intentionally unsupported by the UniDesk REST CLI; PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks using repo-owned GitHub paths, while high-risk or ambiguous PRs stay commander-reviewed.", { closeoutBoundary: { ...prMergeBoundary(), readOnlyCloseoutCommand: `bun scripts/cli.ts gh pr view ${third ?? "<number>"} --repo ${options.repo} --json ${PR_CLOSEOUT_VIEW_JSON}`, }, }, ); } if (sub !== "list" && !isPrReadCommand(sub)) { return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete."); } 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 prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure); return prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure); } 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); } return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() }); }