Files
pikasTech-unidesk/scripts/src/gh.ts
T
2026-05-21 10:25:40 +00:00

4868 lines
206 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { coreInternalFetch } from "./microservices";
const DEFAULT_REPO = "pikasTech/unidesk";
const GITHUB_API = (process.env.UNIDESK_GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/u, "");
const USER_AGENT = "unidesk-cli-gh";
const PREVIEW_CHARS = 240;
const REQUEST_TIMEOUT_MS = 20_000;
const MIN_SAFE_ISSUE_BODY_CHARS = 20;
const 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 BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks", "focus"] as const;
const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const;
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const PR_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 BOARD_MUTATION_SECTIONS = ["open", "closed"] as const;
const BOARD_GITHUB_STATUSES = ["OPEN", "CLOSED"] 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 legacy or daily rolling brief)",
legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE,
requiredHeadings: ["## 常驻观察与长期建议"],
},
} as const;
const DAILY_COMMANDER_BRIEF_TITLE_PATTERN = /^\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)$/u;
const DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)\s*$/u;
const LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+指挥简报\s*$/u;
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 BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number];
type BoardGithubStatus = typeof BOARD_GITHUB_STATUSES[number];
type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES;
type IssueBodyProfileOption = "auto" | IssueBodyProfileName;
type EscapeFindingClassification = "suspected-pollution" | "explanatory-mention" | "risk";
type EscapeFindingSeverity = "low" | "medium" | "high";
type EscapeBodyKind = "issue-body" | "comment-body";
type CleanupSuggestionAction = "rewrite-issue-body-with-body-file" | "review-comment-manually" | "review-body-length" | "no-cleanup-needed";
type BoardSectionKind = "open" | "closed";
type BoardRequiredColumn = typeof BOARD_AUDIT_REQUIRED_COLUMNS[number];
type BoardColumnKind = BoardRequiredColumn | "focus" | "githubStatus" | "category" | "summary";
type BoardRowField = typeof BOARD_ROW_FIELDS[number];
type BoardRowUpsertTextField = typeof BOARD_ROW_UPSERT_TEXT_FIELDS[number];
type BoardRowUpsertField = BoardRowUpsertTextField | "status";
type BoardIgnoreReason = "known-meta" | "ignored" | "brief-index-managed";
type GitHubDegradedReason =
| "missing-binary"
| "missing-token"
| "auth-failed"
| "egress-failed"
| "network-proxy-failed"
| "permission-denied"
| "repo-not-found"
| "repo-forbidden"
| "issue-not-found"
| "pr-not-found"
| "scope-insufficient"
| "validation-failed"
| "invalid-response"
| "unsupported-command";
type RunnerDisposition = "infra-blocked" | "business-failed";
type ClaudeQqTargetType = "private" | "group";
type CommanderBriefDiffMode = "identical" | "append-only" | "heading-diff" | "unreliable";
interface CommanderBriefDiff {
ok: boolean;
mode: CommanderBriefDiffMode;
message: string;
chars: number;
sections: string[];
sectionCount: number;
skippedReason?: string;
}
interface ClaudeQqConfig {
enabled: boolean;
baseUrl: string;
targetType: ClaudeQqTargetType;
userId?: string;
groupId?: string;
timeoutMs: number;
}
interface ClaudeQqSendResult {
ok: boolean;
attempted: boolean;
skipped?: boolean;
skippedReason?: string;
endpoint?: string;
status?: number;
degradedReason?: string;
message?: string;
response?: unknown;
target: Record<string, unknown>;
}
interface ClaudeQqEndpointResult {
ok: boolean;
endpoint: string;
status?: number;
degradedReason?: string;
message?: string;
response?: unknown;
}
interface CommanderBriefSection {
heading: string;
text: string;
startLine: number;
}
interface EscapePatternDefinition {
kind: string;
pattern: RegExp;
description: string;
}
interface EscapeMatchFinding {
type: "issue" | "comment";
bodyKind: EscapeBodyKind;
bodyId: string;
issueNumber: number;
issueId: number;
commentId?: number;
url: string;
kind: string;
classification: EscapeFindingClassification;
severity: EscapeFindingSeverity;
reason: string;
snippet: string;
lineNumber: number;
column: number;
match: string;
bodyChars: number | null;
bodyTrimmedChars: number | null;
cleanupPreview?: {
before: string;
after: string;
};
}
interface EscapeCleanupSuggestion {
type: EscapeBodyKind;
bodyId: string;
issueNumber: number;
issueId: number;
commentId?: number;
url: string;
classification: EscapeFindingClassification;
action: CleanupSuggestionAction;
reason: string;
findings: Array<Pick<EscapeMatchFinding, "kind" | "classification" | "severity" | "lineNumber" | "column" | "snippet" | "match">>;
plannedCommand?: string;
bodyFileHint?: string;
preview?: {
before: string;
after: string;
};
}
interface EscapeScanErrorFinding {
type: "comment-scan-error";
bodyKind: EscapeBodyKind;
bodyId: string;
issueNumber: number;
issueId: number;
url: string;
degradedReason: GitHubDegradedReason;
runnerDisposition: RunnerDisposition;
details: GitHubErrorPayload;
}
type EscapeScanEntry = EscapeMatchFinding | EscapeScanErrorFinding;
interface BoardTableRow {
section: BoardSectionKind;
lineNumber: number;
raw: string;
cells: string[];
issueNumbers: number[];
issueNumber: number | null;
title: string | null;
columns: Partial<Record<BoardRequiredColumn, string>>;
}
interface BoardTableSection {
kind: BoardSectionKind;
heading: string;
headingLine: number;
headerLine: number;
headers: string[];
rows: BoardTableRow[];
}
interface BoardIssueEntry {
number: number;
title: string;
state: string;
url: string;
}
interface BoardIgnoredIssue {
number: number;
reason: BoardIgnoreReason;
title?: string;
state?: string;
url?: string;
}
interface BoardRowValidationWarning {
issueNumber: number | null;
section: BoardSectionKind;
lineNumber: number;
kind: string;
message: string;
missingColumns?: BoardRequiredColumn[];
columns?: Partial<Record<BoardRequiredColumn, string>>;
rowPreview: string;
}
interface BoardRowLookup {
ok: true;
section: BoardTableSection;
row: BoardTableRow;
matches: Array<{ section: BoardTableSection; row: BoardTableRow }>;
}
interface BoardRowUpdatePlanResult {
ok: true;
plan: Record<string, unknown>;
newBody: string;
}
interface BoardRowMutationPlanResult {
ok: true;
plan: Record<string, unknown>;
newBody: string;
}
interface BoardRowUpsertValues {
category?: string;
branch?: string;
tasks?: string;
summary?: string;
focus?: string;
validation?: string;
progress?: string;
status?: BoardGithubStatus;
}
interface GitHubCommandResult {
ok: boolean;
repo: string;
command: string;
degradedReason?: GitHubDegradedReason;
degraded?: GitHubDegradedReason[];
runnerDisposition?: RunnerDisposition;
[key: string]: unknown;
}
type GitHubCommandFailure = GitHubCommandResult & { ok: false };
interface GitHubTokenProbe {
present: boolean;
source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null;
ghFallbackAttempted: boolean;
ghBinaryFound?: boolean;
ghAuthTokenAvailable?: boolean | null;
}
interface GitHubOptions {
repo: string;
dryRun: boolean;
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;
boardRowField?: BoardRowField;
boardRowValue?: string;
boardRowFile?: string;
boardSection?: BoardMutationSection;
boardMoveTo?: BoardMutationSection;
boardGithubStatus?: BoardGithubStatus;
boardRowUpsertValues: BoardRowUpsertValues;
}
interface IssueProfileValidationContext {
issueTitle?: string | null;
issueBody?: string | null;
issueMetadataFetched?: boolean;
}
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<string | { name?: string; color?: string; description?: string | null }>;
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<string, boolean>;
}
interface GitHubBranch {
name?: string;
commit?: { sha?: string };
}
interface GitHubCompare {
status?: string;
ahead_by?: number;
behind_by?: number;
total_commits?: number;
html_url?: string;
base_commit?: { sha?: string };
merge_base_commit?: { sha?: string };
}
function optionValue(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
const value = args[index + 1];
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function optionValues(args: string[], name: string): string[] {
const values: string[] = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] !== name) continue;
const value = args[index + 1];
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
values.push(value);
index += 1;
}
return values;
}
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
function optionWasProvided(args: string[], name: string): boolean {
return args.includes(name);
}
function commaListOption(args: string[], name: string): string[] | undefined {
const raw = optionValue(args, name);
if (raw === undefined) return undefined;
const values = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
if (values.length === 0) throw new Error(`${name} requires at least one comma-separated field`);
return values;
}
function labelsOption(args: string[]): string[] {
const rawValues = optionValues(args, "--label");
const labels: string[] = [];
const seen = new Set<string>();
for (const raw of rawValues) {
const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
if (parts.length === 0) throw new Error("--label requires at least one non-empty label");
for (const label of parts) {
if (seen.has(label)) continue;
labels.push(label);
seen.add(label);
}
}
return labels;
}
function positiveIntegerValuesOption(args: string[], name: string): number[] {
const values: number[] = [];
const seen = new Set<number>();
for (const raw of optionValues(args, name)) {
const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
if (parts.length === 0) throw new Error(`${name} requires at least one positive integer`);
for (const part of parts) {
const value = Number(part.replace(/^#/u, ""));
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} values must be positive issue numbers`);
if (seen.has(value)) continue;
values.push(value);
seen.add(value);
}
}
return values;
}
function positiveIntegerSingleOption(args: string[], name: string, defaultValue: number): number {
const raw = optionValue(args, name);
if (raw === undefined) return defaultValue;
const value = Number(raw.replace(/^#/u, ""));
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive issue number`);
return value;
}
function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const raw = optionValue(args, name);
if (raw === undefined) return defaultValue;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return Math.min(value, maxValue);
}
function validateEnumValue<T extends string>(name: string, raw: string, allowedValues: readonly T[]): T {
if ((allowedValues as readonly string[]).includes(raw)) return raw as T;
throw new Error(`unsupported ${name} ${raw}; supported values: ${allowedValues.join(",")}`);
}
function validateJsonFields<T extends string>(command: string, requested: string[] | undefined, allowedFields: readonly T[]): T[] | undefined {
if (requested === undefined) return undefined;
const allowed = new Set<string>(allowedFields);
const unsupported = requested.filter((field) => !allowed.has(field));
if (unsupported.length > 0) {
throw new Error(`unsupported ${command} --json field(s): ${unsupported.join(", ")}; supported fields: ${allowedFields.join(",")}`);
}
return requested as T[];
}
function parseIssueViewJsonFields(requested: string[] | undefined): IssueViewJsonField[] | undefined {
return validateJsonFields("gh issue read/view", requested, ISSUE_VIEW_JSON_FIELDS);
}
function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined {
return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS);
}
function parsePrJsonFields(command: string, requested: string[] | undefined): PrJsonField[] | undefined {
return validateJsonFields(command, requested, PR_JSON_FIELDS);
}
function isIssueReadCommand(sub: string | undefined): boolean {
return sub === "read" || sub === "view";
}
function isPrReadCommand(sub: string | undefined): boolean {
return sub === "read" || sub === "view";
}
function parseIssueListState(args: string[]): IssueListState {
const raw = optionValue(args, "--state") ?? "open";
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState;
throw new Error(`unsupported gh issue list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`);
}
function parseBodyUpdateMode(args: string[]): BodyUpdateMode {
const raw = optionValue(args, "--mode") ?? "replace";
if ((BODY_UPDATE_MODES as readonly string[]).includes(raw)) return raw as BodyUpdateMode;
throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`);
}
function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
const raw = optionValue(args, "--body-profile") ?? "auto";
if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw;
throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`);
}
function parseBoardMutationSection(args: string[], name: "--section" | "--to"): BoardMutationSection | undefined {
const raw = optionValue(args, name);
if (raw === undefined) return undefined;
return validateEnumValue(name, raw, BOARD_MUTATION_SECTIONS);
}
function parseBoardGithubStatus(args: string[]): BoardGithubStatus | undefined {
const raw = optionValue(args, "--status");
if (raw === undefined) return undefined;
return validateEnumValue("--status", raw, BOARD_GITHUB_STATUSES);
}
function parseBoardRowField(args: string[]): BoardRowField | undefined {
const raw = optionValue(args, "--field");
if (raw === undefined) return undefined;
return validateEnumValue("--field", raw, BOARD_ROW_FIELDS);
}
function parseBoardRowUpsertValues(args: string[]): BoardRowUpsertValues {
return {
category: optionValue(args, "--category"),
branch: optionValue(args, "--branch"),
tasks: optionValue(args, "--tasks"),
summary: optionValue(args, "--summary"),
focus: optionValue(args, "--focus"),
validation: optionValue(args, "--validation"),
progress: optionValue(args, "--progress"),
status: parseBoardGithubStatus(args),
};
}
function validateKnownOptions(args: string[]): void {
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", "--field", "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress"]);
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" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined,
issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined,
prJsonFields: top === "pr" && (isPrReadCommand(sub) || sub === "list") ? parsePrJsonFields(`gh pr ${sub}`, requestedJsonFields) : undefined,
listState: parseIssueListState(args),
mode: parseBodyUpdateMode(args),
expectUpdatedAt: optionValue(args, "--expect-updated-at"),
expectBodySha: optionValue(args, "--expect-body-sha"),
bodyProfile: parseIssueBodyProfile(args),
boardRowField: parseBoardRowField(args),
boardRowValue: optionValue(args, "--value"),
boardRowFile: optionValue(args, "--row-file"),
boardSection: parseBoardMutationSection(args, "--section"),
boardMoveTo: parseBoardMutationSection(args, "--to"),
boardGithubStatus: parseBoardGithubStatus(args),
boardRowUpsertValues: parseBoardRowUpsertValues(args),
};
}
function parseNumber(raw: string | undefined, label: string): number {
if (raw === undefined) throw new Error(`${label} requires a number`);
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`);
return value;
}
function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult {
try {
return parseNumber(raw, label);
} catch (error) {
return validationError(label, repo, error instanceof Error ? error.message : String(error));
}
}
function readBodyFile(path: string | undefined, command: string): string {
if (path === undefined) throw new Error(`${command} requires --body-file <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<string, unknown> } {
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 <file> or --body <text>`);
}
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<string, unknown> {
const evidence = shellPollutionEvidence(body);
return {
bodyChars: body.length,
bodyTrimmedChars: body.trim().length,
bodyLines: body.length === 0 ? 0 : body.split(/\r?\n/).length,
bodySha: bodySha(body),
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body),
shellPollution: {
suspected: evidence.length > 0,
evidence,
},
};
}
function dryRunBody(repo: string, title: string | undefined, body: string): Record<string, unknown> {
return {
repo,
...(title === undefined ? {} : { title }),
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
...bodySafetySignals(body),
};
}
function normalizeNewlines(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
function isTimelineHeading(line: string): boolean {
return /^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u.test(line.trimEnd());
}
function extractTimelineSections(markdown: string): CommanderBriefSection[] {
const normalized = normalizeNewlines(markdown);
const lines = normalized.split("\n");
const sections: CommanderBriefSection[] = [];
let currentStart = -1;
for (let index = 0; index < lines.length; index += 1) {
if (!isTimelineHeading(lines[index])) continue;
if (currentStart !== -1) {
sections.push({
heading: lines[currentStart].trimEnd(),
text: lines.slice(currentStart, index).join("\n").trimEnd(),
startLine: currentStart + 1,
});
}
currentStart = index;
}
if (currentStart !== -1) {
sections.push({
heading: lines[currentStart].trimEnd(),
text: lines.slice(currentStart).join("\n").trimEnd(),
startLine: currentStart + 1,
});
}
return sections.filter((section) => section.text.length > 0);
}
export function commanderBriefDiff(oldBodyRaw: string, newBodyRaw: string): CommanderBriefDiff {
const oldBody = normalizeNewlines(oldBodyRaw);
const newBody = normalizeNewlines(newBodyRaw);
if (oldBody === newBody) {
return {
ok: false,
mode: "identical",
message: "",
chars: 0,
sections: [],
sectionCount: 0,
skippedReason: "issue body is unchanged",
};
}
if (newBody.startsWith(oldBody)) {
const suffix = newBody.slice(oldBody.length);
const sections = extractTimelineSections(suffix).map((section) => section.text);
const message = sections.join("\n\n").trim();
if (message.length === 0) {
return {
ok: false,
mode: "append-only",
message: "",
chars: 0,
sections: [],
sectionCount: 0,
skippedReason: "append-only suffix contains no new commander brief update section",
};
}
return { ok: true, mode: "append-only", message, chars: message.length, sections, sectionCount: sections.length };
}
const oldSections = new Set(extractTimelineSections(oldBody).map((section) => section.text));
const allNewSections = extractTimelineSections(newBody);
const newSections = allNewSections.map((section) => section.text).filter((section) => !oldSections.has(section));
if (newSections.length === 0) {
return {
ok: false,
mode: "heading-diff",
message: "",
chars: 0,
sections: [],
sectionCount: 0,
skippedReason: "no new commander brief update section found; non-timeline edits are not notified",
};
}
const duplicateNewHeadings = allNewSections.some((section, index) => allNewSections.findIndex((candidate) => candidate.heading === section.heading) !== index);
const oldHeadings = new Set(extractTimelineSections(oldBody).map((section) => section.heading));
const ambiguousChangedHeadings = allNewSections.some((section) => oldHeadings.has(section.heading) && !oldSections.has(section.text));
if (duplicateNewHeadings || ambiguousChangedHeadings) {
return {
ok: false,
mode: "unreliable",
message: "",
chars: 0,
sections: [],
sectionCount: 0,
skippedReason: duplicateNewHeadings
? "duplicate update headings make commander brief diff unreliable"
: "an existing update section changed; cannot reliably isolate only new timeline content",
};
}
const message = newSections.join("\n\n").trim();
return { ok: true, mode: "heading-diff", message, chars: message.length, sections: newSections, sectionCount: newSections.length };
}
function envEnabled(name: string, defaultValue: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return defaultValue;
return !["0", "false", "no", "off", "disabled"].includes(raw.toLowerCase());
}
function positiveEnvInteger(name: string, defaultValue: number): number {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return defaultValue;
const value = Number(raw);
return Number.isInteger(value) && value > 0 ? value : defaultValue;
}
function commanderBriefClaudeQqConfig(): ClaudeQqConfig {
const targetTypeRaw = (process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE ?? "private").toLowerCase();
const targetType: ClaudeQqTargetType = targetTypeRaw === "group" ? "group" : "private";
return {
enabled: envEnabled("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED", true),
baseUrl: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL,
targetType,
userId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID,
groupId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID,
timeoutMs: positiveEnvInteger("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS", 15_000),
};
}
function maskedTarget(config: ClaudeQqConfig): Record<string, unknown> {
if (config.targetType === "group") return { targetType: "group", groupId: config.groupId === undefined ? null : maskId(config.groupId) };
return { targetType: "private", userId: config.userId === undefined ? null : maskId(config.userId) };
}
function maskId(value: string): string {
if (value.length <= 4) return "*".repeat(value.length);
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
function sanitizeUrlForOutput(value: string): string {
try {
const parsed = new URL(value);
parsed.username = parsed.username.length > 0 ? "***" : "";
parsed.password = parsed.password.length > 0 ? "***" : "";
parsed.search = parsed.search.length > 0 ? "?..." : "";
parsed.hash = "";
return parsed.toString();
} catch {
return value.includes("?") ? `${value.split("?")[0]}?...` : value;
}
}
function claudeQqPayload(config: ClaudeQqConfig, message: string): Record<string, unknown> {
if (config.targetType === "group") return { targetType: "group", groupId: config.groupId ?? "", message };
return { targetType: "private", userId: config.userId ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, message };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function proxiedClaudeQqBasePath(baseUrl: string): string | null {
try {
const parsed = new URL(baseUrl);
const path = parsed.pathname.replace(/\/$/u, "");
if (parsed.hostname === "backend-core" && path === "/api/microservices/claudeqq/proxy") return path;
if ((parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.port === "8080" && path === "/api/microservices/claudeqq/proxy") return path;
return null;
} catch {
return baseUrl === "/api/microservices/claudeqq/proxy" ? baseUrl : null;
}
}
function normalizeClaudeQqEndpoint(basePath: string, endpoint: string): string {
return `${basePath.replace(/\/$/u, "")}${endpoint}`;
}
function claudeQqResponseOk(response: unknown): boolean {
if (!isRecord(response)) return false;
if (response.ok === false) return false;
if (typeof response.status === "number" && (response.status < 200 || response.status >= 300)) return false;
const body = response.body;
if (isRecord(body) && (body.ok === false || body.success === false)) return false;
return response.ok === true || typeof response.status === "number";
}
function summarizeClaudeQqProxyFailure(response: unknown, endpoint: string): ClaudeQqEndpointResult {
if (!isRecord(response)) return { ok: false, endpoint, degradedReason: "invalid-response", message: "ClaudeQQ proxy returned a non-object response", response };
const status = typeof response.status === "number" ? response.status : undefined;
const stdoutTail = typeof response.stdoutTail === "string" ? response.stdoutTail : "";
let parsedTail: unknown = null;
if (stdoutTail.length > 0) {
try {
parsedTail = JSON.parse(stdoutTail.trim()) as unknown;
} catch {
parsedTail = null;
}
}
return {
ok: false,
endpoint,
status,
degradedReason: response.ok === false ? "microservice-proxy-failed" : "invalid-response",
message: typeof response.error === "string"
? response.error
: typeof response.stderrTail === "string" && response.stderrTail.length > 0
? response.stderrTail.slice(-500)
: "ClaudeQQ proxy request failed",
response: parsedTail ?? response,
};
}
async function sendClaudeQqEndpoint(config: ClaudeQqConfig, endpoint: string, payload: Record<string, unknown>): Promise<ClaudeQqEndpointResult> {
const basePath = proxiedClaudeQqBasePath(config.baseUrl);
if (basePath !== null) {
const response = coreInternalFetch(normalizeClaudeQqEndpoint(basePath, endpoint), {
method: "POST",
body: payload,
maxResponseBytes: 240_000,
});
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<ClaudeQqSendResult> {
const target = maskedTarget(config);
if (!config.enabled) {
return { ok: true, attempted: false, skipped: true, skippedReason: "UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED disabled", target };
}
if (config.targetType === "group" && (config.groupId === undefined || config.groupId.length === 0)) {
return { ok: false, attempted: false, skipped: true, skippedReason: "group target requires UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID", target };
}
const payload = claudeQqPayload(config, message);
const first = await sendClaudeQqEndpoint(config, "/api/push/text", payload);
if (first.ok) return { ok: true, attempted: true, endpoint: first.endpoint, status: first.status, response: first.response, target };
const second = await sendClaudeQqEndpoint(config, "/api/send/text", payload);
if (second.ok) return { ok: true, attempted: true, endpoint: second.endpoint, status: second.status, response: second.response, target };
return {
ok: false,
attempted: true,
endpoint: second.endpoint,
status: second.status ?? first.status,
degradedReason: second.degradedReason ?? first.degradedReason ?? "claudeqq-send-failed",
message: second.message ?? first.message ?? "ClaudeQQ send failed",
response: { attempts: [first, second] },
target,
};
}
function commanderBriefNotificationPlan(issueNumber: number, body: string, diff: CommanderBriefDiff, config: ClaudeQqConfig): Record<string, unknown> {
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<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
const isIssueWrite = command === "issue create" || command === "issue comment create";
const requestBody: Record<string, unknown> = {
bodyChars: body.length,
bodySource,
};
if (command === "issue create" && typeof extra.title === "string") requestBody.title = extra.title;
if (command === "issue create" && Array.isArray(extra.labels)) requestBody.labels = extra.labels;
return {
repo,
bodySource,
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<string, unknown>;
const details: Record<string, unknown> = {};
if ("message" in source) details.message = source.message;
if ("documentation_url" in source) details.documentationUrl = source.documentation_url;
if ("status" in source) details.status = source.status;
if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5);
if ("raw" in source) details.raw = source.raw;
return details;
}
function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload {
return {
ok: false,
degradedReason: reason,
runnerDisposition: runnerDisposition(reason),
message,
...extra,
};
}
function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record<string, unknown> = {}): GitHubCommandResult {
return {
ok: false,
command,
repo,
degradedReason: error.degradedReason,
runnerDisposition: error.runnerDisposition,
details: error,
...extra,
};
}
function validationError(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
const error = errorPayload("validation-failed", message, { details: extra });
return commandError(command, repo, error, extra);
}
function unsupportedCommand(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
const error = errorPayload("unsupported-command", message, { details: extra });
return commandError(command, repo, error, { message, ...extra });
}
async function parseGitHubResponse(response: Response): Promise<unknown> {
const text = await response.text();
if (text.length === 0) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return { raw: preview(text) };
}
}
function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason {
const lower = message.toLowerCase();
const acceptedScopes = response.headers.get("x-accepted-oauth-scopes");
if (status === 401) return "auth-failed";
if (status === 403) {
if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient";
if (path.startsWith("/repos/")) return "repo-forbidden";
return "permission-denied";
}
if (status === 404) {
if (/\/pulls\/\d+/u.test(path)) return "pr-not-found";
if (/\/issues\/\d+/u.test(path)) return "issue-not-found";
if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed";
return "repo-not-found";
}
if (status === 422) return "validation-failed";
return "invalid-response";
}
async function githubRequest<T>(
token: string,
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<T | GitHubErrorPayload> {
let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
response = await fetch(`${GITHUB_API}${path}`, {
method,
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
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<string, unknown> {
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<string, unknown> {
if (typeof label === "string") return { name: label, color: null, description: null };
return {
name: label.name ?? "",
color: label.color ?? null,
description: label.description ?? null,
};
}
function issueLabelNames(issue: GitHubIssue): string[] {
return (issue.labels ?? []).map((label) => typeof label === "string" ? label : label.name ?? "").filter((label) => label.length > 0);
}
function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Record<string, unknown> {
const summary: Record<IssueListJsonField, unknown> = {
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
updatedAt: issue.updated_at ?? null,
createdAt: issue.created_at ?? null,
author: issue.user?.login ?? null,
labels: (issue.labels ?? []).map(labelSummary),
};
const selected: Record<string, unknown> = {};
for (const field of fields) selected[field] = summary[field];
return selected;
}
function boardIssueEntry(issue: GitHubIssue): BoardIssueEntry {
return {
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
};
}
function sortedIssueEntries(issues: BoardIssueEntry[]): BoardIssueEntry[] {
return issues.slice().sort((a, b) => a.number - b.number);
}
function mergedKnownMetaIssues(options: GitHubOptions): number[] {
const seen = new Set<number>();
const values: number[] = [];
for (const issueNumber of [...DEFAULT_BOARD_KNOWN_META_ISSUES, ...options.knownMetaIssues]) {
if (seen.has(issueNumber)) continue;
seen.add(issueNumber);
values.push(issueNumber);
}
return values;
}
function boardIgnoreMap(options: GitHubOptions, issues: BoardIssueEntry[] = []): Map<number, BoardIgnoreReason> {
const ignored = new Map<number, BoardIgnoreReason>();
for (const issueNumber of mergedKnownMetaIssues(options)) ignored.set(issueNumber, "known-meta");
for (const issueNumber of options.ignoredIssues) ignored.set(issueNumber, "ignored");
if (options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE) {
for (const issue of issues) {
if (!ignored.has(issue.number) && isDailyCommanderBriefTitle(issue.title)) ignored.set(issue.number, "brief-index-managed");
}
}
return ignored;
}
function stripMarkdownInline(text: string): string {
return text
.replace(/\[[^\]]*]\(([^)]*)\)/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/[*_`~]/g, "")
.trim();
}
function normalizeBoardHeader(header: string): string {
return stripMarkdownInline(header).toLowerCase().replace(/\s+/g, "");
}
function boardHeaderColumnKind(header: string): BoardColumnKind | null {
const normalized = normalizeBoardHeader(header);
if (["category", "类别", "分类", "类型", "任务类型"].includes(normalized)) return "category";
if (["branch", "分支", "目标分支", "工作分支"].includes(normalized)) return "branch";
if (["acceptance", "验收", "验收状态", "验收结果", "验收标准"].includes(normalized)) return "acceptance";
if (["relatedtask", "task", "codequeue", "codequeuetask", "相关任务", "任务", "codequeue任务", "cq任务", "相关codequeue任务", "相关codequeuetask"].includes(normalized)) return "relatedTask";
if (["githubstatus", "githubstate", "github状态", "githubissue状态", "issuestate", "issue状态", "issuegithub状态", "开闭状态", "openclosed", "openclosed状态"].includes(normalized)) return "githubStatus";
if (["summary", "摘要", "概述", "任务摘要", "任务概述", "标题"].includes(normalized)) return "summary";
if (["focus", "currentfocus", "关注点", "当前关注点", "当前focus"].includes(normalized)) return "focus";
if (["progress", "进度", "状态", "当前进度"].includes(normalized)) return "progress";
return null;
}
function boardColumnIndexMap(headers: string[]): Map<BoardColumnKind, number> {
const map = new Map<BoardColumnKind, number>();
headers.forEach((header, headerIndex) => {
const kind = boardHeaderColumnKind(header);
if (kind !== null && !map.has(kind)) map.set(kind, headerIndex);
});
return map;
}
function boardRequiredColumnIndexMap(headers: string[]): Map<BoardRequiredColumn, number> {
const map = new Map<BoardRequiredColumn, number>();
const allColumns = boardColumnIndexMap(headers);
for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) {
const index = allColumns.get(column);
if (index !== undefined) map.set(column, index);
}
return map;
}
function boardColumnKindForField(field: BoardRowField): BoardColumnKind {
if (field === "branch") return "branch";
if (field === "tasks") return "relatedTask";
if (field === "focus") return "focus";
if (field === "progress") return "progress";
return "acceptance";
}
function boardIssueColumnIndex(headers: string[]): number | null {
for (let index = 0; index < headers.length; index += 1) {
const normalized = normalizeBoardHeader(headers[index]);
if (["issue", "issue#", "issuenumber", "issueid", "issue编号", "issue号", "编号"].includes(normalized)) return index;
}
return null;
}
function markdownCells(line: string): string[] {
const trimmed = line.trim();
const withoutOuterPipes = trimmed.replace(/^\|/u, "").replace(/\|$/u, "");
const cells: string[] = [];
let current = "";
for (let index = 0; index < withoutOuterPipes.length; index += 1) {
const char = withoutOuterPipes[index];
const previous = withoutOuterPipes[index - 1];
if (char === "|" && previous !== "\\") {
cells.push(current.trim().replace(/\\\|/g, "|"));
current = "";
continue;
}
current += char;
}
cells.push(current.trim().replace(/\\\|/g, "|"));
return cells;
}
function isMarkdownTableSeparator(line: string): boolean {
const cells = markdownCells(line);
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/u.test(cell.trim()));
}
function boardSectionKindFromHeading(line: string): BoardSectionKind | null {
const normalized = line.toLowerCase();
if (!line.trimStart().startsWith("#")) return null;
if (normalized.includes("open") || normalized.includes("看板(open") || normalized.includes("看板(open)") || normalized.includes("开放")) return "open";
if (normalized.includes("closed") || normalized.includes("看板(closed") || normalized.includes("看板(closed)") || normalized.includes("关闭") || normalized.includes("已关闭")) return "closed";
return null;
}
function extractIssueNumbers(text: string): number[] {
const numbers: number[] = [];
const seen = new Set<number>();
const patterns = [/#(\d+)/g, /\/issues\/(\d+)/g];
for (const pattern of patterns) {
pattern.lastIndex = 0;
let match = pattern.exec(text);
while (match !== null) {
const value = Number(match[1]);
if (Number.isInteger(value) && value > 0 && !seen.has(value)) {
seen.add(value);
numbers.push(value);
}
match = pattern.exec(text);
}
}
return numbers;
}
function primaryBoardIssueNumberFromIssueCell(issueCell: string | undefined): number | null {
if (issueCell === undefined) return null;
const markdownLinkPattern = /\[[^\]]*]\(([^)]*)\)/g;
let match = markdownLinkPattern.exec(issueCell);
while (match !== null) {
const issueNumber = /\/issues\/(\d+)/u.exec(match[1] ?? "")?.[1];
if (issueNumber !== undefined) {
const value = Number(issueNumber);
if (Number.isInteger(value) && value > 0) return value;
}
match = markdownLinkPattern.exec(issueCell);
}
const leadingIssueReference = /^\s*#(\d+)\b/u.exec(issueCell)?.[1];
if (leadingIssueReference !== undefined) {
const value = Number(leadingIssueReference);
if (Number.isInteger(value) && value > 0) return value;
}
return null;
}
function boardRowFromCells(section: BoardTableSection, raw: string, cells: string[], lineNumber: number): BoardTableRow {
const columnMap = boardRequiredColumnIndexMap(section.headers);
const issueColumnIndex = boardIssueColumnIndex(section.headers);
const issueCell = issueColumnIndex === null ? undefined : cells[issueColumnIndex];
const primaryIssueNumber = issueColumnIndex === null ? null : primaryBoardIssueNumberFromIssueCell(issueCell);
const issueNumbers = issueColumnIndex === null
? extractIssueNumbers(raw)
: (primaryIssueNumber === null ? extractIssueNumbers(issueCell ?? "") : [primaryIssueNumber]);
const columns: Partial<Record<BoardRequiredColumn, string>> = {};
for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) {
const cellIndex = columnMap.get(column);
if (cellIndex !== undefined) columns[column] = cells[cellIndex] ?? "";
}
return {
section: section.kind,
lineNumber,
raw,
cells,
issueNumbers,
issueNumber: issueNumbers[0] ?? null,
title: issueColumnIndex === null
? (cells.find((cell) => extractIssueNumbers(cell).length > 0) ?? cells[0] ?? null)
: (issueCell ?? cells[0] ?? null),
columns,
};
}
function normalizedBoardCellPlaceholder(value: string): { spaced: string; compact: string } {
const spaced = stripMarkdownInline(value).toLowerCase().replace(/\s+/g, " ").trim();
return { spaced, compact: spaced.replace(/\s+/g, "") };
}
function isRelatedTaskNoTaskPlaceholder(value: string): boolean {
const normalized = normalizedBoardCellPlaceholder(value);
return [
"-",
"",
"",
"n/a",
"na",
"none",
"no task",
"no-task",
"no code queue task",
"not applicable",
].includes(normalized.spaced) || [
"",
"无任务",
"无关联任务",
"无相关任务",
"codequeue任务",
"codequeuetask",
"不适用",
].includes(normalized.compact);
}
function cellHasMeaningfulValue(value: string | undefined, column: BoardRequiredColumn): boolean {
if (value === undefined) return false;
const stripped = stripMarkdownInline(value);
if (stripped.length === 0) return false;
if (column === "relatedTask" && isRelatedTaskNoTaskPlaceholder(value)) return true;
return !["-", "", "n/a", "na", "todo", "tbd", "待补", "未填", ""].includes(stripped.toLowerCase());
}
function parseBoardTables(body: string): { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] } {
const lines = normalizeNewlines(body).split("\n");
const sections: BoardTableSection[] = [];
const warnings: BoardRowValidationWarning[] = [];
let currentKind: BoardSectionKind | null = null;
let currentHeading = "";
let currentHeadingLine = 0;
for (let index = 0; index < lines.length; index += 1) {
const headingKind = boardSectionKindFromHeading(lines[index]);
if (headingKind !== null) {
currentKind = headingKind;
currentHeading = lines[index].trim();
currentHeadingLine = index + 1;
continue;
}
if (currentKind === null) continue;
if (!lines[index].trim().startsWith("|")) continue;
const separator = lines[index + 1];
if (separator === undefined || !separator.trim().startsWith("|") || !isMarkdownTableSeparator(separator)) continue;
const headers = markdownCells(lines[index]);
const rows: BoardTableRow[] = [];
let rowIndex = index + 2;
while (rowIndex < lines.length && lines[rowIndex].trim().startsWith("|")) {
if (!isMarkdownTableSeparator(lines[rowIndex])) {
const cells = markdownCells(lines[rowIndex]);
const row = boardRowFromCells(
{ kind: currentKind, heading: currentHeading, headingLine: currentHeadingLine, headerLine: index + 1, headers, rows: [] },
lines[rowIndex],
cells,
rowIndex + 1,
);
rows.push(row);
if (row.issueNumbers.length === 0) {
warnings.push({
issueNumber: null,
section: currentKind,
lineNumber: row.lineNumber,
kind: "missing-issue-reference",
message: "Board table row does not contain a GitHub issue reference such as #36.",
rowPreview: preview(row.raw),
});
}
if (row.issueNumbers.length > 1) {
warnings.push({
issueNumber: row.issueNumber,
section: currentKind,
lineNumber: row.lineNumber,
kind: "multiple-issue-references",
message: boardIssueColumnIndex(headers) === null
? "Board table row contains multiple issue references; audit uses the first number as the row key."
: "Board table row contains multiple issue references in the Issue column; audit uses the first number as the row key.",
rowPreview: preview(row.raw),
});
}
const missingColumns = BOARD_AUDIT_REQUIRED_COLUMNS.filter((column) => !cellHasMeaningfulValue(row.columns[column], column));
if (missingColumns.length > 0) {
warnings.push({
issueNumber: row.issueNumber,
section: currentKind,
lineNumber: row.lineNumber,
kind: "missing-required-columns",
message: "Board table row is missing branch, acceptance, related task, or progress information.",
missingColumns,
columns: row.columns,
rowPreview: preview(row.raw),
});
}
}
rowIndex += 1;
}
sections.push({
kind: currentKind,
heading: currentHeading,
headingLine: currentHeadingLine,
headerLine: index + 1,
headers,
rows,
});
index = rowIndex - 1;
}
return { sections, warnings };
}
function boardRowsByIssue(rows: BoardTableRow[]): Map<number, BoardTableRow[]> {
const map = new Map<number, BoardTableRow[]>();
for (const row of rows) {
if (row.issueNumber === null) continue;
const list = map.get(row.issueNumber);
if (list === undefined) map.set(row.issueNumber, [row]);
else list.push(row);
}
return map;
}
function boardRowFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowField, string | null> {
const columnMap = boardColumnIndexMap(section.headers);
const valueFor = (kind: BoardColumnKind): string | null => {
const index = columnMap.get(kind);
return index === undefined ? null : row.cells[index] ?? "";
};
const acceptance = valueFor("acceptance");
return {
progress: valueFor("progress"),
status: acceptance,
validation: acceptance,
branch: valueFor("branch"),
tasks: valueFor("relatedTask"),
focus: valueFor("focus"),
};
}
function boardRowUpsertFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowUpsertField, string | null> {
const columnMap = boardColumnIndexMap(section.headers);
const valueFor = (kind: BoardColumnKind): string | null => {
const index = columnMap.get(kind);
return index === undefined ? null : row.cells[index] ?? "";
};
const acceptance = valueFor("acceptance");
return {
category: valueFor("category"),
branch: valueFor("branch"),
tasks: valueFor("relatedTask"),
summary: valueFor("summary"),
focus: valueFor("focus"),
validation: acceptance,
progress: valueFor("progress"),
status: valueFor("githubStatus"),
};
}
function boardRowSummary(section: BoardTableSection, row: BoardTableRow): Record<string, unknown> {
return {
issueNumber: row.issueNumber,
section: row.section,
lineNumber: row.lineNumber,
heading: section.heading,
headers: section.headers,
title: row.title,
cells: row.cells,
fields: boardRowFieldValues(section, row),
raw: row.raw,
rowPreview: preview(row.raw),
};
}
function boardRowsExactMatch(
sections: BoardTableSection[],
issueNumber: number,
): Array<{ section: BoardTableSection; row: BoardTableRow }> {
return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber);
}
function boardRowsForState(sections: BoardTableSection[], state: IssueListState): Array<{ section: BoardTableSection; row: BoardTableRow }> {
return sections
.filter((section) => state === "all" || section.kind === state)
.flatMap((section) => section.rows.map((row) => ({ section, row })));
}
function boardDuplicateRows(
sections: BoardTableSection[],
issueNumber: number,
): Array<{ section: BoardTableSection; row: BoardTableRow }> {
return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber);
}
function targetBoardSection(
repo: string,
sections: BoardTableSection[],
kind: BoardMutationSection,
commandName: string,
): { ok: true; section: BoardTableSection } | GitHubCommandFailure {
const matches = sections.filter((section) => section.kind === kind);
if (matches.length === 0) {
return validationError(commandName, repo, `board ${kind.toUpperCase()} table was not found`, {
section: kind,
availableSections: sections.map((section) => ({ kind: section.kind, heading: section.heading, headerLine: section.headerLine })),
}) as GitHubCommandFailure;
}
if (matches.length > 1) {
return validationError(commandName, repo, `board ${kind.toUpperCase()} table is ambiguous`, {
section: kind,
matches: matches.map((section) => ({ heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine })),
}) as GitHubCommandFailure;
}
return { ok: true, section: matches[0] };
}
function findBoardRow(
repo: string,
sections: BoardTableSection[],
issueNumber: number,
state: IssueListState = "all",
): BoardRowLookup | GitHubCommandFailure {
const matches = boardRowsForState(sections, state).filter(({ row }) => row.issueNumber === issueNumber);
if (matches.length === 0) {
return validationError("issue board-row", repo, `board row for issue #${issueNumber} was not found`, {
issueNumber,
state,
availableIssueNumbers: boardRowsForState(sections, state)
.map(({ row }) => row.issueNumber)
.filter((value): value is number => value !== null)
.sort((a, b) => a - b),
}) as GitHubCommandFailure;
}
if (matches.length > 1) {
return validationError("issue board-row", repo, `board row for issue #${issueNumber} is ambiguous`, {
issueNumber,
state,
matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}) as GitHubCommandFailure;
}
return { ok: true, ...matches[0], matches };
}
function escapeMarkdownTableCell(value: string): string {
return value
.replace(/\r\n/g, " ")
.replace(/[\r\n]+/g, " ")
.replace(/\|/g, "\\|")
.trim();
}
function boardRowValuePollutionEvidence(value: string): string[] {
return shellPollutionEvidence(value).filter((item) => item !== "bare-carriage-return");
}
function renderMarkdownTableRow(cells: string[]): string {
return `| ${cells.map(escapeMarkdownTableCell).join(" | ")} |`;
}
function validateMarkdownTableRowLine(line: string): string | null {
if (!line.trim().startsWith("|") || !line.trim().endsWith("|")) return "row-file must contain a Markdown table row starting and ending with |";
if (isMarkdownTableSeparator(line)) return "row-file must contain a data row, not a Markdown table separator";
return null;
}
function readBoardRowFile(path: string | undefined, command: string): { path: string; raw: string; cells: string[] } {
if (path === undefined) throw new Error(`${command} requires --row-file <markdown-row-file>`);
if (!existsSync(path)) throw new Error(`row file not found: ${path}`);
const normalized = normalizeNewlines(readFileSync(path, "utf8")).trim();
if (normalized.length === 0) throw new Error("row-file must contain exactly one non-empty Markdown table row");
const lines = normalized.split("\n");
if (lines.length !== 1) throw new Error("row-file must contain exactly one Markdown table row");
const line = lines[0];
const lineError = validateMarkdownTableRowLine(line);
if (lineError !== null) throw new Error(lineError);
return { path, raw: line, cells: markdownCells(line) };
}
function replaceBoardBodyLine(body: string, lineNumber: number, newLine: string): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`);
lines[lineNumber - 1] = newLine;
return lines.join("\n");
}
function removeBoardBodyLine(body: string, lineNumber: number): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`);
lines.splice(lineNumber - 1, 1);
return lines.join("\n");
}
function insertBoardBodyLineAfter(body: string, lineNumber: number, newLine: string): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (lineNumber < 0 || lineNumber > lines.length) throw new Error(`insert line ${lineNumber} is outside the board body`);
lines.splice(lineNumber, 0, newLine);
return lines.join("\n");
}
function boardSectionInsertAfterLine(section: BoardTableSection): number {
if (section.rows.length === 0) return section.headerLine + 1;
return section.rows[section.rows.length - 1].lineNumber;
}
function boardBodySafetyIssue(repo: string, boardIssueNumber: number, body: string, options: GitHubOptions): GitHubCommandResult | null {
const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile };
return validateIssueBodyGuard(repo, boardIssueNumber, body, guardOptions);
}
function boardBodyGuardSummary(boardIssueNumber: number, body: string, options: GitHubOptions): Record<string, unknown> {
const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile };
return issueEditGuardSummary(boardIssueNumber, body, guardOptions);
}
function boardRowUpdatePlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
field: BoardRowField,
value: string,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowUpdatePlanResult | GitHubCommandFailure {
const found = findBoardRow(repo, parsed.sections, issueNumber);
if (found.ok === false) return found;
const { section, row } = found;
const targetColumn = boardColumnKindForField(field);
const columnMap = boardColumnIndexMap(section.headers);
const columnIndex = columnMap.get(targetColumn);
if (columnIndex === undefined) {
return validationError("issue board-row update", repo, `board table does not contain a column for --field ${field}`, {
issueNumber,
field,
targetColumn,
section: section.kind,
headers: section.headers,
supportedFields: BOARD_ROW_FIELDS.slice(),
}) as GitHubCommandFailure;
}
const oldCells = row.cells.slice();
const newCells = oldCells.slice();
while (newCells.length < section.headers.length) newCells.push("");
newCells[columnIndex] = value;
const newRow = renderMarkdownTableRow(newCells);
const oldBody = boardIssue.body ?? "";
let newBody: string;
try {
newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow);
} catch (error) {
return validationError("issue board-row update", repo, error instanceof Error ? error.message : String(error), {
issueNumber,
field,
lineNumber: row.lineNumber,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
field,
targetColumn,
targetColumnIndex: columnIndex,
section: section.kind,
lineNumber: row.lineNumber,
oldRow: row.raw,
newRow,
oldCells,
newCells,
oldValue: oldCells[columnIndex] ?? "",
newValue: value,
row: {
old: boardRowSummary(section, row),
new: {
...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }),
updatedField: field,
},
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function expectedBoardGithubStatus(section: BoardMutationSection): BoardGithubStatus {
return section === "open" ? "OPEN" : "CLOSED";
}
function normalizeBoardGithubStatusCell(value: string | undefined): BoardGithubStatus | null {
const normalized = stripMarkdownInline(value ?? "").toUpperCase().replace(/\s+/g, "");
if (normalized === "OPEN") return "OPEN";
if (normalized === "CLOSED") return "CLOSED";
return null;
}
function boardHeaderShape(headers: string[]): string[] {
return headers.map((header) => normalizeBoardHeader(header));
}
function sameBoardHeaderShape(a: string[], b: string[]): boolean {
const left = boardHeaderShape(a);
const right = boardHeaderShape(b);
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function boardRowDuplicateFailure(
repo: string,
commandName: string,
sections: BoardTableSection[],
issueNumber: number,
): GitHubCommandFailure | null {
const duplicates = boardDuplicateRows(sections, issueNumber);
if (duplicates.length === 0) return null;
return validationError(commandName, repo, `board row for issue #${issueNumber} already exists; refusing duplicate row`, {
issueNumber,
matches: duplicates.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}) as GitHubCommandFailure;
}
function boardStatusOptionFailure(
repo: string,
commandName: string,
targetSection: BoardMutationSection,
status: BoardGithubStatus | undefined,
): GitHubCommandFailure | null {
if (status === undefined) return null;
const expected = expectedBoardGithubStatus(targetSection);
if (status === expected) return null;
return validationError(commandName, repo, `--status ${status} conflicts with --to ${targetSection}; expected ${expected}`, {
targetSection,
status,
expectedStatus: expected,
}) as GitHubCommandFailure;
}
function boardRowStatusConsistency(
repo: string,
commandName: string,
section: BoardTableSection,
row: BoardTableRow,
role: "source" | "target",
): { ok: true; githubStatusColumnIndex: number; actualStatus: BoardGithubStatus | null; expectedStatus: BoardGithubStatus } | GitHubCommandFailure {
const columnMap = boardColumnIndexMap(section.headers);
const githubStatusColumnIndex = columnMap.get("githubStatus");
if (githubStatusColumnIndex === undefined) {
return validationError(commandName, repo, `${role} board table does not contain a GitHub 状态 column`, {
section: section.kind,
headers: section.headers,
lineNumber: row.lineNumber,
rowPreview: preview(row.raw),
}) as GitHubCommandFailure;
}
const expectedStatus = expectedBoardGithubStatus(section.kind);
const actualStatus = normalizeBoardGithubStatusCell(row.cells[githubStatusColumnIndex]);
if (actualStatus !== expectedStatus) {
return validationError(commandName, repo, `${role} row GitHub 状态 ${actualStatus ?? "missing"} conflicts with ${section.kind.toUpperCase()} section; expected ${expectedStatus}`, {
section: section.kind,
lineNumber: row.lineNumber,
rowPreview: preview(row.raw),
actualStatus,
expectedStatus,
githubStatusColumnIndex,
headers: section.headers,
}) as GitHubCommandFailure;
}
return { ok: true, githubStatusColumnIndex, actualStatus, expectedStatus };
}
function boardRowUpsertValueEntries(values: BoardRowUpsertValues): Array<{ field: BoardRowUpsertField; value: string }> {
const entries: Array<{ field: BoardRowUpsertField; value: string }> = [];
for (const field of BOARD_ROW_UPSERT_TEXT_FIELDS) {
const value = values[field];
if (value !== undefined) entries.push({ field, value });
}
if (values.status !== undefined) entries.push({ field: "status", value: values.status });
return entries;
}
function boardColumnKindForUpsertField(field: BoardRowUpsertField): BoardColumnKind {
if (field === "category") return "category";
if (field === "branch") return "branch";
if (field === "tasks") return "relatedTask";
if (field === "summary") return "summary";
if (field === "focus") return "focus";
if (field === "validation") return "acceptance";
if (field === "status") return "githubStatus";
return "progress";
}
function renderBoardIssueCell(repo: string, issueNumber: number, summary: string | undefined): string {
const trimmed = (summary ?? "").trim();
if (trimmed.length === 0) return `#${issueNumber}`;
const targetHref = `https://github.com/${repo}/issues/${issueNumber}`;
const firstMarkdownIssue = /^\s*\[[^\]]*\]\(([^)]*)\)/u.exec(trimmed)?.[1] ?? "";
if (new RegExp(`/issues/${issueNumber}(?:\\b|$)`, "u").test(firstMarkdownIssue)) return trimmed;
return `[#${issueNumber}](${targetHref}) ${trimmed}`;
}
function boardRowUpsertTargetColumn(
repo: string,
commandName: string,
section: BoardTableSection,
issueNumber: number,
field: BoardRowUpsertField,
): { ok: true; columnIndex: number; targetColumn: BoardColumnKind | "issue"; usesIssueCell: boolean } | GitHubCommandFailure {
const columnMap = boardColumnIndexMap(section.headers);
if (field === "summary") {
const summaryIndex = columnMap.get("summary");
if (summaryIndex !== undefined) return { ok: true, columnIndex: summaryIndex, targetColumn: "summary", usesIssueCell: false };
const issueColumnIndex = boardIssueColumnIndex(section.headers);
if (issueColumnIndex !== null) return { ok: true, columnIndex: issueColumnIndex, targetColumn: "issue", usesIssueCell: true };
}
const targetColumn = boardColumnKindForUpsertField(field);
const columnIndex = columnMap.get(targetColumn);
if (columnIndex === undefined) {
return validationError(commandName, repo, `board table does not contain a column for --${field}`, {
issueNumber,
field,
targetColumn,
section: section.kind,
headers: section.headers,
}) as GitHubCommandFailure;
}
return { ok: true, columnIndex, targetColumn, usesIssueCell: false };
}
function boardRowUpsertRenderedValue(
repo: string,
issueNumber: number,
field: BoardRowUpsertField,
value: string,
targetColumn: BoardColumnKind | "issue",
): string {
if (field === "summary" && targetColumn === "issue") return renderBoardIssueCell(repo, issueNumber, value);
return value;
}
function boardRowUpsertPollutionFailure(
repo: string,
commandName: string,
issueNumber: number,
values: BoardRowUpsertValues,
): GitHubCommandFailure | null {
const findings = BOARD_ROW_UPSERT_TEXT_FIELDS
.map((field) => {
const value = values[field];
if (value === undefined) return null;
const evidence = boardRowValuePollutionEvidence(value);
return evidence.length === 0 ? null : { field, valuePreview: preview(value), evidence };
})
.filter((item): item is { field: BoardRowUpsertTextField; valuePreview: string; evidence: string[] } => item !== null);
if (findings.length === 0) return null;
return validationError(commandName, repo, "issue board-row upsert field value contains literal shell escape text that would pollute a board cell; pass real newlines or plain text instead", {
issueNumber,
shellPollution: { suspected: true, findings },
}) as GitHubCommandFailure;
}
function boardRowUpsertUpdatePlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
found: { section: BoardTableSection; row: BoardTableRow },
values: BoardRowUpsertValues,
requestedSection: BoardMutationSection | undefined,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row upsert";
const { section, row } = found;
if (requestedSection !== undefined && requestedSection !== section.kind) {
return validationError(commandName, repo, `--section ${requestedSection} conflicts with existing row in ${section.kind}; use gh issue board-row move for section migration`, {
issueNumber,
requestedSection,
existingSection: section.kind,
moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${requestedSection}`,
}) as GitHubCommandFailure;
}
if (values.status !== undefined) {
const expectedStatus = expectedBoardGithubStatus(section.kind);
if (values.status !== expectedStatus) {
return validationError(commandName, repo, `--status ${values.status} conflicts with existing ${section.kind.toUpperCase()} section; use gh issue board-row move for section migration`, {
issueNumber,
existingSection: section.kind,
requestedStatus: values.status,
expectedStatus,
moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${values.status === "OPEN" ? "open" : "closed"} --status ${values.status}`,
}) as GitHubCommandFailure;
}
}
const entries = boardRowUpsertValueEntries(values);
if (entries.length === 0) {
return validationError(commandName, repo, "issue board-row upsert found an existing row but no upsert fields were provided", {
issueNumber,
existingSection: section.kind,
supportedFields: ["--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--status"],
}) as GitHubCommandFailure;
}
if (row.cells.length !== section.headers.length) {
return validationError(commandName, repo, "existing row column count does not match the board table header", {
issueNumber,
section: section.kind,
expectedColumns: section.headers.length,
actualColumns: row.cells.length,
headers: section.headers,
lineNumber: row.lineNumber,
rowPreview: preview(row.raw),
}) as GitHubCommandFailure;
}
const oldCells = row.cells.slice();
const newCells = oldCells.slice();
const changedFields: Record<string, unknown>[] = [];
for (const entry of entries) {
const target = boardRowUpsertTargetColumn(repo, commandName, section, issueNumber, entry.field);
if (target.ok === false) return target;
const newValue = boardRowUpsertRenderedValue(repo, issueNumber, entry.field, entry.value, target.targetColumn);
const oldValue = oldCells[target.columnIndex] ?? "";
newCells[target.columnIndex] = newValue;
changedFields.push({
field: entry.field,
targetColumn: target.targetColumn,
targetColumnIndex: target.columnIndex,
requestedValue: entry.value,
oldValue,
newValue,
changed: oldValue !== newValue,
});
}
const newRow = renderMarkdownTableRow(newCells);
const oldBody = boardIssue.body ?? "";
let newBody: string;
try {
newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
lineNumber: row.lineNumber,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
operation: "update",
action: "update",
section: section.kind,
lineNumber: row.lineNumber,
requestedSection: requestedSection ?? null,
providedFields: entries.map((entry) => entry.field),
changedFields,
oldRow: row.raw,
newRow,
oldCells,
newCells,
row: {
old: boardRowSummary(section, row),
new: {
...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }),
upsertFields: boardRowUpsertFieldValues(section, { ...row, raw: newRow, cells: newCells }),
},
},
linePlan: {
action: "replace",
section: section.kind,
lineNumber: row.lineNumber,
},
sectionPlan: {
kind: section.kind,
heading: section.heading,
headerLine: section.headerLine,
rowCount: section.rows.length,
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function boardRowUpsertAddPlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
sectionKind: BoardMutationSection,
values: BoardRowUpsertValues,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row upsert";
const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName);
if (targetLookup.ok === false) return targetLookup;
const target = targetLookup.section;
const issueColumnIndex = boardIssueColumnIndex(target.headers);
if (issueColumnIndex === null) {
return validationError(commandName, repo, "board table does not contain an Issue column required for generated upsert rows", {
boardIssue: boardIssue.number,
section: sectionKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
const columnMap = boardColumnIndexMap(target.headers);
const githubStatusColumnIndex = columnMap.get("githubStatus");
if (githubStatusColumnIndex === undefined) {
return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for generated upsert rows", {
boardIssue: boardIssue.number,
section: sectionKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
const expectedStatus = expectedBoardGithubStatus(sectionKind);
const requestedStatus = values.status ?? expectedStatus;
if (requestedStatus !== expectedStatus) {
return validationError(commandName, repo, `--status ${requestedStatus} conflicts with --section ${sectionKind}; expected ${expectedStatus}`, {
issueNumber,
section: sectionKind,
requestedStatus,
expectedStatus,
}) as GitHubCommandFailure;
}
const appliedFields = new Set<BoardRowUpsertField>();
const missingFields: BoardRowUpsertTextField[] = [];
const unsupportedColumns: Array<{ index: number; header: string }> = [];
const summaryColumnIndex = columnMap.get("summary");
const cells = target.headers.map((header, index) => {
if (index === issueColumnIndex) {
if (summaryColumnIndex === undefined && values.summary !== undefined) appliedFields.add("summary");
return summaryColumnIndex === undefined ? renderBoardIssueCell(repo, issueNumber, values.summary) : `#${issueNumber}`;
}
const kind = boardHeaderColumnKind(header);
if (kind === "githubStatus") {
if (values.status !== undefined) appliedFields.add("status");
return requestedStatus;
}
const valueFor = (field: BoardRowUpsertTextField, value: string | undefined): string => {
if (value === undefined) {
missingFields.push(field);
return "";
}
appliedFields.add(field);
return value;
};
if (kind === "category") return valueFor("category", values.category);
if (kind === "branch") return valueFor("branch", values.branch);
if (kind === "relatedTask") return valueFor("tasks", values.tasks);
if (kind === "summary") return valueFor("summary", values.summary);
if (kind === "focus") return valueFor("focus", values.focus);
if (kind === "acceptance") return valueFor("validation", values.validation);
if (kind === "progress") return valueFor("progress", values.progress);
unsupportedColumns.push({ index, header });
return "";
});
if (unsupportedColumns.length > 0) {
return validationError(commandName, repo, "target board table contains unsupported column(s) for generated upsert rows", {
issueNumber,
section: sectionKind,
unsupportedColumns,
headers: target.headers,
}) as GitHubCommandFailure;
}
const unappliedFields = boardRowUpsertValueEntries(values)
.map((entry) => entry.field)
.filter((field) => !appliedFields.has(field));
if (unappliedFields.length > 0) {
return validationError(commandName, repo, "target board table does not contain column(s) for supplied upsert field(s)", {
issueNumber,
section: sectionKind,
unappliedFields,
headers: target.headers,
}) as GitHubCommandFailure;
}
const uniqueMissingFields = Array.from(new Set(missingFields));
if (uniqueMissingFields.length > 0) {
return validationError(commandName, repo, "issue board-row upsert add requires values for every generated row column", {
issueNumber,
section: sectionKind,
missingFields: uniqueMissingFields,
requiredOptions: uniqueMissingFields.map((field) => `--${field}`),
headers: target.headers,
}) as GitHubCommandFailure;
}
const newRow = renderMarkdownTableRow(cells);
const row = boardRowFromCells(target, newRow, cells, 0);
if (row.issueNumber !== issueNumber) {
return validationError(commandName, repo, "generated Issue column does not match the requested issue number", {
issueNumber,
issueColumnIndex,
issueCell: cells[issueColumnIndex] ?? "",
detectedIssueNumber: row.issueNumber,
rowPreview: preview(newRow),
}) as GitHubCommandFailure;
}
const actualStatus = normalizeBoardGithubStatusCell(cells[githubStatusColumnIndex]);
if (actualStatus !== expectedStatus) {
return validationError(commandName, repo, "generated GitHub 状态 column does not match the target section", {
issueNumber,
section: sectionKind,
expectedStatus,
actualStatus,
statusColumnIndex: githubStatusColumnIndex,
statusCell: cells[githubStatusColumnIndex] ?? "",
rowPreview: preview(newRow),
}) as GitHubCommandFailure;
}
const oldBody = boardIssue.body ?? "";
const insertAfterLine = boardSectionInsertAfterLine(target);
let newBody: string;
try {
newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, newRow);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
section: sectionKind,
insertAfterLine,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
operation: "add",
action: "add",
section: sectionKind,
insertAfterLine,
providedFields: boardRowUpsertValueEntries(values).map((entry) => entry.field),
newRow,
newCells: cells,
row: {
old: null,
new: {
...boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }),
upsertFields: boardRowUpsertFieldValues(target, { ...row, lineNumber: insertAfterLine + 1 }),
},
},
linePlan: {
action: "insert",
section: sectionKind,
insertAfterLine,
newLineNumber: insertAfterLine + 1,
},
sectionPlan: {
kind: sectionKind,
heading: target.heading,
headerLine: target.headerLine,
rowCount: target.rows.length,
},
validation: {
issueColumnIndex,
githubStatusColumnIndex,
expectedStatus,
actualStatus,
columnCount: cells.length,
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function boardRowUpsertPlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
options: GitHubOptions,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row upsert";
const pollutionFailure = boardRowUpsertPollutionFailure(repo, commandName, issueNumber, options.boardRowUpsertValues);
if (pollutionFailure !== null) return pollutionFailure;
const matches = boardRowsExactMatch(parsed.sections, issueNumber);
if (matches.length > 1) {
return validationError(commandName, repo, `board row for issue #${issueNumber} is ambiguous; refusing upsert`, {
issueNumber,
matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}) as GitHubCommandFailure;
}
if (matches.length === 1) {
return boardRowUpsertUpdatePlan(repo, boardIssue, issueNumber, matches[0], options.boardRowUpsertValues, options.boardSection, parsed);
}
if (options.boardSection === undefined) {
return validationError(commandName, repo, "issue board-row upsert add requires --section open|closed when the row does not already exist", {
issueNumber,
supportedSections: BOARD_MUTATION_SECTIONS.slice(),
}) as GitHubCommandFailure;
}
return boardRowUpsertAddPlan(repo, boardIssue, issueNumber, options.boardSection, options.boardRowUpsertValues, parsed);
}
function boardRowAddPlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
sectionKind: BoardMutationSection,
rowFile: { path: string; raw: string; cells: string[] },
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row add";
const duplicate = boardRowDuplicateFailure(repo, commandName, parsed.sections, issueNumber);
if (duplicate !== null) return duplicate;
const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName);
if (targetLookup.ok === false) return targetLookup;
const target = targetLookup.section;
const issueColumnIndex = boardIssueColumnIndex(target.headers);
if (issueColumnIndex === null) {
return validationError(commandName, repo, "board table does not contain an Issue column required for row-file validation", {
boardIssue: boardIssue.number,
section: sectionKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
const columnMap = boardColumnIndexMap(target.headers);
const githubStatusColumnIndex = columnMap.get("githubStatus");
if (githubStatusColumnIndex === undefined) {
return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for add section validation", {
boardIssue: boardIssue.number,
section: sectionKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
if (rowFile.cells.length !== target.headers.length) {
return validationError(commandName, repo, "row-file column count does not match the target board table header", {
boardIssue: boardIssue.number,
section: sectionKind,
expectedColumns: target.headers.length,
actualColumns: rowFile.cells.length,
headers: target.headers,
rowFile: rowFile.path,
rowPreview: preview(rowFile.raw),
}) as GitHubCommandFailure;
}
const row = boardRowFromCells(target, rowFile.raw, rowFile.cells, 0);
if (row.issueNumber !== issueNumber) {
return validationError(commandName, repo, "row-file Issue column does not match the requested issue number", {
issueNumber,
issueColumnIndex,
issueCell: rowFile.cells[issueColumnIndex] ?? "",
detectedIssueNumber: row.issueNumber,
rowPreview: preview(rowFile.raw),
}) as GitHubCommandFailure;
}
const expectedStatus = expectedBoardGithubStatus(sectionKind);
const actualStatus = normalizeBoardGithubStatusCell(rowFile.cells[githubStatusColumnIndex]);
if (actualStatus !== expectedStatus) {
return validationError(commandName, repo, "row-file GitHub 状态 column does not match the target section", {
issueNumber,
section: sectionKind,
expectedStatus,
actualStatus,
statusColumnIndex: githubStatusColumnIndex,
statusCell: rowFile.cells[githubStatusColumnIndex] ?? "",
rowPreview: preview(rowFile.raw),
}) as GitHubCommandFailure;
}
const oldBody = boardIssue.body ?? "";
const insertAfterLine = boardSectionInsertAfterLine(target);
let newBody: string;
try {
newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, rowFile.raw);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
section: sectionKind,
insertAfterLine,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
action: "add",
section: sectionKind,
insertAfterLine,
rowFile: rowFile.path,
newRow: rowFile.raw,
newCells: rowFile.cells,
row: {
old: null,
new: boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }),
},
linePlan: {
action: "insert",
section: sectionKind,
insertAfterLine,
newLineNumber: insertAfterLine + 1,
},
sectionPlan: {
kind: sectionKind,
heading: target.heading,
headerLine: target.headerLine,
rowCount: target.rows.length,
},
validation: {
issueColumnIndex,
githubStatusColumnIndex,
expectedStatus,
actualStatus,
columnCount: rowFile.cells.length,
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function moveBoardBodyRow(body: string, sourceLineNumber: number, insertAfterLine: number, newLine: string): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (sourceLineNumber <= 0 || sourceLineNumber > lines.length) throw new Error(`line ${sourceLineNumber} is outside the board body`);
if (insertAfterLine < 0 || insertAfterLine > lines.length) throw new Error(`insert line ${insertAfterLine} is outside the board body`);
lines.splice(sourceLineNumber - 1, 1);
const adjustedInsertAfterLine = sourceLineNumber <= insertAfterLine ? insertAfterLine - 1 : insertAfterLine;
lines.splice(adjustedInsertAfterLine, 0, newLine);
return lines.join("\n");
}
function boardRowMovePlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
targetKind: BoardMutationSection,
status: BoardGithubStatus | undefined,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row move";
const statusFailure = boardStatusOptionFailure(repo, commandName, targetKind, status);
if (statusFailure !== null) return statusFailure;
const found = findBoardRow(repo, parsed.sections, issueNumber);
if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure;
const sourceConsistency = boardRowStatusConsistency(repo, commandName, found.section, found.row, "source");
if (sourceConsistency.ok === false) return sourceConsistency;
const targetLookup = targetBoardSection(repo, parsed.sections, targetKind, commandName);
if (targetLookup.ok === false) return targetLookup;
const target = targetLookup.section;
const { section: source, row } = found;
if (!sameBoardHeaderShape(source.headers, target.headers)) {
return validationError(commandName, repo, "source and target board tables have different headers; refusing to move a raw row across mismatched columns", {
issueNumber,
source: { section: source.kind, headers: source.headers, headerLine: source.headerLine },
target: { section: target.kind, headers: target.headers, headerLine: target.headerLine },
}) as GitHubCommandFailure;
}
if (row.cells.length !== target.headers.length) {
return validationError(commandName, repo, "source row column count does not match the target board table header", {
issueNumber,
source: { section: source.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw), cells: row.cells.length },
target: { section: target.kind, headers: target.headers, columns: target.headers.length },
}) as GitHubCommandFailure;
}
const columnMap = boardColumnIndexMap(target.headers);
const githubStatusColumnIndex = columnMap.get("githubStatus");
if (githubStatusColumnIndex === undefined) {
return validationError(commandName, repo, "target board table does not contain a GitHub 状态 column required for section migration", {
issueNumber,
targetSection: targetKind,
headers: target.headers,
}) as GitHubCommandFailure;
}
const oldCells = row.cells.slice();
const newCells = oldCells.slice();
const expectedStatus = expectedBoardGithubStatus(targetKind);
if (githubStatusColumnIndex !== undefined) newCells[githubStatusColumnIndex] = status ?? expectedStatus;
const newRow = renderMarkdownTableRow(newCells);
const oldBody = boardIssue.body ?? "";
const insertAfterLine = boardSectionInsertAfterLine(target);
let newBody: string;
try {
newBody = source.kind === target.kind
? replaceBoardBodyLine(oldBody, row.lineNumber, newRow)
: moveBoardBodyRow(oldBody, row.lineNumber, insertAfterLine, newRow);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
sourceLineNumber: row.lineNumber,
insertAfterLine,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
action: source.kind === target.kind ? "move-within-section" : "move",
from: source.kind,
to: target.kind,
sourceLineNumber: row.lineNumber,
insertAfterLine,
oldRow: row.raw,
newRow,
oldCells,
newCells,
row: {
old: boardRowSummary(source, row),
new: boardRowSummary(target, { ...row, section: target.kind, raw: newRow, cells: newCells, lineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1 }),
},
linePlan: {
action: source.kind === target.kind ? "replace" : "move",
sourceLineNumber: row.lineNumber,
insertAfterLine,
newLineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1,
},
sectionPlan: {
from: source.kind,
to: target.kind,
sourceHeading: source.heading,
targetHeading: target.heading,
},
status: {
requested: status ?? null,
targetDefault: expectedStatus,
githubStatusColumnIndex: githubStatusColumnIndex ?? null,
old: oldCells[githubStatusColumnIndex] ?? "",
new: newCells[githubStatusColumnIndex] ?? "",
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
function boardRowDeletePlan(
repo: string,
boardIssue: GitHubIssue,
issueNumber: number,
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
): BoardRowMutationPlanResult | GitHubCommandFailure {
const commandName = "issue board-row delete";
const found = findBoardRow(repo, parsed.sections, issueNumber);
if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure;
const { section, row } = found;
const sourceConsistency = boardRowStatusConsistency(repo, commandName, section, row, "source");
if (sourceConsistency.ok === false) return sourceConsistency;
const oldBody = boardIssue.body ?? "";
let newBody: string;
try {
newBody = removeBoardBodyLine(oldBody, row.lineNumber);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
lineNumber: row.lineNumber,
}) as GitHubCommandFailure;
}
return {
ok: true,
newBody,
plan: {
repo,
boardIssue: boardIssue.number,
issueNumber,
action: "delete",
section: section.kind,
lineNumber: row.lineNumber,
oldRow: row.raw,
oldCells: row.cells,
row: {
old: boardRowSummary(section, row),
new: null,
},
linePlan: {
action: "remove",
section: section.kind,
lineNumber: row.lineNumber,
},
sectionPlan: {
kind: section.kind,
heading: section.heading,
headerLine: section.headerLine,
rowCount: section.rows.length,
},
body: {
oldBodyChars: oldBody.length,
oldBodySha: bodySha(oldBody),
newBodyChars: newBody.length,
newBodySha: bodySha(newBody),
changed: oldBody !== newBody,
},
tableParser: {
reused: "parseBoardTables",
rowValidationWarnings: parsed.warnings.length,
},
request: {
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
body: { bodyChars: newBody.length },
},
},
};
}
async function issueBoardRowMutation(
repo: string,
token: string,
issueNumber: number,
options: GitHubOptions,
commandName: "issue board-row add" | "issue board-row move" | "issue board-row delete" | "issue board-row upsert",
mutationKey: "add" | "move" | "delete" | "upsert",
buildPlan: (boardIssue: GitHubIssue, parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }) => BoardRowMutationPlanResult | GitHubCommandFailure,
): Promise<GitHubCommandResult> {
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo };
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber });
const parsed = parseBoardTables(boardIssue.body ?? "");
const planned = buildPlan(boardIssue, parsed);
if (planned.ok === false) return planned;
const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options);
const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options);
const hasConcurrencyGuard = options.expectBodySha !== undefined || options.expectUpdatedAt !== undefined;
const effectiveDryRun = options.dryRun || !hasConcurrencyGuard;
const base = {
ok: true,
command: commandName,
repo,
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
issueNumber,
operation: typeof planned.plan.operation === "string" ? planned.plan.operation : mutationKey,
dryRun: effectiveDryRun,
planned: true,
[mutationKey]: planned.plan,
guard,
bodyOnlySafety: {
oldBody: {
fetched: true,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
newBody: {
bodyChars: planned.newBody.length,
bodySha: bodySha(planned.newBody),
...bodySafetySignals(planned.newBody),
},
},
concurrency: {
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
note: `non-dry-run board-row ${mutationKey} requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH`,
},
};
if (guardError !== null) return { ...guardError, command: commandName, [mutationKey]: planned.plan, guard };
if (effectiveDryRun) {
return {
...base,
dryRun: true,
wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length },
note: !hasConcurrencyGuard
? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified."
: "Dry-run only; no GitHub issue body was modified.",
};
}
const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options);
if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName };
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody });
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, [mutationKey]: planned.plan });
return {
...base,
dryRun: false,
planned: false,
issue: issueSummary(issue),
concurrency: {
checked: true,
oldIssueUpdatedAt: boardIssue.updated_at ?? null,
oldBodySha: bodySha(boardIssue.body ?? ""),
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
},
rest: true,
};
}
async function issueBoardRowList(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row list";
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue });
const parsed = parseBoardTables(boardIssue.body ?? "");
const rows = boardRowsForState(parsed.sections, options.listState);
return {
ok: true,
command: commandName,
repo,
dryRun: true,
readOnly: true,
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
state: options.listState,
count: rows.length,
rows: rows.map(({ section, row }) => boardRowSummary(section, row)),
sections: parsed.sections.map((section) => ({ kind: section.kind, heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine, headers: section.headers, rows: section.rows.length })),
rowValidationWarnings: parsed.warnings,
note: "Read-only board row list; no issue body was edited.",
};
}
async function issueBoardRowGet(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row get";
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber });
const parsed = parseBoardTables(boardIssue.body ?? "");
const found = findBoardRow(repo, parsed.sections, issueNumber);
if (found.ok === false) return { ...found, command: commandName, repo, boardIssue: options.boardIssue };
return {
ok: true,
command: commandName,
repo,
dryRun: true,
readOnly: true,
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
issueNumber,
row: boardRowSummary(found.section, found.row),
rowValidationWarnings: parsed.warnings.filter((warning) => warning.issueNumber === issueNumber),
note: "Read-only board row get; no issue body was edited.",
};
}
async function issueBoardRowUpdate(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row update";
if (options.boardRowField === undefined) return validationError(commandName, repo, "issue board-row update requires --field progress|status|validation|branch|tasks|focus", { supportedFields: BOARD_ROW_FIELDS.slice() });
if (options.boardRowValue === undefined) return validationError(commandName, repo, "issue board-row update requires --value <text>", { issueNumber, field: options.boardRowField });
const valuePollutionEvidence = boardRowValuePollutionEvidence(options.boardRowValue);
if (valuePollutionEvidence.length > 0) {
return validationError(commandName, repo, "issue board-row update --value contains literal shell escape text that would pollute the board cell; pass real newlines or plain text instead", {
issueNumber,
field: options.boardRowField,
valuePreview: preview(options.boardRowValue),
shellPollution: { suspected: true, evidence: valuePollutionEvidence },
});
}
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo };
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber });
const parsed = parseBoardTables(boardIssue.body ?? "");
const planned = boardRowUpdatePlan(repo, boardIssue, issueNumber, options.boardRowField, options.boardRowValue, parsed);
if (planned.ok === false) return planned;
const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options);
const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options);
const effectiveDryRun = options.dryRun || (options.expectBodySha === undefined && options.expectUpdatedAt === undefined);
const base = {
ok: true,
command: commandName,
repo,
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
issueNumber,
dryRun: effectiveDryRun,
planned: true,
update: planned.plan,
guard,
bodyOnlySafety: {
oldBody: {
fetched: true,
bodyChars: (boardIssue.body ?? "").length,
bodySha: bodySha(boardIssue.body ?? ""),
updatedAt: boardIssue.updated_at ?? null,
},
newBody: {
bodyChars: planned.newBody.length,
bodySha: bodySha(planned.newBody),
...bodySafetySignals(planned.newBody),
},
},
concurrency: {
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
note: "non-dry-run board-row update requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH",
},
};
if (guardError !== null) return { ...guardError, command: commandName, update: planned.plan, guard };
if (effectiveDryRun) {
return {
...base,
dryRun: true,
wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length },
note: options.expectBodySha === undefined && options.expectUpdatedAt === undefined
? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified."
: "Dry-run only; no GitHub issue body was modified.",
};
}
const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options);
if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName };
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody });
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, update: planned.plan });
return {
...base,
dryRun: false,
planned: false,
issue: issueSummary(issue),
concurrency: {
checked: true,
oldIssueUpdatedAt: boardIssue.updated_at ?? null,
oldBodySha: bodySha(boardIssue.body ?? ""),
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
},
rest: true,
};
}
async function issueBoardRowAdd(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row add";
if (options.boardSection === undefined) return validationError(commandName, repo, "issue board-row add requires --section open|closed", { issueNumber, supportedSections: BOARD_MUTATION_SECTIONS.slice() });
let rowFile: { path: string; raw: string; cells: string[] };
try {
rowFile = readBoardRowFile(options.boardRowFile, commandName);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
issueNumber,
rowFile: options.boardRowFile ?? null,
});
}
return issueBoardRowMutation(
repo,
token,
issueNumber,
options,
commandName,
"add",
(boardIssue, parsed) => boardRowAddPlan(repo, boardIssue, issueNumber, options.boardSection as BoardMutationSection, rowFile, parsed),
);
}
async function issueBoardRowUpsert(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row upsert";
return issueBoardRowMutation(
repo,
token,
issueNumber,
options,
commandName,
"upsert",
(boardIssue, parsed) => boardRowUpsertPlan(repo, boardIssue, issueNumber, options, parsed),
);
}
async function issueBoardRowMove(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row move";
if (options.boardMoveTo === undefined) return validationError(commandName, repo, "issue board-row move requires --to open|closed", { issueNumber, supportedTargets: BOARD_MUTATION_SECTIONS.slice() });
return issueBoardRowMutation(
repo,
token,
issueNumber,
options,
commandName,
"move",
(boardIssue, parsed) => boardRowMovePlan(repo, boardIssue, issueNumber, options.boardMoveTo as BoardMutationSection, options.boardGithubStatus, parsed),
);
}
async function issueBoardRowDelete(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-row delete";
return issueBoardRowMutation(
repo,
token,
issueNumber,
options,
commandName,
"delete",
(boardIssue, parsed) => boardRowDeletePlan(repo, boardIssue, issueNumber, parsed),
);
}
function ignoredIssueList(issues: BoardIssueEntry[], ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] {
return sortedIssueEntries(issues.filter((issue) => ignoreMap.has(issue.number))).map((issue) => ({
number: issue.number,
reason: ignoreMap.get(issue.number) ?? "ignored",
title: issue.title,
state: issue.state,
url: issue.url,
}));
}
function ignoredBoardOnlyIssues(issueNumbers: number[], issueMap: Map<number, BoardIssueEntry>, ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] {
return issueNumbers
.filter((issueNumber) => ignoreMap.has(issueNumber) && !issueMap.has(issueNumber))
.sort((a, b) => a - b)
.map((issueNumber) => ({ number: issueNumber, reason: ignoreMap.get(issueNumber) ?? "ignored" }));
}
function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null {
if (requested !== "auto") return requested;
if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board";
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return "commander-brief";
return null;
}
function firstMarkdownLine(body: string | null | undefined): string {
return normalizeNewlines(body ?? "").split("\n").find((line) => line.trim().length > 0)?.trim() ?? "";
}
function isDailyCommanderBriefTitle(title: string | null | undefined): boolean {
return DAILY_COMMANDER_BRIEF_TITLE_PATTERN.test((title ?? "").trim());
}
function isDailyCommanderBriefBodyHeading(line: string): boolean {
return DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim());
}
function isLegacyCommanderBriefBodyHeading(line: string): boolean {
return LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim());
}
function currentBodyLooksLikeCommanderBrief(body: string | null | undefined): boolean {
const normalized = normalizeNewlines(body ?? "");
const firstLine = firstMarkdownLine(normalized);
if (isDailyCommanderBriefBodyHeading(firstLine)) return true;
return isLegacyCommanderBriefBodyHeading(firstLine)
&& normalized.includes("## 常驻观察与长期建议")
&& extractTimelineSections(normalized).length > 0;
}
function commanderBriefIssueProfileMatch(issueNumber: number, context: IssueProfileValidationContext): { ok: boolean; reason: string } {
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return { ok: true, reason: "legacy-issue-number" };
if (isDailyCommanderBriefTitle(context.issueTitle)) return { ok: true, reason: "daily-title" };
if (currentBodyLooksLikeCommanderBrief(context.issueBody)) return { ok: true, reason: "current-body-heading" };
return {
ok: false,
reason: context.issueMetadataFetched === true ? "not-commander-brief" : "issue-metadata-unavailable",
};
}
function issueProfileNeedsMetadata(issueNumber: number, requested: IssueBodyProfileOption): boolean {
return requested === "commander-brief" && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE;
}
function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption, context: IssueProfileValidationContext = {}): Record<string, unknown> {
const profileName = issueProfileFor(issueNumber, requested);
if (profileName === null) return { profile: null, applied: false, requiredHeadings: [], missingHeadings: [], ok: true };
const profile = ISSUE_BODY_PROFILES[profileName];
const match = profileName === "commander-brief"
? commanderBriefIssueProfileMatch(issueNumber, context)
: { ok: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber, reason: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber ? "issue-number" : "wrong-issue-number" };
const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading));
return {
profile: profileName,
label: profile.label,
applied: true,
...(profileName === "code-queue-board"
? { expectedIssueNumber: ISSUE_BODY_PROFILES["code-queue-board"].issueNumber }
: { legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE }),
issueMatchesProfile: match.ok,
issueMatchReason: match.reason,
issueMetadataFetched: context.issueMetadataFetched === true,
issueTitle: context.issueTitle ?? null,
requiredHeadings: profile.requiredHeadings,
missingHeadings,
ok: match.ok && missingHeadings.length === 0,
};
}
function validationBoolean(record: Record<string, unknown>, key: string): boolean {
return record[key] === true;
}
function validationStringArray(record: Record<string, unknown>, key: string): string[] {
const value = record[key];
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): GitHubCommandResult | null {
const trimmed = body.trim();
const isLiteralNull = trimmed.toLowerCase() === "null";
const isBlank = trimmed.length === 0;
const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS;
const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
const 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, profileContext: IssueProfileValidationContext = {}): Record<string, unknown> {
const trimmed = body.trim();
const profile = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
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<string, unknown>;
if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected");
return {
ok: trimmed.length > 0
&& trimmed.toLowerCase() !== "null"
&& (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody)
&& profile.ok === true,
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
allowShortBody: options.allowShortBody,
warnings,
profile,
...signals,
};
}
function assertConcurrencyOptions(options: GitHubOptions, commandName: string): GitHubCommandResult | null {
if (options.expectBodySha === undefined) return null;
try {
normalizeExpectedSha(options.expectBodySha);
return null;
} catch (error) {
return validationError(commandName, options.repo, error instanceof Error ? error.message : String(error), {
expectBodySha: options.expectBodySha,
});
}
}
function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions): GitHubCommandResult | null {
const checks: Record<string, unknown> = {
issueNumber,
expectUpdatedAt: options.expectUpdatedAt ?? null,
actualUpdatedAt: oldIssue.updated_at ?? null,
expectBodySha: options.expectBodySha ?? null,
actualBodySha: bodySha(oldIssue.body ?? ""),
};
if (options.expectUpdatedAt !== undefined && oldIssue.updated_at !== options.expectUpdatedAt) {
return validationError("issue edit", repo, "--expect-updated-at did not match current GitHub issue updated_at; refusing stale body overwrite", checks);
}
if (options.expectBodySha !== undefined) {
const expected = normalizeExpectedSha(options.expectBodySha);
if (bodySha(oldIssue.body ?? "") !== expected) {
return validationError("issue edit", repo, "--expect-body-sha did not match current GitHub issue body; refusing stale body overwrite", checks);
}
}
return null;
}
function commentSummary(comment: GitHubComment): Record<string, unknown> {
return {
id: comment.id,
body: comment.body ?? "",
url: comment.html_url,
author: comment.user?.login ?? null,
createdAt: comment.created_at ?? null,
updatedAt: comment.updated_at ?? null,
};
}
function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
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<string, unknown> {
const summary = prSummary(pr) as Record<PrJsonField, unknown>;
const selected: Record<string, unknown> = {};
for (const field of fields) selected[field] = summary[field];
return selected;
}
function repoSummary(repo: GitHubRepository): Record<string, unknown> {
return {
id: repo.id ?? null,
fullName: repo.full_name ?? null,
private: repo.private ?? null,
defaultBranch: repo.default_branch ?? null,
permissions: repo.permissions ?? null,
};
}
function branchSummary(branch: GitHubBranch): Record<string, unknown> {
return {
name: branch.name ?? null,
sha: branch.commit?.sha ?? null,
};
}
function compareSummary(compare: GitHubCompare): Record<string, unknown> {
return {
status: compare.status ?? null,
aheadBy: compare.ahead_by ?? null,
behindBy: compare.behind_by ?? null,
totalCommits: compare.total_commits ?? null,
htmlUrl: compare.html_url ?? null,
baseSha: compare.base_commit?.sha ?? null,
mergeBaseSha: compare.merge_base_commit?.sha ?? null,
};
}
function encodePathPart(value: string): string {
return encodeURIComponent(value);
}
async function repoInfo(token: string, repo: string): Promise<GitHubRepository | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`);
}
async function branchInfo(token: string, repo: string, branch: string): Promise<GitHubBranch | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubBranch>(token, "GET", `/repos/${owner}/${name}/branches/${encodePathPart(branch)}`);
}
async function compareBranches(token: string, repo: string, base: string, head: string): Promise<GitHubCompare | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubCompare>(token, "GET", `/repos/${owner}/${name}/compare/${encodePathPart(base)}...${encodePathPart(head)}`);
}
function prCreatePlannedOperation(repo: string, options: GitHubOptions, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
return {
repo,
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
bodyChars: body.length,
bodyPreview: preview(body),
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
bodySource,
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
request: {
method: "POST",
path: "/repos/{owner}/{repo}/pulls",
body: {
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
bodyChars: body.length,
},
},
validation: {
repo: "required",
base: "required",
head: "required",
title: "required",
body: body.length > 0 ? "optional" : "empty",
},
};
}
function prCommentPlannedOperation(repo: string, issueNumber: number, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
return {
repo,
issueNumber,
bodyChars: body.length,
bodyPreview: preview(body),
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
bodySource,
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
request: {
method: "POST",
path: `/repos/{owner}/{repo}/issues/${issueNumber}/comments`,
body: { bodyChars: body.length },
},
validation: {
repo: "required",
issueNumber: "required",
body: "required",
},
};
}
function bodyUpdatePlan(command: string, repo: string, number: number, mode: BodyUpdateMode, body: string, bodySource: Record<string, unknown>, existingBody: string | null): Record<string, unknown> {
const finalBody = mode === "append" ? `${existingBody ?? ""}${body}` : body;
return {
command,
repo,
number,
mode,
bodySource,
input: bodySafetySignals(body),
existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) },
finalBody: {
bodyChars: finalBody.length,
bodyPreview: preview(finalBody),
bodyPreviewLines: previewLines(finalBody),
...bodySafetySignals(finalBody),
},
};
}
async function prCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title <title>");
if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>");
if (options.head === undefined) return validationError("pr create", repo, "pr create requires --head <branch>");
let bodySource: { body: string; bodySource: Record<string, unknown> };
try {
bodySource = readMarkdownBody(options, "pr create");
} catch (error) {
return validationError("pr create", repo, error instanceof Error ? error.message : String(error));
}
const body = bodySource.body;
const planned = prCreatePlannedOperation(repo, options, body, bodySource.bodySource);
if (options.dryRun) {
return {
ok: true,
command: "pr create",
repo,
dryRun: true,
planned: true,
draft: options.draft,
...planned,
};
}
const repoResult = await repoInfo(token, repo);
if (isGitHubError(repoResult)) return commandError("pr create", repo, repoResult, { planned });
const baseBranch = await branchInfo(token, repo, options.base);
if (isGitHubError(baseBranch)) return commandError("pr create", repo, baseBranch, { base: options.base, planned });
const headBranch = await branchInfo(token, repo, options.head);
if (isGitHubError(headBranch)) return commandError("pr create", repo, headBranch, { head: options.head, planned });
const compare = await compareBranches(token, repo, options.base, options.head);
if (isGitHubError(compare)) return commandError("pr create", repo, compare, { base: options.base, head: options.head, planned });
const aheadBy = typeof compare.ahead_by === "number" ? compare.ahead_by : null;
if (aheadBy === null || aheadBy <= 0) {
return validationError("pr create", repo, "head branch must be ahead of base branch before creating a PR", {
base: options.base,
head: options.head,
compare: compareSummary(compare),
});
}
const { owner, name } = repoParts(repo);
const payload: Record<string, unknown> = {
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
body,
};
const pr = await githubRequest<GitHubPullRequest>(token, "POST", `/repos/${owner}/${name}/pulls`, payload);
if (isGitHubError(pr)) return commandError("pr create", repo, pr, { base: options.base, head: options.head, planned });
return {
ok: true,
command: "pr create",
repo,
pr: prSummary(pr),
validation: {
repo: repoSummary(repoResult),
base: branchSummary(baseBranch),
head: branchSummary(headBranch),
compare: compareSummary(compare),
bodySource: bodySource.bodySource,
},
request: {
method: "POST",
path: `/repos/${owner}/${name}/pulls`,
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
bodyChars: body.length,
},
rest: true,
};
}
async function prComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
let bodySource: { body: string; bodySource: Record<string, unknown> };
try {
bodySource = readMarkdownBody(options, "pr comment");
} catch (error) {
return validationError("pr comment", repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const body = bodySource.body;
const planned = prCommentPlannedOperation(repo, issueNumber, body, bodySource.bodySource);
if (options.dryRun) {
return {
ok: true,
command: "pr comment",
repo,
dryRun: true,
planned: true,
issueNumber,
...planned,
};
}
const repoResult = await repoInfo(token, repo);
if (isGitHubError(repoResult)) return commandError("pr comment", repo, repoResult, { issueNumber, planned });
const { owner, name } = repoParts(repo);
const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`);
if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned });
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned });
return {
ok: true,
command: "pr comment create",
repo,
issueNumber,
pr: prSummary(prResult),
comment: commentSummary(comment),
request: {
method: "POST",
path: `/repos/${owner}/${name}/issues/${issueNumber}/comments`,
bodyChars: body.length,
},
validation: {
repo: repoSummary(repoResult),
pr: prSummary(prResult),
bodySource: bodySource.bodySource,
},
rest: true,
};
}
async function prUpdate(repo: string, token: string, number: number, options: GitHubOptions): 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 issueRead(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, commandName = "issue read"): Promise<GitHubCommandResult> {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
const needsComments = jsonFields === undefined || jsonFields.includes("comments");
const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null;
if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue) });
return {
ok: true,
command: commandName,
repo,
issue: issueSummary(issue),
...(comments === null ? {} : { comments: comments.map(commentSummary) }),
...(jsonFields === undefined ? {} : {
jsonFields,
json: selectedIssueJson(issue, comments, jsonFields),
compatibility: {
legacyJsonBodyPath: ".data.issue.body",
},
}),
};
}
async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined): Promise<GitHubCommandResult> {
return issueRead(repo, token, issueNumber, jsonFields, "issue view");
}
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, allListedIssues);
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,
briefIndexManagedIssuePattern: options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE ? "YYYY-MM-DD 指挥简报(北京时间)" : null,
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);
const needsProfileMetadata = issueProfileNeedsMetadata(issueNumber, options.bodyProfile);
if (options.mode === "replace" && !needsProfileMetadata) {
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
if (bodyGuard !== null) return bodyGuard;
}
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
if (concurrencyOptionError !== null) return concurrencyOptionError;
let oldIssue: GitHubIssue | null = null;
let briefDiff: CommanderBriefDiff | null = null;
let profileContext: IssueProfileValidationContext = {};
const claudeQqConfig = commanderBriefClaudeQqConfig();
if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) {
return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber });
}
const needsReadBeforeEdit = options.mode === "append"
|| needsProfileMetadata && token.length > 0
|| !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined);
if (needsReadBeforeEdit) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" });
oldIssue = issue;
profileContext = { issueTitle: issue.title, issueBody: issue.body, issueMetadataFetched: true };
const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options);
if (concurrencyError !== null) return concurrencyError;
if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body);
}
const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body;
if (options.mode === "append" || needsProfileMetadata) {
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options, profileContext);
if (bodyGuard !== null) return bodyGuard;
}
if (options.dryRun) {
const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null;
const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext);
let dryRunOldBody: Record<string, unknown> = {
fetched: false,
bodyChars: null,
bodySha: null,
updatedAt: null,
reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run",
};
if (oldIssue !== null) {
const oldBody = oldIssue.body ?? "";
dryRunOldBody = {
fetched: true,
bodyChars: oldBody.length,
bodySha: bodySha(oldBody),
updatedAt: oldIssue.updated_at ?? null,
};
} else if (token.length > 0) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) {
dryRunOldBody = {
fetched: false,
bodyChars: null,
bodySha: null,
updatedAt: null,
readError: {
degradedReason: issue.degradedReason,
runnerDisposition: issue.runnerDisposition,
message: issue.message,
status: issue.status ?? null,
},
};
} else {
const oldBody = issue.body ?? "";
dryRunOldBody = {
fetched: true,
bodyChars: oldBody.length,
bodySha: bodySha(oldBody),
updatedAt: issue.updated_at ?? null,
};
}
}
return {
ok: true,
command: commandName,
repo,
dryRun: true,
issueNumber,
mode: options.mode,
...dryRunBody(repo, options.title, finalBody),
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, profileContext);
const concurrency = oldIssue === null
? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }
: { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null };
if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: commandName, repo, issue: issueSummary(issue), 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 prRead(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined, commandName = "pr read"): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
return {
ok: true,
command: commandName,
repo,
pullRequest: prSummary(pr),
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(pr, jsonFields) }),
};
}
async function prView(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> {
return prRead(repo, token, number, jsonFields, "pr view");
}
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 read <number> [--repo owner/name] [--json body,title,state,comments]",
"bun scripts/cli.ts gh issue view <number> [--repo owner/name] [compatibility alias for issue read]",
"bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]",
"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 issue board-row list [--repo owner/name] --board-issue 20 [--state open|closed|all] [--dry-run]",
"bun scripts/cli.ts gh issue board-row get <issueNumber> [--repo owner/name] --board-issue 20",
"bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row add <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed --row-file <markdown-row-file> [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
"bun scripts/cli.ts gh pr read <number> [--repo owner/name] [--json body,title,state,head,base,draft]",
"bun scripts/cli.ts gh pr view <number> [--repo owner/name] [compatibility alias for pr read]",
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title title] [--repo owner/name] [--dry-run]",
"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 read is the canonical read path; view remains a compatibility alias. Read 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 requires its board heading, and commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).",
"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. Daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间) are managed by #20's brief index and appear in ignoredIssues with reason=brief-index-managed instead of missingOpenIssues.",
"issue board-row list/get reuse the board-audit table parser and are read-only. board-row update changes one table cell by issue number, returns old/new row, body SHA, body guard and request plan, and defaults to dry-run unless --expect-updated-at or --expect-body-sha is supplied for the guarded PATCH. Field aliases map status and validation to the 验收状态 column, tasks to 相关 Code Queue 任务, and focus to 当前关注点.",
"issue board-row upsert updates an existing row when the issue is already present, or generates a complete row in --section open|closed when missing. It returns operation=update or operation=add, defaults to dry-run, requires --expect-body-sha or --expect-updated-at before PATCH, and refuses section migration; use board-row move for OPEN/CLOSED migration.",
"issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation.",
"issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"PR read is the canonical read path; view remains a compatibility alias. PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
],
};
}
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" || sub === "board-row" && third === "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 and gh issue board-row list");
}
if (optionWasProvided(args, "--json") && !(top === "issue" && (isIssueReadCommand(sub) || sub === "list"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
if (!(top === "pr" && (isPrReadCommand(sub) || sub === "list"))) {
return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list");
}
}
if (optionWasProvided(args, "--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" || sub === "board-row" && ["update", "add", "move", "delete", "upsert"].includes(third ?? "")))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue update/edit and gh issue board-row update/add/move/delete/upsert");
}
if ((optionWasProvided(args, "--board-issue") || optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && (sub === "board-audit" || sub === "board-row"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--board-issue, --known-meta-issue, and --ignore-issue are only supported by gh issue board-audit and --board-issue by gh issue board-row");
}
if ((optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && sub === "board-audit")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--known-meta-issue and --ignore-issue are only supported by gh issue board-audit");
}
if ((optionWasProvided(args, "--field") || optionWasProvided(args, "--value")) && !(top === "issue" && sub === "board-row" && third === "update")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--field and --value are only supported by gh issue board-row update");
}
if (optionWasProvided(args, "--row-file") && !(top === "issue" && sub === "board-row" && third === "add")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--row-file is only supported by gh issue board-row add");
}
if (optionWasProvided(args, "--section") && !(top === "issue" && sub === "board-row" && (third === "add" || third === "upsert"))) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--section is only supported by gh issue board-row add/upsert");
}
if (optionWasProvided(args, "--to") && !(top === "issue" && sub === "board-row" && third === "move")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--to is only supported by gh issue board-row move");
}
if (optionWasProvided(args, "--status") && !(top === "issue" && sub === "board-row" && (third === "move" || third === "upsert"))) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--status is only supported by gh issue board-row move/upsert");
}
if ((optionWasProvided(args, "--category") || optionWasProvided(args, "--branch") || optionWasProvided(args, "--tasks") || optionWasProvided(args, "--summary") || optionWasProvided(args, "--focus") || optionWasProvided(args, "--validation") || optionWasProvided(args, "--progress")) && !(top === "issue" && sub === "board-row" && third === "upsert")) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--category, --branch, --tasks, --summary, --focus, --validation, and --progress are only supported by gh issue board-row upsert");
}
if (optionWasProvided(args, "--label") && !(top === "issue" && sub === "create")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--label is only supported by gh issue create");
}
if (top === "auth" && sub === "status") return authStatus(options.repo);
if (top === "issue") {
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
if (sub === "comment" && third === "delete") {
const commentId = parseNumberForCommand(options.repo, args[3], "issue comment delete");
if (typeof commentId !== "number") return commentId;
if (options.dryRun) return commentDelete(options.repo, "", "issue", commentId, true);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "issue comment delete", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true });
return commentDelete(options.repo, token, "issue", commentId, false);
}
if (sub === "comment" && third === "create") {
const issueNumber = parseNumberForCommand(options.repo, args[3], "issue comment create");
if (typeof issueNumber !== "number") return issueNumber;
if (options.dryRun) return issueComment(options.repo, "", issueNumber, options);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "issue comment create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true });
return issueComment(options.repo, token, issueNumber, options);
}
if (sub === "scan-escape" || sub === "cleanup-plan") {
const { token, probe } = resolveToken(true);
const commandName = sub === "cleanup-plan" ? "issue cleanup-plan" : "issue scan-escape";
const missing = authRequired(options.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return issueScanEscape(options.repo, token, options.limit, options.dryRun || sub === "cleanup-plan", commandName);
}
if (sub === "board-audit") {
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "issue board-audit", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue board-audit", { present: false, source: null, ghFallbackAttempted: true });
return issueBoardAudit(options.repo, token, options);
}
if (sub === "board-row") {
const action = third;
const commandName = `issue board-row ${action ?? ""}`.trim();
if (action !== "list" && action !== "get" && action !== "update" && action !== "add" && action !== "move" && action !== "delete" && action !== "upsert") {
return unsupportedCommand(commandName, options.repo, "board-row supported commands are list, get, update, add, move, delete, and upsert.");
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
if (action === "list") return issueBoardRowList(options.repo, token, options);
const issueNumber = parseNumberForCommand(options.repo, args[3], commandName);
if (typeof issueNumber !== "number") return issueNumber;
if (action === "get") return issueBoardRowGet(options.repo, token, issueNumber, options);
if (action === "update") return issueBoardRowUpdate(options.repo, token, issueNumber, options);
if (action === "add") return issueBoardRowAdd(options.repo, token, issueNumber, options);
if (action === "upsert") return issueBoardRowUpsert(options.repo, token, issueNumber, options);
if (action === "move") return issueBoardRowMove(options.repo, token, issueNumber, options);
return issueBoardRowDelete(options.repo, token, issueNumber, options);
}
if (options.dryRun) {
if (sub === "create") return issueCreate(options.repo, "", options);
if (sub === "edit") {
const issueNumber = parseNumber(third, "issue edit");
const { token } = resolveToken(false);
return issueEdit(options.repo, token ?? "", issueNumber, options);
}
if (sub === "update") {
const issueNumber = parseNumber(third, "issue update");
const { token } = resolveToken(false);
return issueEdit(options.repo, token ?? "", issueNumber, options, "issue update");
}
if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options);
if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true);
if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true);
}
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 === "read") return issueRead(options.repo, token, parseNumber(third, "issue read"), options.jsonFields);
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 (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" && !isPrReadCommand(sub)) {
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, read/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);
if (sub === "read") return prRead(options.repo, token, parseNumber(third, "pr read"), options.prJsonFields);
return prView(options.repo, token, parseNumber(third, "pr view"), options.prJsonFields);
}
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });
}