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 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 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 ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const; const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const; const PR_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const; const ISSUE_LIST_STATES = ["open", "closed", "all"] as const; const BODY_UPDATE_MODES = ["replace", "append"] as const; 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", issueNumber: COMMANDER_BRIEF_TARGET_ISSUE, requiredHeadings: ["## 常驻观察与长期建议"], }, } as const; type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number]; type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number]; type PrJsonField = typeof PR_JSON_FIELDS[number]; type IssueListState = typeof ISSUE_LIST_STATES[number]; type BodyUpdateMode = typeof BODY_UPDATE_MODES[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 GitHubDegradedReason = | "missing-binary" | "missing-token" | "auth-failed" | "egress-failed" | "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: "known-meta" | "ignored"; 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 GitHubCommandResult { ok: boolean; repo: string; command: string; degradedReason?: GitHubDegradedReason; degraded?: GitHubDegradedReason[]; runnerDisposition?: RunnerDisposition; [key: string]: unknown; } 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; 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[]; prJsonFields?: PrJsonField[]; listState: IssueListState; mode: BodyUpdateMode; expectUpdatedAt?: string; expectBodySha?: string; bodyProfile: IssueBodyProfileOption; } interface GitHubErrorPayload { ok: false; degradedReason: GitHubDegradedReason; runnerDisposition: RunnerDisposition; status?: number; message: 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 }; created_at?: string; updated_at?: string; } 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 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 view", requested, ISSUE_VIEW_JSON_FIELDS); } function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined { return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS); } function parsePrJsonFields(command: string, requested: string[] | undefined): PrJsonField[] | undefined { return validateJsonFields(command, requested, PR_JSON_FIELDS); } function 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 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 validateKnownOptions(args: string[]): void { const valueOptions = 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"]); const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (!arg.startsWith("--")) continue; if (flagOptions.has(arg)) continue; if (valueOptions.has(arg)) { index += 1; continue; } throw new Error(`unknown gh option: ${arg}`); } } function parseOptions(args: string[]): GitHubOptions { validateKnownOptions(args); const [top, sub] = args; const requestedJsonFields = commaListOption(args, "--json"); return { repo: optionValue(args, "--repo") ?? DEFAULT_REPO, dryRun: hasFlag(args, "--dry-run"), limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : 30, 100), 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" && sub === "view" ? parseIssueViewJsonFields(requestedJsonFields) : undefined, issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined, prJsonFields: top === "pr" && (sub === "view" || sub === "list") ? parsePrJsonFields(`gh pr ${sub}`, requestedJsonFields) : undefined, listState: parseIssueListState(args), mode: parseBodyUpdateMode(args), expectUpdatedAt: optionValue(args, "--expect-updated-at"), expectBodySha: optionValue(args, "--expect-body-sha"), bodyProfile: parseIssueBodyProfile(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 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 readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } { if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file or --body`); if (options.bodyFile !== undefined) { const body = readBodyFile(options.bodyFile, command); return { body, bodySource: { kind: "body-file", path: 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", }, }; } throw new Error(`${command} requires --body-file or --body `); } 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); } 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 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, }); if (claudeQqResponseOk(response)) return { ok: true, endpoint, status: isRecord(response) && typeof response.status === "number" ? response.status : 200, response }; return summarizeClaudeQqProxyFailure(response, endpoint); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeoutMs); try { const response = await fetch(normalizeClaudeQqEndpoint(config.baseUrl, endpoint), { method: "POST", signal: controller.signal, headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify(payload), }); const parsed = await parseGitHubResponse(response); const bodyFailed = isRecord(parsed) && (parsed.ok === false || parsed.success === false); if (response.ok && !bodyFailed) return { ok: true, endpoint, status: response.status, response: parsed }; return { ok: false, endpoint, status: response.status, degradedReason: bodyFailed ? "upstream-rejected" : "http-failed", message: isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : response.statusText, response: sanitizedErrorDetails(parsed), }; } catch (error) { return { ok: false, endpoint, degradedReason: "network-proxy-failed", message: error instanceof Error ? error.message : String(error), }; } finally { clearTimeout(timeout); } } 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 { 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, baseUrl: sanitizeUrlForOutput(config.baseUrl), target: maskedTarget(config), timeoutMs: config.timeoutMs, }, }; } 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 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, 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: String(bodySource.kind ?? "unknown"), rawText: "read from file bytes without shell interpolation", }, ...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 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) { return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method, path } }); } 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; } 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 issueSummary(issue: GitHubIssue): Record { return { id: issue.id, number: issue.number, title: issue.title, body: issue.body ?? "", state: issue.state, url: issue.html_url, author: issue.user?.login ?? null, createdAt: issue.created_at ?? null, updatedAt: issue.updated_at ?? null, }; } 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): 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"); 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): BoardRequiredColumn | null { const normalized = normalizeBoardHeader(header); 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 (["progress", "进度", "状态", "当前进度"].includes(normalized)) return "progress"; return null; } 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, ""); return withoutOuterPipes.split("|").map((cell) => cell.trim()); } 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 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 columnMap = new Map(); headers.forEach((header, headerIndex) => { const kind = boardHeaderColumnKind(header); if (kind !== null && !columnMap.has(kind)) columnMap.set(kind, headerIndex); }); const issueColumnIndex = boardIssueColumnIndex(headers); 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 issueCell = issueColumnIndex === null ? undefined : cells[issueColumnIndex]; const primaryIssueNumber = issueColumnIndex === null ? null : primaryBoardIssueNumberFromIssueCell(issueCell); const issueNumbers = issueColumnIndex === null ? extractIssueNumbers(lines[rowIndex]) : (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] ?? ""; } const row: BoardTableRow = { section: currentKind, lineNumber: rowIndex + 1, raw: lines[rowIndex], cells, issueNumbers, issueNumber: issueNumbers[0] ?? null, title: issueColumnIndex === null ? (cells.find((cell) => extractIssueNumbers(cell).length > 0) ?? cells[0] ?? null) : (issueCell ?? cells[0] ?? null), columns, }; rows.push(row); if (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 (issueNumbers.length > 1) { warnings.push({ issueNumber: row.issueNumber, section: currentKind, lineNumber: row.lineNumber, kind: "multiple-issue-references", message: issueColumnIndex === 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(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, 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 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 issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption): 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 issueMatchesProfile = issueNumber === profile.issueNumber; const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading)); return { profile: profileName, label: profile.label, applied: true, expectedIssueNumber: profile.issueNumber, issueMatchesProfile, requiredHeadings: profile.requiredHeadings, missingHeadings, ok: issueMatchesProfile && 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): 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); 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 (!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), ...bodySafetySignals(body), isLiteralNull, isBlank, isShort, profile: profileValidation, }, }); } function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions): Record { const trimmed = body.trim(); const profile = issueProfileValidation(issueNumber, body, options.bodyProfile); 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"); 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, minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS, allowShortBody: options.allowShortBody, warnings, profile, ...signals, }; } function assertConcurrencyOptions(options: GitHubOptions): GitHubCommandResult | null { if (options.expectBodySha === undefined) return null; try { normalizeExpectedSha(options.expectBodySha); return null; } catch (error) { return validationError("issue edit", 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 prSummary(pr: GitHubPullRequest): Record { return { id: pr.id, number: pr.number, title: pr.title, body: pr.body ?? "", 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 selectedPrJson(pr: GitHubPullRequest, fields: PrJsonField[]): Record { const summary = prSummary(pr) as Record; const selected: Record = {}; for (const field of fields) selected[field] = summary[field]; return selected; } 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 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), }, }; } 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): Promise<GitHubCommandResult> { let bodySource: { body: string; bodySource: Record<string, unknown> }; try { bodySource = readMarkdownBody(options, "pr update"); } catch (error) { return validationError("pr update", repo, error instanceof Error ? error.message : String(error), { number }); } const { owner, name } = repoParts(repo); let oldPr: GitHubPullRequest | null = null; if (token.length > 0 && (options.mode === "append" || !options.dryRun)) { const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); if (isGitHubError(pr)) return commandError("pr update", repo, pr, { number, phase: "read-before-update" }); oldPr = pr; } const finalBody = options.mode === "append" ? `${oldPr?.body ?? ""}${bodySource.body}` : bodySource.body; const planned = bodyUpdatePlan("pr update", repo, number, options.mode, bodySource.body, bodySource.bodySource, oldPr?.body ?? null); if (options.dryRun) { return { ok: true, command: "pr update", repo, dryRun: true, planned: true, number, title: options.title ?? null, ...planned, request: { method: "PATCH", path: `/repos/{owner}/{repo}/pulls/${number}`, body: { title: options.title ?? null, bodyChars: finalBody.length }, }, }; } const payload: Record<string, unknown> = { 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("pr update", repo, pr, { number, planned }); return { ok: true, command: "pr update", repo, number, mode: options.mode, pullRequest: prSummary(pr), update: planned, request: { method: "PATCH", path: `/repos/${owner}/${name}/pulls/${number}`, title: options.title ?? null, bodyChars: finalBody.length }, rest: true, }; } 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 issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined): Promise<GitHubCommandResult> { const issue = await getIssue(token, repo, issueNumber); if (isGitHubError(issue)) return commandError("issue view", repo, issue, { issueNumber }); const needsComments = jsonFields === undefined || jsonFields.includes("comments"); const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null; if (isGitHubError(comments)) return commandError("issue view", repo, comments, { issueNumber, issue: issueSummary(issue) }); return { ok: true, command: "issue view", repo, issue: issueSummary(issue), ...(comments === null ? {} : { comments: comments.map(commentSummary) }), ...(jsonFields === undefined ? {} : { jsonFields, json: selectedIssueJson(issue, comments, jsonFields), compatibility: { legacyJsonBodyPath: ".data.issue.body", }, }), }; } 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 [rawOpenIssues, rawClosedIssues] = await Promise.all([ listIssues(token, repo, "open", options.limit), listIssues(token, repo, "closed", options.limit), ]); if (isGitHubError(rawOpenIssues)) return commandError(commandName, repo, rawOpenIssues, { phase: "list-open-issues", boardIssue: options.boardIssue, limit: options.limit }); if (isGitHubError(rawClosedIssues)) return commandError(commandName, repo, rawClosedIssues, { phase: "list-closed-issues", boardIssue: options.boardIssue, limit: options.limit }); const openIssues = rawOpenIssues.filter((issue) => issue.pull_request === undefined).slice(0, options.limit).map(boardIssueEntry); const closedIssues = rawClosedIssues.filter((issue) => issue.pull_request === undefined).slice(0, options.limit).map(boardIssueEntry); const allListedIssues = [...openIssues, ...closedIssues]; const issueMap = new Map<number, BoardIssueEntry>(allListedIssues.map((issue) => [issue.number, issue])); const ignoreMap = boardIgnoreMap(options); const parsed = parseBoardTables(boardIssue.body ?? ""); const rowValidationWarnings = parsed.warnings.filter((warning) => warning.issueNumber === null || !ignoreMap.has(warning.issueNumber)); 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 openRowMap = boardRowsByIssue(openRows); const closedRowMap = boardRowsByIssue(closedRows); const openIssueNumbers = new Set(openIssues.map((issue) => issue.number)); const closedIssueNumbers = new Set(closedIssues.map((issue) => issue.number)); const boardIssueNumbers = Array.from(new Set([...openRowMap.keys(), ...closedRowMap.keys()])); const missingOpenIssues = sortedIssueEntries(openIssues.filter((issue) => !ignoreMap.has(issue.number) && !openRowMap.has(issue.number))); const closedInOpenRows = sortedIssueEntries(closedIssues.filter((issue) => !ignoreMap.has(issue.number) && openRowMap.has(issue.number))).map((issue) => ({ ...issue, rows: (openRowMap.get(issue.number) ?? []).map((row) => ({ lineNumber: row.lineNumber, rowPreview: preview(row.raw) })), })); const missingClosedRows = sortedIssueEntries(closedIssues.filter((issue) => !ignoreMap.has(issue.number) && !closedRowMap.has(issue.number))); const openInClosedRows = sortedIssueEntries(openIssues.filter((issue) => !ignoreMap.has(issue.number) && closedRowMap.has(issue.number))).map((issue) => ({ ...issue, rows: (closedRowMap.get(issue.number) ?? []).map((row) => ({ lineNumber: row.lineNumber, rowPreview: preview(row.raw) })), })); const staleOpenRows = Array.from(openRowMap.entries()) .filter(([issueNumber]) => !ignoreMap.has(issueNumber) && !openIssueNumbers.has(issueNumber)) .map(([issueNumber, rows]) => ({ issueNumber, knownState: closedIssueNumbers.has(issueNumber) ? "closed" : "not-in-listed-window", rows: rows.map((row) => ({ lineNumber: row.lineNumber, rowPreview: preview(row.raw) })), })) .sort((a, b) => a.issueNumber - b.issueNumber); const boardOnlyRows = boardIssueNumbers .filter((issueNumber) => !ignoreMap.has(issueNumber) && !issueMap.has(issueNumber)) .sort((a, b) => a - b) .map((issueNumber) => ({ issueNumber, sections: [ ...(openRowMap.has(issueNumber) ? ["open"] : []), ...(closedRowMap.has(issueNumber) ? ["closed"] : []), ], rows: [...(openRowMap.get(issueNumber) ?? []), ...(closedRowMap.get(issueNumber) ?? [])].map((row) => ({ section: row.section, lineNumber: row.lineNumber, rowPreview: preview(row.raw), })), })); const ignoredIssues = [ ...ignoredIssueList(allListedIssues, ignoreMap), ...ignoredBoardOnlyIssues(boardIssueNumbers, issueMap, ignoreMap), ].sort((a, b) => a.number - b.number); const recommendedActions = [ ...missingOpenIssues.map((issue) => ({ action: "add-open-row", issueNumber: issue.number, title: issue.title, section: "open", reason: "GitHub issue is open but no #20 OPEN table row was found.", })), ...closedInOpenRows.map((issue) => ({ action: "move-open-row-to-closed", issueNumber: issue.number, title: issue.title, reason: "GitHub issue is closed but still appears in the #20 OPEN table.", })), ...missingClosedRows.map((issue) => ({ action: "add-closed-row", issueNumber: issue.number, title: issue.title, section: "closed", reason: "GitHub issue is closed but no #20 CLOSED table row was found.", })), ...openInClosedRows.map((issue) => ({ action: "move-closed-row-to-open", issueNumber: issue.number, title: issue.title, reason: "GitHub issue is open but appears in the #20 CLOSED table.", })), ...rowValidationWarnings.map((warning) => ({ action: "fill-board-row-fields", issueNumber: warning.issueNumber, section: warning.section, lineNumber: warning.lineNumber, reason: warning.message, missingColumns: warning.missingColumns ?? [], })), ]; 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: (boardIssue.body ?? "").length, updatedAt: boardIssue.updated_at ?? null, }, config: { limit: options.limit, knownMetaIssues: mergedKnownMetaIssues(options), ignoredIssues: options.ignoredIssues, requiredColumns: BOARD_AUDIT_REQUIRED_COLUMNS.slice(), }, summary: { openIssues: openIssues.length, closedIssues: closedIssues.length, openRows: openRows.length, closedRows: closedRows.length, missingOpenIssues: missingOpenIssues.length, closedInOpenRows: closedInOpenRows.length, missingClosedRows: missingClosedRows.length, openInClosedRows: openInClosedRows.length, rowValidationWarnings: rowValidationWarnings.length, ignoredIssues: ignoredIssues.length, boardOnlyRows: boardOnlyRows.length, staleOpenRows: staleOpenRows.length, }, 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), })), missingOpenIssues, closedInOpenRows, missingClosedRows, openInClosedRows, staleOpenRows, boardOnlyRows, rowValidationWarnings, ignoredIssues, recommendedActions, request: { method: "GET", paths: [ `/repos/{owner}/{repo}/issues/${options.boardIssue}`, "/repos/{owner}/{repo}/issues?state=open", "/repos/{owner}/{repo}/issues?state=closed", ], query: { per_page: options.limit }, }, note: "Read-only board audit; 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); if (options.mode === "replace") { const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options); if (bodyGuard !== null) return bodyGuard; } const concurrencyOptionError = assertConcurrencyOptions(options); if (concurrencyOptionError !== null) return concurrencyOptionError; let oldIssue: GitHubIssue | null = null; let briefDiff: CommanderBriefDiff | null = null; 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" || !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; 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") { const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options); if (bodyGuard !== null) return bodyGuard; } if (options.dryRun) { const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null; const guard = issueEditGuardSummary(issueNumber, finalBody, options); 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 (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), 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), }, }, 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: { title: options.title ?? null, bodyFromFile: options.bodyFile }, ...(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); 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), mode: options.mode, guard, concurrency, 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), guard, concurrency, 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> { const body = readBodyFile(options.bodyFile, "issue comment"); const bodySource = { kind: "body-file", path: options.bodyFile ?? null }; if (options.dryRun) { return { ok: true, command: "issue comment create", repo, dryRun: true, planned: true, 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: commentSummary(comment), bodySource, 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, limit: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=all&per_page=${limit}`); if (isGitHubError(prs)) return commandError("pr list", repo, prs); const fields = jsonFields ?? PR_JSON_FIELDS.slice(); return { ok: true, command: "pr list", repo, limit, count: prs.length, jsonFields: fields, pullRequests: prs.map((pr) => selectedPrJson(pr, fields)), }; } async function prView(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); if (isGitHubError(pr)) return commandError("pr view", repo, pr, { number }); return { ok: true, command: "pr view", repo, pullRequest: prSummary(pr), ...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(pr, jsonFields) }), }; } 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 [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]", "bun scripts/cli.ts gh issue view <number> [--repo owner/name] [--json body,title,state,comments]", "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]", "bun scripts/cli.ts gh issue edit <number> --body-file <file> [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> [--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 pr list [--repo owner/name] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]", "bun scripts/cli.ts gh pr view <number> [--repo owner/name] [--json body,title,state,head,base,draft]", "bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]", "bun scripts/cli.ts gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title title] [--repo owner/name] [--dry-run]", "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 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.", "issue view supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", "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 and #24 body-only profiles require their stable headings.", "issue update dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.", "Issue body stdin is intentionally unsupported in this CLI; 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 Markdown writes intentionally use --body-file only.", "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 compares GitHub open/closed issue lists with the board OPEN/CLOSED tables and reports missingOpenIssues, closedInOpenRows, missingClosedRows, rowValidationWarnings, ignoredIssues, and recommendedActions. When an Issue column exists, row.issueNumber is taken from that column; #20 and #24 are known meta issues by default.", "issue edit 24 --notify-claudeqq-brief-diff 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 create/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; return message.startsWith("unknown gh option:") ? unsupportedCommand(command, repo, message) : 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")) { 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"); } if (optionWasProvided(args, "--json") && !(top === "issue" && (sub === "view" || sub === "list"))) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; if (!(top === "pr" && (sub === "view" || sub === "list"))) { return validationError(command, options.repo, "--json field selection is only supported by gh issue view/list and gh pr view/list"); } } if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && sub === "update"))) { 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"); } if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && (sub === "edit" || sub === "update"))) { 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"); } if ((optionWasProvided(args, "--board-issue") || optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && sub === "board-audit")) { 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"); } 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 === "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 (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); } 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 === "view") return issueView(options.repo, token, parseNumber(third, "issue view"), options.jsonFields); 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 (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 (top === "pr") { 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 === "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") { const number = parseNumberForCommand(options.repo, third, "pr update"); if (typeof number !== "number") return number; if (options.dryRun && options.mode === "replace") return prUpdate(options.repo, "", number, options); const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, "pr update", probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr update", { present: false, source: null, ghFallbackAttempted: true }); return prUpdate(options.repo, token, number, options); } 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 in this phase; use create/comment/read only."); } if (sub !== "list" && sub !== "view") { return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, update, close, reopen, comment create/delete, and unsupported merge/delete."); } const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, `pr ${sub}`, probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); if (sub === "list") return prList(options.repo, token, options.limit, options.prJsonFields); return prView(options.repo, token, parseNumber(third, "pr view"), options.prJsonFields); } return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() }); }