Files
pikasTech-unidesk/scripts/src/gh.ts
T
2026-05-20 22:08:23 +00:00

3125 lines
130 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 ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const PR_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_STATES = ["open", "closed", "all"] as const;
const BODY_UPDATE_MODES = ["replace", "append"] as const;
const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
const ISSUE_SCAN_MAX_FINDINGS = 60;
const ISSUE_BODY_PROFILES = {
"code-queue-board": {
label: "Code Queue long board issue #20",
issueNumber: CODE_QUEUE_BOARD_TARGET_ISSUE,
requiredHeadings: ["## 看板(OPEN"],
},
"commander-brief": {
label: "Commander brief body-only issue #24",
issueNumber: COMMANDER_BRIEF_TARGET_ISSUE,
requiredHeadings: ["## 常驻观察与长期建议"],
},
} as const;
type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number];
type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number];
type PrJsonField = typeof PR_JSON_FIELDS[number];
type IssueListState = typeof ISSUE_LIST_STATES[number];
type BodyUpdateMode = typeof BODY_UPDATE_MODES[number];
type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES;
type IssueBodyProfileOption = "auto" | IssueBodyProfileName;
type EscapeFindingClassification = "suspected-pollution" | "explanatory-mention" | "risk";
type EscapeFindingSeverity = "low" | "medium" | "high";
type EscapeBodyKind = "issue-body" | "comment-body";
type CleanupSuggestionAction = "rewrite-issue-body-with-body-file" | "review-comment-manually" | "review-body-length" | "no-cleanup-needed";
type BoardSectionKind = "open" | "closed";
type BoardRequiredColumn = typeof BOARD_AUDIT_REQUIRED_COLUMNS[number];
type GitHubDegradedReason =
| "missing-binary"
| "missing-token"
| "auth-failed"
| "egress-failed"
| "network-proxy-failed"
| "permission-denied"
| "repo-not-found"
| "repo-forbidden"
| "issue-not-found"
| "pr-not-found"
| "scope-insufficient"
| "validation-failed"
| "invalid-response"
| "unsupported-command";
type RunnerDisposition = "infra-blocked" | "business-failed";
type ClaudeQqTargetType = "private" | "group";
type CommanderBriefDiffMode = "identical" | "append-only" | "heading-diff" | "unreliable";
interface CommanderBriefDiff {
ok: boolean;
mode: CommanderBriefDiffMode;
message: string;
chars: number;
sections: string[];
sectionCount: number;
skippedReason?: string;
}
interface ClaudeQqConfig {
enabled: boolean;
baseUrl: string;
targetType: ClaudeQqTargetType;
userId?: string;
groupId?: string;
timeoutMs: number;
}
interface ClaudeQqSendResult {
ok: boolean;
attempted: boolean;
skipped?: boolean;
skippedReason?: string;
endpoint?: string;
status?: number;
degradedReason?: string;
message?: string;
response?: unknown;
target: Record<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: "known-meta" | "ignored";
title?: string;
state?: string;
url?: string;
}
interface BoardRowValidationWarning {
issueNumber: number | null;
section: BoardSectionKind;
lineNumber: number;
kind: string;
message: string;
missingColumns?: BoardRequiredColumn[];
columns?: Partial<Record<BoardRequiredColumn, string>>;
rowPreview: string;
}
interface GitHubCommandResult {
ok: boolean;
repo: string;
command: string;
degradedReason?: GitHubDegradedReason;
degraded?: GitHubDegradedReason[];
runnerDisposition?: RunnerDisposition;
[key: string]: unknown;
}
interface GitHubTokenProbe {
present: boolean;
source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null;
ghFallbackAttempted: boolean;
ghBinaryFound?: boolean;
ghAuthTokenAvailable?: boolean | null;
}
interface GitHubOptions {
repo: string;
dryRun: boolean;
limit: number;
boardIssue: number;
knownMetaIssues: number[];
ignoredIssues: number[];
draft: boolean;
notifyClaudeQqBriefDiff: boolean;
allowShortBody: boolean;
labels: string[];
title?: string;
body?: string;
bodyFile?: string;
base?: string;
head?: string;
jsonFields?: IssueViewJsonField[];
issueListJsonFields?: IssueListJsonField[];
prJsonFields?: PrJsonField[];
listState: IssueListState;
mode: BodyUpdateMode;
expectUpdatedAt?: string;
expectBodySha?: string;
bodyProfile: IssueBodyProfileOption;
}
interface GitHubErrorPayload {
ok: false;
degradedReason: GitHubDegradedReason;
runnerDisposition: RunnerDisposition;
status?: number;
message: string;
details?: unknown;
scopes?: {
accepted: string | null;
token: string | null;
};
request?: {
method: string;
path: string;
};
}
interface GitHubIssue {
id: number;
number: number;
title: string;
body: string | null;
state: string;
html_url: string;
comments: number;
user?: { login?: string };
labels?: Array<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 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 view", requested, ISSUE_VIEW_JSON_FIELDS);
}
function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined {
return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS);
}
function parsePrJsonFields(command: string, requested: string[] | undefined): PrJsonField[] | undefined {
return validateJsonFields(command, requested, PR_JSON_FIELDS);
}
function parseIssueListState(args: string[]): IssueListState {
const raw = optionValue(args, "--state") ?? "open";
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState;
throw new Error(`unsupported gh issue list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`);
}
function parseBodyUpdateMode(args: string[]): BodyUpdateMode {
const raw = optionValue(args, "--mode") ?? "replace";
if ((BODY_UPDATE_MODES as readonly string[]).includes(raw)) return raw as BodyUpdateMode;
throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`);
}
function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
const raw = optionValue(args, "--body-profile") ?? "auto";
if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw;
throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`);
}
function validateKnownOptions(args: string[]): void {
const valueOptions = new Set(["--repo", "--limit", "--board-issue", "--known-meta-issue", "--ignore-issue", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label"]);
const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body"]);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) continue;
if (flagOptions.has(arg)) continue;
if (valueOptions.has(arg)) {
index += 1;
continue;
}
throw new Error(`unknown gh option: ${arg}`);
}
}
function parseOptions(args: string[]): GitHubOptions {
validateKnownOptions(args);
const [top, sub] = args;
const requestedJsonFields = commaListOption(args, "--json");
return {
repo: optionValue(args, "--repo") ?? DEFAULT_REPO,
dryRun: hasFlag(args, "--dry-run"),
limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : 30, 100),
boardIssue: positiveIntegerSingleOption(args, "--board-issue", CODE_QUEUE_BOARD_TARGET_ISSUE),
knownMetaIssues: positiveIntegerValuesOption(args, "--known-meta-issue"),
ignoredIssues: positiveIntegerValuesOption(args, "--ignore-issue"),
draft: hasFlag(args, "--draft"),
notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"),
allowShortBody: hasFlag(args, "--allow-short-body"),
labels: labelsOption(args),
title: optionValue(args, "--title"),
body: optionValue(args, "--body"),
bodyFile: optionValue(args, "--body-file"),
base: optionValue(args, "--base"),
head: optionValue(args, "--head"),
jsonFields: top === "issue" && sub === "view" ? parseIssueViewJsonFields(requestedJsonFields) : undefined,
issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined,
prJsonFields: top === "pr" && (sub === "view" || sub === "list") ? parsePrJsonFields(`gh pr ${sub}`, requestedJsonFields) : undefined,
listState: parseIssueListState(args),
mode: parseBodyUpdateMode(args),
expectUpdatedAt: optionValue(args, "--expect-updated-at"),
expectBodySha: optionValue(args, "--expect-body-sha"),
bodyProfile: parseIssueBodyProfile(args),
};
}
function parseNumber(raw: string | undefined, label: string): number {
if (raw === undefined) throw new Error(`${label} requires a number`);
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`);
return value;
}
function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult {
try {
return parseNumber(raw, label);
} catch (error) {
return validationError(label, repo, error instanceof Error ? error.message : String(error));
}
}
function readBodyFile(path: string | undefined, command: string): string {
if (path === undefined) throw new Error(`${command} requires --body-file <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): Map<number, "known-meta" | "ignored"> {
const ignored = new Map<number, "known-meta" | "ignored">();
for (const issueNumber of mergedKnownMetaIssues(options)) ignored.set(issueNumber, "known-meta");
for (const issueNumber of options.ignoredIssues) ignored.set(issueNumber, "ignored");
return ignored;
}
function stripMarkdownInline(text: string): string {
return text
.replace(/\[[^\]]*]\(([^)]*)\)/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/[*_`~]/g, "")
.trim();
}
function normalizeBoardHeader(header: string): string {
return stripMarkdownInline(header).toLowerCase().replace(/\s+/g, "");
}
function boardHeaderColumnKind(header: string): BoardRequiredColumn | null {
const normalized = normalizeBoardHeader(header);
if (["branch", "分支", "目标分支", "工作分支"].includes(normalized)) return "branch";
if (["acceptance", "验收", "验收状态", "验收结果", "验收标准"].includes(normalized)) return "acceptance";
if (["relatedtask", "task", "codequeue", "codequeuetask", "相关任务", "任务", "codequeue任务", "cq任务", "相关codequeue任务", "相关codequeuetask"].includes(normalized)) return "relatedTask";
if (["progress", "进度", "状态", "当前进度"].includes(normalized)) return "progress";
return null;
}
function boardIssueColumnIndex(headers: string[]): number | null {
for (let index = 0; index < headers.length; index += 1) {
const normalized = normalizeBoardHeader(headers[index]);
if (["issue", "issue#", "issuenumber", "issueid", "issue编号", "issue号", "编号"].includes(normalized)) return index;
}
return null;
}
function markdownCells(line: string): string[] {
const trimmed = line.trim();
const withoutOuterPipes = trimmed.replace(/^\|/u, "").replace(/\|$/u, "");
return withoutOuterPipes.split("|").map((cell) => cell.trim());
}
function isMarkdownTableSeparator(line: string): boolean {
const cells = markdownCells(line);
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/u.test(cell.trim()));
}
function boardSectionKindFromHeading(line: string): BoardSectionKind | null {
const normalized = line.toLowerCase();
if (!line.trimStart().startsWith("#")) return null;
if (normalized.includes("open") || normalized.includes("看板(open") || normalized.includes("看板(open)") || normalized.includes("开放")) return "open";
if (normalized.includes("closed") || normalized.includes("看板(closed") || normalized.includes("看板(closed)") || normalized.includes("关闭") || normalized.includes("已关闭")) return "closed";
return null;
}
function extractIssueNumbers(text: string): number[] {
const numbers: number[] = [];
const seen = new Set<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 normalizedBoardCellPlaceholder(value: string): { spaced: string; compact: string } {
const spaced = stripMarkdownInline(value).toLowerCase().replace(/\s+/g, " ").trim();
return { spaced, compact: spaced.replace(/\s+/g, "") };
}
function isRelatedTaskNoTaskPlaceholder(value: string): boolean {
const normalized = normalizedBoardCellPlaceholder(value);
return [
"-",
"",
"",
"n/a",
"na",
"none",
"no task",
"no-task",
"no code queue task",
"not applicable",
].includes(normalized.spaced) || [
"",
"无任务",
"无关联任务",
"无相关任务",
"codequeue任务",
"codequeuetask",
"不适用",
].includes(normalized.compact);
}
function cellHasMeaningfulValue(value: string | undefined, column: BoardRequiredColumn): boolean {
if (value === undefined) return false;
const stripped = stripMarkdownInline(value);
if (stripped.length === 0) return false;
if (column === "relatedTask" && isRelatedTaskNoTaskPlaceholder(value)) return true;
return !["-", "", "n/a", "na", "todo", "tbd", "待补", "未填", ""].includes(stripped.toLowerCase());
}
function parseBoardTables(body: string): { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] } {
const lines = normalizeNewlines(body).split("\n");
const sections: BoardTableSection[] = [];
const warnings: BoardRowValidationWarning[] = [];
let currentKind: BoardSectionKind | null = null;
let currentHeading = "";
let currentHeadingLine = 0;
for (let index = 0; index < lines.length; index += 1) {
const headingKind = boardSectionKindFromHeading(lines[index]);
if (headingKind !== null) {
currentKind = headingKind;
currentHeading = lines[index].trim();
currentHeadingLine = index + 1;
continue;
}
if (currentKind === null) continue;
if (!lines[index].trim().startsWith("|")) continue;
const separator = lines[index + 1];
if (separator === undefined || !separator.trim().startsWith("|") || !isMarkdownTableSeparator(separator)) continue;
const headers = markdownCells(lines[index]);
const columnMap = new Map<BoardRequiredColumn, number>();
headers.forEach((header, headerIndex) => {
const kind = boardHeaderColumnKind(header);
if (kind !== null && !columnMap.has(kind)) columnMap.set(kind, headerIndex);
});
const issueColumnIndex = boardIssueColumnIndex(headers);
const rows: BoardTableRow[] = [];
let rowIndex = index + 2;
while (rowIndex < lines.length && lines[rowIndex].trim().startsWith("|")) {
if (!isMarkdownTableSeparator(lines[rowIndex])) {
const cells = markdownCells(lines[rowIndex]);
const issueCell = issueColumnIndex === null ? undefined : cells[issueColumnIndex];
const primaryIssueNumber = issueColumnIndex === null ? null : primaryBoardIssueNumberFromIssueCell(issueCell);
const issueNumbers = issueColumnIndex === null
? extractIssueNumbers(lines[rowIndex])
: (primaryIssueNumber === null ? extractIssueNumbers(issueCell ?? "") : [primaryIssueNumber]);
const columns: Partial<Record<BoardRequiredColumn, string>> = {};
for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) {
const cellIndex = columnMap.get(column);
if (cellIndex !== undefined) columns[column] = cells[cellIndex] ?? "";
}
const row: BoardTableRow = {
section: currentKind,
lineNumber: rowIndex + 1,
raw: lines[rowIndex],
cells,
issueNumbers,
issueNumber: issueNumbers[0] ?? null,
title: issueColumnIndex === null
? (cells.find((cell) => extractIssueNumbers(cell).length > 0) ?? cells[0] ?? null)
: (issueCell ?? cells[0] ?? null),
columns,
};
rows.push(row);
if (issueNumbers.length === 0) {
warnings.push({
issueNumber: null,
section: currentKind,
lineNumber: row.lineNumber,
kind: "missing-issue-reference",
message: "Board table row does not contain a GitHub issue reference such as #36.",
rowPreview: preview(row.raw),
});
}
if (issueNumbers.length > 1) {
warnings.push({
issueNumber: row.issueNumber,
section: currentKind,
lineNumber: row.lineNumber,
kind: "multiple-issue-references",
message: issueColumnIndex === null
? "Board table row contains multiple issue references; audit uses the first number as the row key."
: "Board table row contains multiple issue references in the Issue column; audit uses the first number as the row key.",
rowPreview: preview(row.raw),
});
}
const missingColumns = BOARD_AUDIT_REQUIRED_COLUMNS.filter((column) => !cellHasMeaningfulValue(columns[column], column));
if (missingColumns.length > 0) {
warnings.push({
issueNumber: row.issueNumber,
section: currentKind,
lineNumber: row.lineNumber,
kind: "missing-required-columns",
message: "Board table row is missing branch, acceptance, related task, or progress information.",
missingColumns,
columns,
rowPreview: preview(row.raw),
});
}
}
rowIndex += 1;
}
sections.push({
kind: currentKind,
heading: currentHeading,
headingLine: currentHeadingLine,
headerLine: index + 1,
headers,
rows,
});
index = rowIndex - 1;
}
return { sections, warnings };
}
function boardRowsByIssue(rows: BoardTableRow[]): Map<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 ignoredIssueList(issues: BoardIssueEntry[], ignoreMap: Map<number, "known-meta" | "ignored">): 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, "known-meta" | "ignored">): BoardIgnoredIssue[] {
return issueNumbers
.filter((issueNumber) => ignoreMap.has(issueNumber) && !issueMap.has(issueNumber))
.sort((a, b) => a - b)
.map((issueNumber) => ({ number: issueNumber, reason: ignoreMap.get(issueNumber) ?? "ignored" }));
}
function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null {
if (requested !== "auto") return requested;
if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board";
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return "commander-brief";
return null;
}
function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption): Record<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 issueMatchesProfile = issueNumber === profile.issueNumber;
const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading));
return {
profile: profileName,
label: profile.label,
applied: true,
expectedIssueNumber: profile.issueNumber,
issueMatchesProfile,
requiredHeadings: profile.requiredHeadings,
missingHeadings,
ok: issueMatchesProfile && missingHeadings.length === 0,
};
}
function validationBoolean(record: Record<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): GitHubCommandResult | null {
const trimmed = body.trim();
const isLiteralNull = trimmed.toLowerCase() === "null";
const isBlank = trimmed.length === 0;
const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS;
const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile);
const profileOk = validationBoolean(profileValidation, "ok");
const failures: string[] = [];
if (isLiteralNull) failures.push("literal-null-body");
if (isBlank) failures.push("blank-body");
if (isShort && !options.allowShortBody) failures.push("short-body");
if (!profileOk) {
if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch");
const missingHeadings = validationStringArray(profileValidation, "missingHeadings");
if (missingHeadings.length > 0) failures.push("profile-heading-missing");
}
if (failures.length === 0) return null;
const overrideAvailable = failures.every((failure) => failure === "short-body");
const message = overrideAvailable
? "issue edit body is shorter than the safe default; pass --allow-short-body only for intentional short writes"
: "issue edit body failed safety guard; refusing to write possible body-only issue corruption";
return validationError("issue edit", repo, message, {
issueNumber,
guard: {
ok: false,
failures,
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
allowShortBody: options.allowShortBody,
shortBodyOverrideAvailable: overrideAvailable,
bodySource: { kind: "body-file", path: options.bodyFile ?? null },
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
...bodySafetySignals(body),
isLiteralNull,
isBlank,
isShort,
profile: profileValidation,
},
});
}
function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions): Record<string, unknown> {
const trimmed = body.trim();
const profile = issueProfileValidation(issueNumber, body, options.bodyProfile);
const warnings: string[] = [];
if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) {
warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail");
}
if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk");
if (profile.ok === false) warnings.push("profile guard would fail");
const signals = bodySafetySignals(body);
const shellPollution = signals.shellPollution as Record<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): GitHubCommandResult | null {
if (options.expectBodySha === undefined) return null;
try {
normalizeExpectedSha(options.expectBodySha);
return null;
} catch (error) {
return validationError("issue edit", options.repo, error instanceof Error ? error.message : String(error), {
expectBodySha: options.expectBodySha,
});
}
}
function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions): GitHubCommandResult | null {
const checks: Record<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 issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined): Promise<GitHubCommandResult> {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError("issue view", repo, issue, { issueNumber });
const needsComments = jsonFields === undefined || jsonFields.includes("comments");
const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null;
if (isGitHubError(comments)) return commandError("issue view", repo, comments, { issueNumber, issue: issueSummary(issue) });
return {
ok: true,
command: "issue view",
repo,
issue: issueSummary(issue),
...(comments === null ? {} : { comments: comments.map(commentSummary) }),
...(jsonFields === undefined ? {} : {
jsonFields,
json: selectedIssueJson(issue, comments, jsonFields),
compatibility: {
legacyJsonBodyPath: ".data.issue.body",
},
}),
};
}
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined): Promise<GitHubCommandResult> {
const rawIssues = await listIssues(token, repo, state, limit);
if (isGitHubError(rawIssues)) return commandError("issue list", repo, rawIssues, { state, limit });
const issues = rawIssues.filter((issue) => issue.pull_request === undefined).slice(0, limit);
const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice();
return {
ok: true,
command: "issue list",
repo,
state,
limit,
count: issues.length,
rawCount: rawIssues.length,
jsonFields: fields,
issues: issues.map((issue) => issueListSummary(issue, fields)),
request: {
method: "GET",
path: "/repos/{owner}/{repo}/issues",
query: { state, per_page: limit },
},
note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.",
};
}
async function issueBoardAudit(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue board-audit";
const boardIssue = await getIssue(token, repo, options.boardIssue);
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue });
const [rawOpenIssues, rawClosedIssues] = await Promise.all([
listIssues(token, repo, "open", options.limit),
listIssues(token, repo, "closed", options.limit),
]);
if (isGitHubError(rawOpenIssues)) return commandError(commandName, repo, rawOpenIssues, { phase: "list-open-issues", boardIssue: options.boardIssue, limit: options.limit });
if (isGitHubError(rawClosedIssues)) return commandError(commandName, repo, rawClosedIssues, { phase: "list-closed-issues", boardIssue: options.boardIssue, limit: options.limit });
const openIssues = rawOpenIssues.filter((issue) => issue.pull_request === undefined).slice(0, options.limit).map(boardIssueEntry);
const closedIssues = rawClosedIssues.filter((issue) => issue.pull_request === undefined).slice(0, options.limit).map(boardIssueEntry);
const allListedIssues = [...openIssues, ...closedIssues];
const issueMap = new Map<number, BoardIssueEntry>(allListedIssues.map((issue) => [issue.number, issue]));
const ignoreMap = boardIgnoreMap(options);
const parsed = parseBoardTables(boardIssue.body ?? "");
const rowValidationWarnings = parsed.warnings.filter((warning) => warning.issueNumber === null || !ignoreMap.has(warning.issueNumber));
const openRows = parsed.sections.filter((section) => section.kind === "open").flatMap((section) => section.rows);
const closedRows = parsed.sections.filter((section) => section.kind === "closed").flatMap((section) => section.rows);
const openRowMap = boardRowsByIssue(openRows);
const closedRowMap = boardRowsByIssue(closedRows);
const openIssueNumbers = new Set(openIssues.map((issue) => issue.number));
const closedIssueNumbers = new Set(closedIssues.map((issue) => issue.number));
const boardIssueNumbers = Array.from(new Set([...openRowMap.keys(), ...closedRowMap.keys()]));
const missingOpenIssues = sortedIssueEntries(openIssues.filter((issue) => !ignoreMap.has(issue.number) && !openRowMap.has(issue.number)));
const closedInOpenRows = sortedIssueEntries(closedIssues.filter((issue) => !ignoreMap.has(issue.number) && openRowMap.has(issue.number))).map((issue) => ({
...issue,
rows: (openRowMap.get(issue.number) ?? []).map((row) => ({ lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}));
const missingClosedRows = sortedIssueEntries(closedIssues.filter((issue) => !ignoreMap.has(issue.number) && !closedRowMap.has(issue.number)));
const openInClosedRows = sortedIssueEntries(openIssues.filter((issue) => !ignoreMap.has(issue.number) && closedRowMap.has(issue.number))).map((issue) => ({
...issue,
rows: (closedRowMap.get(issue.number) ?? []).map((row) => ({ lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}));
const staleOpenRows = Array.from(openRowMap.entries())
.filter(([issueNumber]) => !ignoreMap.has(issueNumber) && !openIssueNumbers.has(issueNumber))
.map(([issueNumber, rows]) => ({
issueNumber,
knownState: closedIssueNumbers.has(issueNumber) ? "closed" : "not-in-listed-window",
rows: rows.map((row) => ({ lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}))
.sort((a, b) => a.issueNumber - b.issueNumber);
const boardOnlyRows = boardIssueNumbers
.filter((issueNumber) => !ignoreMap.has(issueNumber) && !issueMap.has(issueNumber))
.sort((a, b) => a - b)
.map((issueNumber) => ({
issueNumber,
sections: [
...(openRowMap.has(issueNumber) ? ["open"] : []),
...(closedRowMap.has(issueNumber) ? ["closed"] : []),
],
rows: [...(openRowMap.get(issueNumber) ?? []), ...(closedRowMap.get(issueNumber) ?? [])].map((row) => ({
section: row.section,
lineNumber: row.lineNumber,
rowPreview: preview(row.raw),
})),
}));
const ignoredIssues = [
...ignoredIssueList(allListedIssues, ignoreMap),
...ignoredBoardOnlyIssues(boardIssueNumbers, issueMap, ignoreMap),
].sort((a, b) => a.number - b.number);
const recommendedActions = [
...missingOpenIssues.map((issue) => ({
action: "add-open-row",
issueNumber: issue.number,
title: issue.title,
section: "open",
reason: "GitHub issue is open but no #20 OPEN table row was found.",
})),
...closedInOpenRows.map((issue) => ({
action: "move-open-row-to-closed",
issueNumber: issue.number,
title: issue.title,
reason: "GitHub issue is closed but still appears in the #20 OPEN table.",
})),
...missingClosedRows.map((issue) => ({
action: "add-closed-row",
issueNumber: issue.number,
title: issue.title,
section: "closed",
reason: "GitHub issue is closed but no #20 CLOSED table row was found.",
})),
...openInClosedRows.map((issue) => ({
action: "move-closed-row-to-open",
issueNumber: issue.number,
title: issue.title,
reason: "GitHub issue is open but appears in the #20 CLOSED table.",
})),
...rowValidationWarnings.map((warning) => ({
action: "fill-board-row-fields",
issueNumber: warning.issueNumber,
section: warning.section,
lineNumber: warning.lineNumber,
reason: warning.message,
missingColumns: warning.missingColumns ?? [],
})),
];
return {
ok: true,
command: commandName,
repo,
dryRun: true,
planned: true,
readOnly: true,
boardIssue: {
number: boardIssue.number,
title: boardIssue.title,
state: boardIssue.state,
url: boardIssue.html_url,
bodyChars: (boardIssue.body ?? "").length,
updatedAt: boardIssue.updated_at ?? null,
},
config: {
limit: options.limit,
knownMetaIssues: mergedKnownMetaIssues(options),
ignoredIssues: options.ignoredIssues,
requiredColumns: BOARD_AUDIT_REQUIRED_COLUMNS.slice(),
},
summary: {
openIssues: openIssues.length,
closedIssues: closedIssues.length,
openRows: openRows.length,
closedRows: closedRows.length,
missingOpenIssues: missingOpenIssues.length,
closedInOpenRows: closedInOpenRows.length,
missingClosedRows: missingClosedRows.length,
openInClosedRows: openInClosedRows.length,
rowValidationWarnings: rowValidationWarnings.length,
ignoredIssues: ignoredIssues.length,
boardOnlyRows: boardOnlyRows.length,
staleOpenRows: staleOpenRows.length,
},
sections: parsed.sections.map((section) => ({
kind: section.kind,
heading: section.heading,
headingLine: section.headingLine,
headerLine: section.headerLine,
headers: section.headers,
rows: section.rows.length,
issueNumbers: section.rows.flatMap((row) => row.issueNumbers),
})),
missingOpenIssues,
closedInOpenRows,
missingClosedRows,
openInClosedRows,
staleOpenRows,
boardOnlyRows,
rowValidationWarnings,
ignoredIssues,
recommendedActions,
request: {
method: "GET",
paths: [
`/repos/{owner}/{repo}/issues/${options.boardIssue}`,
"/repos/{owner}/{repo}/issues?state=open",
"/repos/{owner}/{repo}/issues?state=closed",
],
query: { per_page: options.limit },
},
note: "Read-only board audit; no issue body was edited, no issue was closed, and no comments were written.",
};
}
async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
if (options.title === undefined) throw new Error("issue create requires --title <title>");
const body = readBodyFile(options.bodyFile, "issue create");
const bodySource = { kind: "body-file", path: options.bodyFile ?? null };
const labels = options.labels;
if (options.dryRun) {
return {
ok: true,
command: "issue create",
repo,
dryRun: true,
planned: true,
title: options.title,
labels,
...writeBodyPlan("issue create", repo, body, bodySource, { title: options.title, labels }),
};
}
const { owner, name } = repoParts(repo);
const payload: Record<string, unknown> = { title: options.title, body };
if (labels.length > 0) payload.labels = labels;
const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, payload);
if (isGitHubError(issue)) return commandError("issue create", repo, issue, { title: options.title, labels });
const appliedLabels = issueLabelNames(issue);
const missingLabels = labels.filter((label) => !appliedLabels.includes(label));
if (missingLabels.length > 0) {
return validationError("issue create", repo, "GitHub created the issue but did not return all requested labels; refusing to report silent label success", {
issue: issueSummary(issue),
requestedLabels: labels,
appliedLabels,
missingLabels,
});
}
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true };
}
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> {
const body = readBodyFile(options.bodyFile, commandName);
if (options.mode === "replace") {
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
if (bodyGuard !== null) return bodyGuard;
}
const concurrencyOptionError = assertConcurrencyOptions(options);
if (concurrencyOptionError !== null) return concurrencyOptionError;
let oldIssue: GitHubIssue | null = null;
let briefDiff: CommanderBriefDiff | null = null;
const claudeQqConfig = commanderBriefClaudeQqConfig();
if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) {
return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber });
}
const needsReadBeforeEdit = options.mode === "append" || !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined);
if (needsReadBeforeEdit) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" });
oldIssue = issue;
const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options);
if (concurrencyError !== null) return concurrencyError;
if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body);
}
const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body;
if (options.mode === "append") {
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options);
if (bodyGuard !== null) return bodyGuard;
}
if (options.dryRun) {
const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null;
const guard = issueEditGuardSummary(issueNumber, finalBody, options);
let dryRunOldBody: Record<string, unknown> = {
fetched: false,
bodyChars: null,
bodySha: null,
updatedAt: null,
reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run",
};
if (token.length > 0) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) {
dryRunOldBody = {
fetched: false,
bodyChars: null,
bodySha: null,
updatedAt: null,
readError: {
degradedReason: issue.degradedReason,
runnerDisposition: issue.runnerDisposition,
message: issue.message,
status: issue.status ?? null,
},
};
} else {
const oldBody = issue.body ?? "";
dryRunOldBody = {
fetched: true,
bodyChars: oldBody.length,
bodySha: bodySha(oldBody),
updatedAt: issue.updated_at ?? null,
};
}
}
return {
ok: true,
command: commandName,
repo,
dryRun: true,
issueNumber,
mode: options.mode,
...dryRunBody(repo, options.title, finalBody),
guard,
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, { kind: "body-file", path: options.bodyFile ?? null }, oldIssue?.body ?? null),
bodyOnlySafety: {
oldBody: dryRunOldBody,
newBody: {
bodyChars: finalBody.length,
bodySha: bodySha(finalBody),
},
},
concurrency: {
dryRunNoWrite: true,
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH",
},
wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile },
...(options.notifyClaudeQqBriefDiff
? {
commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, finalBody, dryRunDiff ?? commanderBriefDiff("", finalBody), claudeQqConfig),
dryRunOldBodySource: "not-fetched",
dryRunDiffReliability: "new-body-only preview; non-dry-run reads the GitHub issue body before PATCH and sends only sections absent from the old body",
dryRunNoWrite: true,
dryRunNoClaudeQqSend: true,
}
: {}),
};
}
const { owner, name } = repoParts(repo);
const payload: Record<string, unknown> = { body: finalBody };
if (options.title !== undefined) payload.title = options.title;
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
const guard = issueEditGuardSummary(issueNumber, finalBody, options);
const concurrency = oldIssue === null
? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }
: { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null };
if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: commandName, repo, issue: issueSummary(issue), mode: options.mode, guard, concurrency, rest: true };
const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody);
const claudeqq = diff.ok
? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message)
: {
ok: true,
attempted: false,
skipped: true,
skippedReason: diff.skippedReason ?? "no commander brief diff to send",
target: maskedTarget(claudeQqConfig),
} satisfies ClaudeQqSendResult;
return {
ok: true,
command: commandName,
repo,
issue: issueSummary(issue),
guard,
concurrency,
rest: true,
commanderBriefNotification: {
issueNumber,
oldIssueUpdatedAt: oldIssue?.updated_at ?? null,
diff: {
ok: diff.ok,
mode: diff.mode,
chars: diff.chars,
sectionCount: diff.sectionCount,
skippedReason: diff.skippedReason ?? null,
preview: diff.ok ? preview(diff.message) : "",
previewLines: diff.ok ? previewLines(diff.message) : [],
containsLiteralBackslashN: diff.message.includes("\\n"),
containsBackticks: diff.message.includes("`"),
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message),
},
},
claudeqq,
};
}
async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const body = readBodyFile(options.bodyFile, "issue comment");
const bodySource = { kind: "body-file", path: options.bodyFile ?? null };
if (options.dryRun) {
return {
ok: true,
command: "issue comment create",
repo,
dryRun: true,
planned: true,
issueNumber,
...writeBodyPlan("issue comment create", repo, body, bodySource, { issueNumber }),
};
}
const { owner, name } = repoParts(repo);
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
return { ok: true, command: "issue comment create", repo, issueNumber, comment: commentSummary(comment), bodySource, rest: true };
}
async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> {
const command = `${ownerKind} comment delete`;
const { owner, name } = repoParts(repo);
if (dryRun) {
return {
ok: true,
command,
repo,
dryRun: true,
planned: true,
commentId,
request: { method: "DELETE", path: `/repos/{owner}/{repo}/issues/comments/${commentId}` },
};
}
const result = await githubRequest<null>(token, "DELETE", `/repos/${owner}/${name}/issues/comments/${commentId}`);
if (isGitHubError(result)) return commandError(command, repo, result, { commentId });
return {
ok: true,
command,
repo,
commentId,
deleted: true,
request: { method: "DELETE", path: `/repos/${owner}/${name}/issues/comments/${commentId}` },
rest: true,
};
}
async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> {
if (dryRun) return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", dryRun: true, repo, issueNumber, wouldPatch: { state } };
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state });
if (isGitHubError(issue)) return commandError(state === "closed" ? "issue close" : "issue reopen", repo, issue, { issueNumber });
return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", repo, issue: issueSummary(issue), rest: true };
}
function escapeSnippet(text: string, index: number, radius = 80): string {
const start = Math.max(0, index - radius);
const end = Math.min(text.length, index + radius + 40);
return text.slice(start, end).replace(/\n/g, "\\n");
}
function lineColumnAt(text: string, index: number): { lineNumber: number; column: number } {
let lineNumber = 1;
let column = 1;
for (let cursor = 0; cursor < index && cursor < text.length; cursor += 1) {
if (text[cursor] === "\n") {
lineNumber += 1;
column = 1;
} else {
column += 1;
}
}
return { lineNumber, column };
}
function lineTextAt(text: string, lineNumber: number): string {
const lines = text.split(/\r?\n/);
return lines[lineNumber - 1] ?? "";
}
function isInsideFencedCodeBlock(text: string, index: number): boolean {
const lines = text.slice(0, index).split(/\r?\n/);
let openFence = false;
for (const line of lines) {
const trimmed = line.trimStart();
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) openFence = !openFence;
}
return openFence;
}
function isInsideInlineCode(text: string, index: number): boolean {
const { lineNumber, column } = lineColumnAt(text, index);
const line = lineTextAt(text, lineNumber);
const before = line.slice(0, Math.max(0, column - 1));
const after = line.slice(Math.max(0, column - 1));
return (before.match(/`/g)?.length ?? 0) % 2 === 1 && (after.match(/`/g)?.length ?? 0) > 0;
}
function isExplanatoryLiteralBackslashN(text: string, index: number): boolean {
if (isInsideFencedCodeBlock(text, index) || isInsideInlineCode(text, index)) return true;
const window = text.slice(Math.max(0, index - 90), Math.min(text.length, index + 90)).toLowerCase();
return [
"字面量",
"说明",
"示例",
"举例",
"提到",
"引用",
"文本",
"字符串",
"escape",
"literal",
"example",
"mention",
"quoted",
"反斜杠",
].some((keyword) => window.includes(keyword));
}
function cleanupEscapedText(text: string): string {
return text
.replace(/\\r\\n/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\x0a/gi, "\n")
.replace(/\\x1b/gi, "")
.replace(/\\u001b/gi, "")
.replace(/\\033/g, "")
.replace(/\\`/g, "`");
}
function escapeBodyId(bodyKind: EscapeBodyKind, issueNumber: number, issueId: number, commentId?: number): string {
return bodyKind === "issue-body"
? `issue:${issueNumber}:body:${issueId}`
: `issue:${issueNumber}:comment:${commentId ?? "unknown"}`;
}
function textFindingPatterns(): EscapePatternDefinition[] {
return [
{
kind: "literal-backslash-n",
pattern: /\\n/g,
description: "literal backslash-n",
},
{
kind: "literal-backslash-t",
pattern: /\\t/g,
description: "literal backslash-t",
},
{
kind: "escaped-backtick",
pattern: /\\`/g,
description: "escaped backtick",
},
{
kind: "shell-escaped-newline",
pattern: /\\r\\n|\\012|\\x0a/gi,
description: "shell escaped newline",
},
{
kind: "ansi-escape-literal",
pattern: /\\x1b|\\u001b|\\033/gi,
description: "ANSI escape literal",
},
];
}
function bodyRiskFindings(kind: EscapeBodyKind, issueNumber: number, issueId: number, body: string | null, url: string, commentId?: number): EscapeMatchFinding[] {
const bodyChars = body === null ? null : body.length;
const bodyTrimmedChars = body === null ? null : body.trim().length;
const location = {
type: kind === "issue-body" ? "issue" as const : "comment" as const,
bodyKind: kind,
bodyId: escapeBodyId(kind, issueNumber, issueId, commentId),
issueNumber,
issueId,
...(commentId === undefined ? {} : { commentId }),
url,
bodyChars,
bodyTrimmedChars,
};
if (body === null) {
return [{
...location,
kind: "null-body",
classification: "risk",
severity: "high",
reason: "GitHub returned a null body",
snippet: "",
lineNumber: 0,
column: 0,
match: "",
}];
}
const trimmed = body.trim();
if (trimmed.toLowerCase() === "null") {
return [{
...location,
kind: "literal-null-body",
classification: "risk",
severity: "high",
reason: "body text is the literal string null",
snippet: escapeSnippet(body, 0),
lineNumber: 1,
column: 1,
match: "null",
}];
}
if (trimmed.length === 0) {
return [{
...location,
kind: "blank-body",
classification: "risk",
severity: "medium",
reason: "body is blank after trimming",
snippet: "",
lineNumber: 0,
column: 0,
match: "",
}];
}
if (trimmed.length < MIN_SAFE_BODY_SCAN_CHARS) {
return [{
...location,
kind: "short-body",
classification: "risk",
severity: "medium",
reason: `body is shorter than the safe guard threshold (${MIN_SAFE_BODY_SCAN_CHARS} chars)`,
snippet: escapeSnippet(body, 0),
lineNumber: 1,
column: 1,
match: "",
}];
}
return [];
}
function scanText(text: string, patterns: EscapePatternDefinition[], issueNumber: number, issueId: number, bodyKind: EscapeBodyKind, url: string, commentId?: number): EscapeMatchFinding[] {
const findings: EscapeMatchFinding[] = [];
const counts = new Map<string, number>();
for (const item of patterns) {
item.pattern.lastIndex = 0;
const matches = text.match(item.pattern);
counts.set(item.kind, matches === null ? 0 : matches.length);
}
for (const item of patterns) {
item.pattern.lastIndex = 0;
let match = item.pattern.exec(text);
while (match !== null) {
const { lineNumber, column } = lineColumnAt(text, match.index);
const explanatory = item.kind === "literal-backslash-n" && counts.get(item.kind) === 1 && isExplanatoryLiteralBackslashN(text, match.index);
const classification: EscapeFindingClassification = explanatory ? "explanatory-mention" : "suspected-pollution";
const severity: EscapeFindingSeverity = explanatory ? "low" : item.kind === "ansi-escape-literal" ? "high" : "medium";
findings.push({
type: bodyKind === "issue-body" ? "issue" : "comment",
bodyKind,
bodyId: escapeBodyId(bodyKind, issueNumber, issueId, commentId),
issueNumber,
issueId,
...(commentId === undefined ? {} : { commentId }),
url,
kind: item.kind,
classification,
severity,
reason: explanatory
? "literal backslash-n appears in explanatory context"
: `matched ${item.description}`,
snippet: escapeSnippet(text, match.index),
lineNumber,
column,
match: match[0],
bodyChars: text.length,
bodyTrimmedChars: text.trim().length,
...(classification === "suspected-pollution"
? { cleanupPreview: { before: escapeSnippet(text, match.index), after: escapeSnippet(cleanupEscapedText(text), match.index) } }
: {}),
});
if (findings.length >= ISSUE_SCAN_MAX_FINDINGS) return findings;
match = item.pattern.exec(text);
}
}
return findings;
}
function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupSuggestion[] {
const groups = new Map<string, EscapeMatchFinding[]>();
for (const finding of findings) {
if (finding.type === "comment-scan-error") continue;
if (finding.classification !== "suspected-pollution" && finding.kind !== "short-body" && finding.kind !== "null-body" && finding.kind !== "blank-body" && finding.kind !== "literal-null-body") continue;
const locatorKey = `${finding.bodyKind}:${finding.issueNumber}:${finding.issueId}:${finding.commentId ?? ""}`;
const list = groups.get(locatorKey);
if (list === undefined) groups.set(locatorKey, [finding]);
else list.push(finding);
}
const suggestions: EscapeCleanupSuggestion[] = [];
for (const group of groups.values()) {
const first = group[0];
const suspected = group.some((finding) => finding.classification === "suspected-pollution");
const bodyRisk = group.some((finding) => finding.classification === "risk");
suggestions.push({
type: first.bodyKind,
bodyId: first.bodyId,
issueNumber: first.issueNumber,
issueId: first.issueId,
...(first.commentId === undefined ? {} : { commentId: first.commentId }),
url: first.url,
classification: suspected ? "suspected-pollution" : "risk",
action: first.bodyKind === "issue-body"
? (suspected ? "rewrite-issue-body-with-body-file" : "review-body-length")
: "review-comment-manually",
reason: suspected
? "suspected shell escape pollution should be rewritten from a body file"
: bodyRisk
? "body length/null risk should be reviewed before any write"
: "no cleanup needed",
findings: group.map((finding) => ({
kind: finding.kind,
classification: finding.classification,
severity: finding.severity,
lineNumber: finding.lineNumber,
column: finding.column,
snippet: finding.snippet,
match: finding.match,
})),
...(first.bodyKind === "issue-body"
? {
plannedCommand: `bun scripts/cli.ts gh issue update ${first.issueNumber} --mode replace --body-file <file> --dry-run`,
bodyFileHint: "write a cleaned Markdown body to a file, then pass it with --body-file; use a quoted heredoc to stage the file instead of inline shell text",
preview: {
before: first.cleanupPreview?.before ?? first.snippet,
after: first.cleanupPreview?.after ?? first.snippet,
},
}
: {
preview: {
before: first.cleanupPreview?.before ?? first.snippet,
after: first.cleanupPreview?.after ?? first.snippet,
},
}),
});
}
return suggestions;
}
async function issueScanEscape(repo: string, token: string, limit: number, dryRun: boolean, commandName = "issue scan-escape"): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const issues = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=all&per_page=${limit}`);
if (isGitHubError(issues)) return commandError(commandName, repo, issues);
const issueOnly = issues.filter((issue) => issue.pull_request === undefined).slice(0, limit);
const patterns = textFindingPatterns();
const findings: EscapeScanEntry[] = [];
let scannedComments = 0;
for (const issue of issueOnly) {
findings.push(...bodyRiskFindings("issue-body", issue.number, issue.id, issue.body, issue.html_url));
findings.push(...scanText(issue.body ?? "", patterns, issue.number, issue.id, "issue-body", issue.html_url));
const comments = await listIssueComments(token, repo, issue.number);
if (isGitHubError(comments)) {
findings.push({
type: "comment-scan-error",
bodyKind: "comment-body",
bodyId: escapeBodyId("comment-body", issue.number, issue.id),
issueNumber: issue.number,
issueId: issue.id,
url: issue.html_url,
degradedReason: comments.degradedReason,
runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason),
details: comments,
});
continue;
}
for (const comment of comments) {
scannedComments += 1;
findings.push(...bodyRiskFindings("comment-body", issue.number, issue.id, comment.body, comment.html_url, comment.id));
findings.push(...scanText(comment.body ?? "", patterns, issue.number, issue.id, "comment-body", comment.html_url, comment.id));
}
}
const cleanupSuggestions = summarizeCleanupSuggestion(findings);
const summary = findings.reduce((acc, finding) => {
if (finding.type === "comment-scan-error") {
acc.commentScanErrors += 1;
return acc;
}
if (finding.classification === "suspected-pollution") acc.suspectedPollution += 1;
if (finding.classification === "explanatory-mention") acc.explanatoryMention += 1;
if (finding.classification === "risk") acc.risk += 1;
if (finding.kind === "null-body" || finding.kind === "literal-null-body" || finding.kind === "blank-body" || finding.kind === "short-body") acc.bodyRisks += 1;
return acc;
}, { suspectedPollution: 0, explanatoryMention: 0, risk: 0, bodyRisks: 0, commentScanErrors: 0 });
return {
ok: true,
command: commandName,
repo,
dryRun,
planned: true,
scannedIssues: issueOnly.length,
rawIssues: issues.length,
scannedComments,
findings,
cleanupSuggestions,
summary: {
findings: findings.length,
suggestions: cleanupSuggestions.length,
...summary,
},
note: dryRun
? "Dry-run cleanup planning only; no issue or comment content was modified."
: "Read-only scan; no issue or comment content was modified.",
};
}
async function authStatus(repo: string): Promise<GitHubCommandResult> {
const ghPath = ghBinaryPath();
const { token, probe } = resolveToken(ghPath !== null);
const degraded: GitHubDegradedReason[] = [];
if (ghPath === null) degraded.push("missing-binary");
if (!probe.present || token === null) {
if (ghPath === null) {
return {
ok: false,
command: "auth status",
repo,
degradedReason: "missing-binary",
degraded,
runnerDisposition: runnerDisposition("missing-binary"),
gh: { binaryFound: false, path: null },
token: probe,
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
};
}
degraded.push("missing-token");
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), {
degraded,
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
});
}
const { owner, name } = repoParts(repo);
const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit");
if (isGitHubError(api)) {
return commandError("auth status", repo, api, {
degraded: [...degraded, api.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: api, repo: "skipped", issueRead: "skipped" },
});
}
const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`);
if (isGitHubError(repoProbe)) {
return commandError("auth status", repo, repoProbe, {
degraded: [...degraded, repoProbe.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" },
});
}
const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`);
if (isGitHubError(issueProbe)) {
return commandError("auth status", repo, issueProbe, {
degraded: [...degraded, issueProbe.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe },
});
}
return {
ok: true,
command: "auth status",
repo,
degraded,
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: {
restApi: "ok",
repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null },
issueRead: { ok: true, readable: true, sampleCount: issueProbe.length },
},
restFallback: true,
};
}
async function prList(repo: string, token: string, limit: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=all&per_page=${limit}`);
if (isGitHubError(prs)) return commandError("pr list", repo, prs);
const fields = jsonFields ?? PR_JSON_FIELDS.slice();
return {
ok: true,
command: "pr list",
repo,
limit,
count: prs.length,
jsonFields: fields,
pullRequests: prs.map((pr) => selectedPrJson(pr, fields)),
};
}
async function prView(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError("pr view", repo, pr, { number });
return {
ok: true,
command: "pr view",
repo,
pullRequest: prSummary(pr),
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(pr, jsonFields) }),
};
}
export function ghHelp(): unknown {
return {
command: "gh",
output: "json",
usage: [
"bun scripts/cli.ts gh auth status [--repo owner/name]",
"bun scripts/cli.ts gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
"bun scripts/cli.ts gh issue view <number> [--repo owner/name] [--json body,title,state,comments]",
"bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [compat alias for issue update --mode replace]",
"bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]",
"bun scripts/cli.ts gh issue comment create <number> --body-file <file> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]",
"bun scripts/cli.ts gh issue cleanup-plan [--repo owner/name] [--limit N] [--dry-run]",
"bun scripts/cli.ts gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]",
"bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
"bun scripts/cli.ts gh pr view <number> [--repo owner/name] [--json body,title,state,head,base,draft]",
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title title] [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
],
defaults: { repo: DEFAULT_REPO },
notes: [
"Issue and PR create/read/update/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
"Token values are never printed; auth status reports only token source and presence.",
"issue list defaults to --state open and bounded --limit 30; supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
"issue view supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"issue create accepts repeatable --label values and comma-separated labels; dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.",
"issue edit is a compatibility alias for issue update --mode replace.",
"issue update --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 and #24 body-only profiles require their stable headings.",
"issue update dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.",
"Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.",
"When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.",
"For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments; GitHub Markdown writes intentionally use --body-file only.",
"issue scan-escape classifies literal \\n findings as suspected-pollution, explanatory-mention, or risk, and emits cleanupSuggestions with body/comment ids plus diff-like previews. cleanup-plan is an alias that remains dry-run/read-only.",
"issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It compares GitHub open/closed issue lists with the board OPEN/CLOSED tables and reports missingOpenIssues, closedInOpenRows, missingClosedRows, rowValidationWarnings, ignoredIssues, and recommendedActions. When an Issue column exists, row.issueNumber is taken from that column; #20 and #24 are known meta issues by default.",
"issue edit 24 --notify-claudeqq-brief-diff reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
],
};
}
export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> {
const [top, sub, third] = args;
if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp();
let options: GitHubOptions;
try {
options = parseOptions(args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
const repo = optionValue(args, "--repo") ?? DEFAULT_REPO;
return message.startsWith("unknown gh option:")
? unsupportedCommand(command, repo, message)
: validationError(command, repo, message);
}
if (options.notifyClaudeQqBriefDiff && !(top === "issue" && sub === "edit")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--notify-claudeqq-brief-diff is only supported by gh issue edit 24");
}
if (optionWasProvided(args, "--state") && !(top === "issue" && sub === "list")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--state is only supported by gh issue list");
}
if (optionWasProvided(args, "--json") && !(top === "issue" && (sub === "view" || sub === "list"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
if (!(top === "pr" && (sub === "view" || sub === "list"))) {
return validationError(command, options.repo, "--json field selection is only supported by gh issue view/list and gh pr view/list");
}
}
if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && sub === "update"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--mode is only supported by gh issue update/edit and gh pr update");
}
if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && (sub === "edit" || sub === "update"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue update/edit");
}
if ((optionWasProvided(args, "--board-issue") || optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && sub === "board-audit")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--board-issue, --known-meta-issue, and --ignore-issue are only supported by gh issue board-audit");
}
if (optionWasProvided(args, "--label") && !(top === "issue" && sub === "create")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--label is only supported by gh issue create");
}
if (top === "auth" && sub === "status") return authStatus(options.repo);
if (top === "issue") {
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
if (sub === "comment" && third === "delete") {
const commentId = parseNumberForCommand(options.repo, args[3], "issue comment delete");
if (typeof commentId !== "number") return commentId;
if (options.dryRun) return commentDelete(options.repo, "", "issue", commentId, true);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "issue comment delete", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true });
return commentDelete(options.repo, token, "issue", commentId, false);
}
if (sub === "comment" && third === "create") {
const issueNumber = parseNumberForCommand(options.repo, args[3], "issue comment create");
if (typeof issueNumber !== "number") return issueNumber;
if (options.dryRun) return issueComment(options.repo, "", issueNumber, options);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "issue comment create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true });
return issueComment(options.repo, token, issueNumber, options);
}
if (sub === "scan-escape" || sub === "cleanup-plan") {
const { token, probe } = resolveToken(true);
const commandName = sub === "cleanup-plan" ? "issue cleanup-plan" : "issue scan-escape";
const missing = authRequired(options.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return issueScanEscape(options.repo, token, options.limit, options.dryRun || sub === "cleanup-plan", commandName);
}
if (sub === "board-audit") {
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "issue board-audit", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue board-audit", { present: false, source: null, ghFallbackAttempted: true });
return issueBoardAudit(options.repo, token, options);
}
if (options.dryRun) {
if (sub === "create") return issueCreate(options.repo, "", options);
if (sub === "edit") {
const issueNumber = parseNumber(third, "issue edit");
const { token } = resolveToken(false);
return issueEdit(options.repo, token ?? "", issueNumber, options);
}
if (sub === "update") {
const issueNumber = parseNumber(third, "issue update");
const { token } = resolveToken(false);
return issueEdit(options.repo, token ?? "", issueNumber, options, "issue update");
}
if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options);
if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true);
if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true);
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `issue ${sub ?? ""}`.trim(), { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields);
if (sub === "view") return issueView(options.repo, token, parseNumber(third, "issue view"), options.jsonFields);
if (sub === "create") return issueCreate(options.repo, token, options);
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);
if (sub === "update") return issueEdit(options.repo, token, parseNumber(third, "issue update"), options, "issue update");
if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options);
if (sub === "close") return issueState(options.repo, token, parseNumber(third, "issue close"), "closed", options.dryRun);
if (sub === "reopen") return issueState(options.repo, token, parseNumber(third, "issue reopen"), "open", options.dryRun);
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
}
if (top === "pr") {
if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics.");
if (sub === "comment" && third === "delete") {
const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete");
if (typeof commentId !== "number") return commentId;
if (options.dryRun) return commentDelete(options.repo, "", "pr", commentId, true);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr comment delete", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true });
return commentDelete(options.repo, token, "pr", commentId, false);
}
if (sub === "comment" && third === "create") {
const number = parseNumberForCommand(options.repo, args[3], "pr comment create");
if (typeof number !== "number") return number;
if (options.dryRun) return prComment(options.repo, "", number, options);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr comment create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment create", { present: false, source: null, ghFallbackAttempted: true });
return prComment(options.repo, token, number, options);
}
if (sub === "create") {
if (options.dryRun) return prCreate(options.repo, "", options);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr create", { present: false, source: null, ghFallbackAttempted: true });
return prCreate(options.repo, token, options);
}
if (sub === "comment") {
if (options.dryRun) {
const number = parseNumberForCommand(options.repo, third, "pr comment");
if (typeof number !== "number") return number;
return prComment(options.repo, "", number, options);
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr comment", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true });
const number = parseNumberForCommand(options.repo, third, "pr comment");
if (typeof number !== "number") return number;
return prComment(options.repo, token, number, options);
}
if (sub === "update") {
const number = parseNumberForCommand(options.repo, third, "pr update");
if (typeof number !== "number") return number;
if (options.dryRun && options.mode === "replace") return prUpdate(options.repo, "", number, options);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr update", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr update", { present: false, source: null, ghFallbackAttempted: true });
return prUpdate(options.repo, token, number, options);
}
if (sub === "close" || sub === "reopen") {
const number = parseNumberForCommand(options.repo, third, `pr ${sub}`);
if (typeof number !== "number") return number;
if (options.dryRun) return prState(options.repo, "", number, sub === "close" ? "closed" : "open", true);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false);
}
if (sub === "merge") {
return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only.");
}
if (sub !== "list" && sub !== "view") {
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, update, close, reopen, comment create/delete, and unsupported merge/delete.");
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return prList(options.repo, token, options.limit, options.prJsonFields);
return prView(options.repo, token, parseNumber(third, "pr view"), options.prJsonFields);
}
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });
}