5582 lines
239 KiB
TypeScript
5582 lines
239 KiB
TypeScript
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 MAX_PR_FILES_LIMIT = 3000;
|
||
const DEFAULT_BOARD_KNOWN_META_ISSUES = [CODE_QUEUE_BOARD_TARGET_ISSUE, COMMANDER_BRIEF_TARGET_ISSUE] as const;
|
||
const BOARD_AUDIT_REQUIRED_COLUMNS = ["branch", "acceptance", "relatedTask", "progress"] as const;
|
||
const BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks", "focus"] as const;
|
||
const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const;
|
||
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
|
||
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
|
||
const PR_LIST_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const;
|
||
const PR_READ_JSON_FIELDS = ["body", "title", "state", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
|
||
const ISSUE_LIST_STATES = ["open", "closed", "all"] as const;
|
||
const BODY_UPDATE_MODES = ["replace", "append"] as const;
|
||
const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const;
|
||
const BOARD_GITHUB_STATUSES = ["OPEN", "CLOSED"] as const;
|
||
const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
|
||
const ISSUE_SCAN_MAX_FINDINGS = 60;
|
||
const ISSUE_BODY_PROFILES = {
|
||
"code-queue-board": {
|
||
label: "Code Queue long board issue #20",
|
||
issueNumber: CODE_QUEUE_BOARD_TARGET_ISSUE,
|
||
requiredHeadings: ["## 看板(OPEN)"],
|
||
},
|
||
"commander-brief": {
|
||
label: "Commander brief body-only issue (#24 legacy or daily rolling brief)",
|
||
legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE,
|
||
requiredHeadings: ["## 常驻观察与长期建议"],
|
||
},
|
||
} as const;
|
||
const DAILY_COMMANDER_BRIEF_TITLE_PATTERN = /^\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)$/u;
|
||
const DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)\s*$/u;
|
||
const LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+指挥简报\s*$/u;
|
||
const COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS = [
|
||
/^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u,
|
||
/^##\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间指挥更新\s*$/u,
|
||
/^###\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+CST[::].*$/u,
|
||
] as const;
|
||
|
||
type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number];
|
||
type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number];
|
||
type PrListJsonField = typeof PR_LIST_JSON_FIELDS[number];
|
||
type PrReadJsonField = typeof PR_READ_JSON_FIELDS[number];
|
||
type IssueListState = typeof ISSUE_LIST_STATES[number];
|
||
type PrListState = typeof ISSUE_LIST_STATES[number];
|
||
type BodyUpdateMode = typeof BODY_UPDATE_MODES[number];
|
||
type BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number];
|
||
type BoardGithubStatus = typeof BOARD_GITHUB_STATUSES[number];
|
||
type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES;
|
||
type IssueBodyProfileOption = "auto" | IssueBodyProfileName;
|
||
type EscapeFindingClassification = "suspected-pollution" | "explanatory-mention" | "risk";
|
||
type EscapeFindingSeverity = "low" | "medium" | "high";
|
||
type EscapeBodyKind = "issue-body" | "comment-body";
|
||
type CleanupSuggestionAction = "rewrite-issue-body-with-body-file" | "review-comment-manually" | "review-body-length" | "no-cleanup-needed";
|
||
type BoardSectionKind = "open" | "closed";
|
||
type BoardRequiredColumn = typeof BOARD_AUDIT_REQUIRED_COLUMNS[number];
|
||
type BoardColumnKind = BoardRequiredColumn | "focus" | "githubStatus" | "category" | "summary";
|
||
type BoardRowField = typeof BOARD_ROW_FIELDS[number];
|
||
type BoardRowUpsertTextField = typeof BOARD_ROW_UPSERT_TEXT_FIELDS[number];
|
||
type BoardRowUpsertField = BoardRowUpsertTextField | "status";
|
||
type BoardIgnoreReason = "known-meta" | "ignored" | "brief-index-managed";
|
||
|
||
type GitHubDegradedReason =
|
||
| "missing-binary"
|
||
| "missing-token"
|
||
| "auth-failed"
|
||
| "egress-failed"
|
||
| "github-transient"
|
||
| "network-proxy-failed"
|
||
| "permission-denied"
|
||
| "repo-not-found"
|
||
| "repo-forbidden"
|
||
| "issue-not-found"
|
||
| "pr-not-found"
|
||
| "scope-insufficient"
|
||
| "validation-failed"
|
||
| "invalid-response"
|
||
| "unsupported-command";
|
||
|
||
type RunnerDisposition = "infra-blocked" | "business-failed";
|
||
type ClaudeQqTargetType = "private" | "group";
|
||
type CommanderBriefDiffMode = "identical" | "append-only" | "heading-diff" | "unreliable";
|
||
|
||
interface CommanderBriefDiff {
|
||
ok: boolean;
|
||
mode: CommanderBriefDiffMode;
|
||
message: string;
|
||
chars: number;
|
||
sections: string[];
|
||
sectionCount: number;
|
||
skippedReason?: string;
|
||
}
|
||
|
||
interface ClaudeQqConfig {
|
||
enabled: boolean;
|
||
baseUrl: string;
|
||
targetType: ClaudeQqTargetType;
|
||
userId?: string;
|
||
groupId?: string;
|
||
timeoutMs: number;
|
||
}
|
||
|
||
interface ClaudeQqSendResult {
|
||
ok: boolean;
|
||
attempted: boolean;
|
||
skipped?: boolean;
|
||
skippedReason?: string;
|
||
endpoint?: string;
|
||
status?: number;
|
||
degradedReason?: string;
|
||
message?: string;
|
||
response?: unknown;
|
||
target: Record<string, unknown>;
|
||
}
|
||
|
||
interface ClaudeQqEndpointResult {
|
||
ok: boolean;
|
||
endpoint: string;
|
||
status?: number;
|
||
degradedReason?: string;
|
||
message?: string;
|
||
response?: unknown;
|
||
}
|
||
|
||
interface CommanderBriefSection {
|
||
heading: string;
|
||
text: string;
|
||
startLine: number;
|
||
}
|
||
|
||
interface EscapePatternDefinition {
|
||
kind: string;
|
||
pattern: RegExp;
|
||
description: string;
|
||
}
|
||
|
||
interface EscapeMatchFinding {
|
||
type: "issue" | "comment";
|
||
bodyKind: EscapeBodyKind;
|
||
bodyId: string;
|
||
issueNumber: number;
|
||
issueId: number;
|
||
commentId?: number;
|
||
url: string;
|
||
kind: string;
|
||
classification: EscapeFindingClassification;
|
||
severity: EscapeFindingSeverity;
|
||
reason: string;
|
||
snippet: string;
|
||
lineNumber: number;
|
||
column: number;
|
||
match: string;
|
||
bodyChars: number | null;
|
||
bodyTrimmedChars: number | null;
|
||
cleanupPreview?: {
|
||
before: string;
|
||
after: string;
|
||
};
|
||
}
|
||
|
||
interface EscapeCleanupSuggestion {
|
||
type: EscapeBodyKind;
|
||
bodyId: string;
|
||
issueNumber: number;
|
||
issueId: number;
|
||
commentId?: number;
|
||
url: string;
|
||
classification: EscapeFindingClassification;
|
||
action: CleanupSuggestionAction;
|
||
reason: string;
|
||
findings: Array<Pick<EscapeMatchFinding, "kind" | "classification" | "severity" | "lineNumber" | "column" | "snippet" | "match">>;
|
||
plannedCommand?: string;
|
||
bodyFileHint?: string;
|
||
preview?: {
|
||
before: string;
|
||
after: string;
|
||
};
|
||
}
|
||
|
||
interface EscapeScanErrorFinding {
|
||
type: "comment-scan-error";
|
||
bodyKind: EscapeBodyKind;
|
||
bodyId: string;
|
||
issueNumber: number;
|
||
issueId: number;
|
||
url: string;
|
||
degradedReason: GitHubDegradedReason;
|
||
runnerDisposition: RunnerDisposition;
|
||
details: GitHubErrorPayload;
|
||
}
|
||
|
||
type EscapeScanEntry = EscapeMatchFinding | EscapeScanErrorFinding;
|
||
|
||
interface BoardTableRow {
|
||
section: BoardSectionKind;
|
||
lineNumber: number;
|
||
raw: string;
|
||
cells: string[];
|
||
issueNumbers: number[];
|
||
issueNumber: number | null;
|
||
title: string | null;
|
||
columns: Partial<Record<BoardRequiredColumn, string>>;
|
||
}
|
||
|
||
interface BoardTableSection {
|
||
kind: BoardSectionKind;
|
||
heading: string;
|
||
headingLine: number;
|
||
headerLine: number;
|
||
headers: string[];
|
||
rows: BoardTableRow[];
|
||
}
|
||
|
||
interface BoardIssueEntry {
|
||
number: number;
|
||
title: string;
|
||
state: string;
|
||
url: string;
|
||
}
|
||
|
||
interface BoardIgnoredIssue {
|
||
number: number;
|
||
reason: BoardIgnoreReason;
|
||
title?: string;
|
||
state?: string;
|
||
url?: string;
|
||
}
|
||
|
||
interface BoardRowValidationWarning {
|
||
issueNumber: number | null;
|
||
section: BoardSectionKind;
|
||
lineNumber: number;
|
||
kind: string;
|
||
message: string;
|
||
missingColumns?: BoardRequiredColumn[];
|
||
columns?: Partial<Record<BoardRequiredColumn, string>>;
|
||
rowPreview: string;
|
||
}
|
||
|
||
interface BoardRowLookup {
|
||
ok: true;
|
||
section: BoardTableSection;
|
||
row: BoardTableRow;
|
||
matches: Array<{ section: BoardTableSection; row: BoardTableRow }>;
|
||
}
|
||
|
||
interface BoardRowUpdatePlanResult {
|
||
ok: true;
|
||
plan: Record<string, unknown>;
|
||
newBody: string;
|
||
}
|
||
|
||
interface BoardRowMutationPlanResult {
|
||
ok: true;
|
||
plan: Record<string, unknown>;
|
||
newBody: string;
|
||
}
|
||
|
||
interface BoardRowUpsertValues {
|
||
category?: string;
|
||
branch?: string;
|
||
tasks?: string;
|
||
summary?: string;
|
||
focus?: string;
|
||
validation?: string;
|
||
progress?: string;
|
||
status?: BoardGithubStatus;
|
||
}
|
||
|
||
interface GitHubCommandResult {
|
||
ok: boolean;
|
||
repo: string;
|
||
command: string;
|
||
degradedReason?: GitHubDegradedReason;
|
||
degraded?: GitHubDegradedReason[];
|
||
runnerDisposition?: RunnerDisposition;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
type GitHubCommandFailure = GitHubCommandResult & { ok: false };
|
||
|
||
interface GitHubTokenProbe {
|
||
present: boolean;
|
||
source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null;
|
||
ghFallbackAttempted: boolean;
|
||
ghBinaryFound?: boolean;
|
||
ghAuthTokenAvailable?: boolean | null;
|
||
}
|
||
|
||
interface GitHubOptions {
|
||
repo: string;
|
||
dryRun: boolean;
|
||
raw: boolean;
|
||
full: boolean;
|
||
limit: number;
|
||
boardIssue: number;
|
||
knownMetaIssues: number[];
|
||
ignoredIssues: number[];
|
||
draft: boolean;
|
||
notifyClaudeQqBriefDiff: boolean;
|
||
allowShortBody: boolean;
|
||
labels: string[];
|
||
title?: string;
|
||
body?: string;
|
||
bodyFile?: string;
|
||
base?: string;
|
||
head?: string;
|
||
jsonFields?: IssueViewJsonField[];
|
||
issueListJsonFields?: IssueListJsonField[];
|
||
prListJsonFields?: PrListJsonField[];
|
||
prJsonFields?: PrReadJsonField[];
|
||
listState: IssueListState;
|
||
prListState: PrListState;
|
||
mode: BodyUpdateMode;
|
||
expectUpdatedAt?: string;
|
||
expectBodySha?: string;
|
||
bodyProfile: IssueBodyProfileOption;
|
||
boardRowField?: BoardRowField;
|
||
boardRowValue?: string;
|
||
boardRowFile?: string;
|
||
boardSection?: BoardMutationSection;
|
||
boardMoveTo?: BoardMutationSection;
|
||
boardGithubStatus?: BoardGithubStatus;
|
||
boardRowUpsertValues: BoardRowUpsertValues;
|
||
}
|
||
|
||
interface GitHubShorthandReference {
|
||
input: string;
|
||
repo: string;
|
||
number: number;
|
||
}
|
||
|
||
interface GitHubResolvedNumberReference {
|
||
repo: string;
|
||
number: number;
|
||
shorthand?: GitHubShorthandReference;
|
||
}
|
||
|
||
interface IssueProfileValidationContext {
|
||
issueTitle?: string | null;
|
||
issueBody?: string | null;
|
||
issueMetadataFetched?: boolean;
|
||
}
|
||
|
||
interface GitHubErrorPayload {
|
||
ok: false;
|
||
degradedReason: GitHubDegradedReason;
|
||
runnerDisposition: RunnerDisposition;
|
||
status?: number;
|
||
message: string;
|
||
retryable?: boolean;
|
||
commanderAction?: string;
|
||
details?: unknown;
|
||
scopes?: {
|
||
accepted: string | null;
|
||
token: string | null;
|
||
};
|
||
request?: {
|
||
method: string;
|
||
path: string;
|
||
};
|
||
}
|
||
|
||
interface GitHubIssue {
|
||
id: number;
|
||
number: number;
|
||
title: string;
|
||
body: string | null;
|
||
state: string;
|
||
html_url: string;
|
||
comments: number;
|
||
user?: { login?: string };
|
||
labels?: Array<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 };
|
||
additions?: number;
|
||
deletions?: number;
|
||
changed_files?: number;
|
||
commits?: number;
|
||
closed_at?: string | null;
|
||
merged?: boolean | null;
|
||
merged_at?: string | null;
|
||
merge_commit_sha?: string | null;
|
||
mergeable?: string | null;
|
||
merge_state_status?: string | null;
|
||
created_at?: string;
|
||
updated_at?: string;
|
||
}
|
||
|
||
interface GitHubPullRequestFile {
|
||
sha?: string;
|
||
filename: string;
|
||
status?: string;
|
||
additions?: number;
|
||
deletions?: number;
|
||
changes?: number;
|
||
blob_url?: string;
|
||
raw_url?: string;
|
||
contents_url?: string;
|
||
previous_filename?: string;
|
||
patch?: string;
|
||
}
|
||
|
||
interface GitHubPullRequestGraphqlStatusContext {
|
||
__typename?: string;
|
||
name?: string | null;
|
||
status?: string | null;
|
||
conclusion?: string | null;
|
||
context?: string | null;
|
||
state?: string | null;
|
||
targetUrl?: string | null;
|
||
description?: string | null;
|
||
}
|
||
|
||
interface GitHubPullRequestGraphqlStatusCheckRollup {
|
||
state?: string | null;
|
||
contexts?: {
|
||
nodes?: GitHubPullRequestGraphqlStatusContext[] | null;
|
||
} | null;
|
||
}
|
||
|
||
interface GitHubPullRequestGraphqlMetadata {
|
||
mergeable?: string | null;
|
||
mergeStateStatus?: string | null;
|
||
headRefName?: string | null;
|
||
baseRefName?: string | null;
|
||
statusCheckRollup?: GitHubPullRequestGraphqlStatusCheckRollup | null;
|
||
}
|
||
|
||
interface GitHubRepository {
|
||
id?: number;
|
||
full_name?: string;
|
||
private?: boolean;
|
||
default_branch?: string;
|
||
permissions?: Record<string, boolean>;
|
||
}
|
||
|
||
interface GitHubBranch {
|
||
name?: string;
|
||
commit?: { sha?: string };
|
||
}
|
||
|
||
interface GitHubCompare {
|
||
status?: string;
|
||
ahead_by?: number;
|
||
behind_by?: number;
|
||
total_commits?: number;
|
||
html_url?: string;
|
||
base_commit?: { sha?: string };
|
||
merge_base_commit?: { sha?: string };
|
||
}
|
||
|
||
function optionValue(args: string[], name: string): string | undefined {
|
||
const index = args.indexOf(name);
|
||
if (index === -1) return undefined;
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||
return value;
|
||
}
|
||
|
||
function optionValues(args: string[], name: string): string[] {
|
||
const values: string[] = [];
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
if (args[index] !== name) continue;
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||
values.push(value);
|
||
index += 1;
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function hasFlag(args: string[], name: string): boolean {
|
||
return args.includes(name);
|
||
}
|
||
|
||
function optionWasProvided(args: string[], name: string): boolean {
|
||
return args.includes(name);
|
||
}
|
||
|
||
function commaListOption(args: string[], name: string): string[] | undefined {
|
||
const raw = optionValue(args, name);
|
||
if (raw === undefined) return undefined;
|
||
const values = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
|
||
if (values.length === 0) throw new Error(`${name} requires at least one comma-separated field`);
|
||
return values;
|
||
}
|
||
|
||
function labelsOption(args: string[]): string[] {
|
||
const rawValues = optionValues(args, "--label");
|
||
const labels: string[] = [];
|
||
const seen = new Set<string>();
|
||
for (const raw of rawValues) {
|
||
const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
|
||
if (parts.length === 0) throw new Error("--label requires at least one non-empty label");
|
||
for (const label of parts) {
|
||
if (seen.has(label)) continue;
|
||
labels.push(label);
|
||
seen.add(label);
|
||
}
|
||
}
|
||
return labels;
|
||
}
|
||
|
||
function positiveIntegerValuesOption(args: string[], name: string): number[] {
|
||
const values: number[] = [];
|
||
const seen = new Set<number>();
|
||
for (const raw of optionValues(args, name)) {
|
||
const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
|
||
if (parts.length === 0) throw new Error(`${name} requires at least one positive integer`);
|
||
for (const part of parts) {
|
||
const value = Number(part.replace(/^#/u, ""));
|
||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} values must be positive issue numbers`);
|
||
if (seen.has(value)) continue;
|
||
values.push(value);
|
||
seen.add(value);
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function positiveIntegerSingleOption(args: string[], name: string, defaultValue: number): number {
|
||
const raw = optionValue(args, name);
|
||
if (raw === undefined) return defaultValue;
|
||
const value = Number(raw.replace(/^#/u, ""));
|
||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive issue number`);
|
||
return value;
|
||
}
|
||
|
||
function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
|
||
const raw = optionValue(args, name);
|
||
if (raw === undefined) return defaultValue;
|
||
const value = Number(raw);
|
||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
||
return Math.min(value, maxValue);
|
||
}
|
||
|
||
function validateEnumValue<T extends string>(name: string, raw: string, allowedValues: readonly T[]): T {
|
||
if ((allowedValues as readonly string[]).includes(raw)) return raw as T;
|
||
throw new Error(`unsupported ${name} ${raw}; supported values: ${allowedValues.join(",")}`);
|
||
}
|
||
|
||
function validateJsonFields<T extends string>(command: string, requested: string[] | undefined, allowedFields: readonly T[]): T[] | undefined {
|
||
if (requested === undefined) return undefined;
|
||
const allowed = new Set<string>(allowedFields);
|
||
const unsupported = requested.filter((field) => !allowed.has(field));
|
||
if (unsupported.length > 0) {
|
||
throw new Error(`unsupported ${command} --json field(s): ${unsupported.join(", ")}; supported fields: ${allowedFields.join(",")}`);
|
||
}
|
||
return requested as T[];
|
||
}
|
||
|
||
function parseIssueViewJsonFields(requested: string[] | undefined): IssueViewJsonField[] | undefined {
|
||
return validateJsonFields("gh issue read/view", requested, ISSUE_VIEW_JSON_FIELDS);
|
||
}
|
||
|
||
function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined {
|
||
return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS);
|
||
}
|
||
|
||
function parsePrListJsonFields(requested: string[] | undefined): PrListJsonField[] | undefined {
|
||
return validateJsonFields("gh pr list", requested, PR_LIST_JSON_FIELDS);
|
||
}
|
||
|
||
function parsePrReadJsonFields(requested: string[] | undefined): PrReadJsonField[] | undefined {
|
||
return validateJsonFields("gh pr read/view", requested, PR_READ_JSON_FIELDS);
|
||
}
|
||
|
||
function isIssueReadCommand(sub: string | undefined): boolean {
|
||
return sub === "read" || sub === "view";
|
||
}
|
||
|
||
function isPrReadCommand(sub: string | undefined): boolean {
|
||
return sub === "read" || sub === "view";
|
||
}
|
||
|
||
function parseIssueListState(args: string[]): IssueListState {
|
||
const raw = optionValue(args, "--state") ?? "open";
|
||
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState;
|
||
throw new Error(`unsupported gh issue list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`);
|
||
}
|
||
|
||
function parsePrListState(args: string[]): PrListState {
|
||
const raw = optionValue(args, "--state") ?? "all";
|
||
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as PrListState;
|
||
throw new Error(`unsupported gh pr list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`);
|
||
}
|
||
|
||
function parseBodyUpdateMode(args: string[]): BodyUpdateMode {
|
||
const raw = optionValue(args, "--mode") ?? "replace";
|
||
if ((BODY_UPDATE_MODES as readonly string[]).includes(raw)) return raw as BodyUpdateMode;
|
||
throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`);
|
||
}
|
||
|
||
function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
|
||
const raw = optionValue(args, "--body-profile") ?? "auto";
|
||
if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw;
|
||
throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`);
|
||
}
|
||
|
||
function parseBoardMutationSection(args: string[], name: "--section" | "--to"): BoardMutationSection | undefined {
|
||
const raw = optionValue(args, name);
|
||
if (raw === undefined) return undefined;
|
||
return validateEnumValue(name, raw, BOARD_MUTATION_SECTIONS);
|
||
}
|
||
|
||
function parseBoardGithubStatus(args: string[]): BoardGithubStatus | undefined {
|
||
const raw = optionValue(args, "--status");
|
||
if (raw === undefined) return undefined;
|
||
return validateEnumValue("--status", raw, BOARD_GITHUB_STATUSES);
|
||
}
|
||
|
||
function parseBoardRowField(args: string[]): BoardRowField | undefined {
|
||
const raw = optionValue(args, "--field");
|
||
if (raw === undefined) return undefined;
|
||
return validateEnumValue("--field", raw, BOARD_ROW_FIELDS);
|
||
}
|
||
|
||
function parseBoardRowUpsertValues(args: string[]): BoardRowUpsertValues {
|
||
return {
|
||
category: optionValue(args, "--category"),
|
||
branch: optionValue(args, "--branch"),
|
||
tasks: optionValue(args, "--tasks"),
|
||
summary: optionValue(args, "--summary"),
|
||
focus: optionValue(args, "--focus"),
|
||
validation: optionValue(args, "--validation"),
|
||
progress: optionValue(args, "--progress"),
|
||
status: parseBoardGithubStatus(args),
|
||
};
|
||
}
|
||
|
||
function validateKnownOptions(args: string[]): void {
|
||
const valueOptions = new Set(["--repo", "--limit", "--board-issue", "--known-meta-issue", "--ignore-issue", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field", "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress"]);
|
||
const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat"]);
|
||
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");
|
||
const limitMax = top === "pr" && (sub === "files" || sub === "diff") ? MAX_PR_FILES_LIMIT : 100;
|
||
return {
|
||
repo: optionValue(args, "--repo") ?? DEFAULT_REPO,
|
||
dryRun: hasFlag(args, "--dry-run"),
|
||
raw: hasFlag(args, "--raw"),
|
||
full: hasFlag(args, "--full"),
|
||
limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : 30, limitMax),
|
||
boardIssue: positiveIntegerSingleOption(args, "--board-issue", CODE_QUEUE_BOARD_TARGET_ISSUE),
|
||
knownMetaIssues: positiveIntegerValuesOption(args, "--known-meta-issue"),
|
||
ignoredIssues: positiveIntegerValuesOption(args, "--ignore-issue"),
|
||
draft: hasFlag(args, "--draft"),
|
||
notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"),
|
||
allowShortBody: hasFlag(args, "--allow-short-body"),
|
||
labels: labelsOption(args),
|
||
title: optionValue(args, "--title"),
|
||
body: optionValue(args, "--body"),
|
||
bodyFile: optionValue(args, "--body-file"),
|
||
base: optionValue(args, "--base"),
|
||
head: optionValue(args, "--head"),
|
||
jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined,
|
||
issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined,
|
||
prListJsonFields: top === "pr" && sub === "list" ? parsePrListJsonFields(requestedJsonFields) : undefined,
|
||
prJsonFields: top === "pr" && isPrReadCommand(sub) ? parsePrReadJsonFields(requestedJsonFields) : undefined,
|
||
listState: top === "issue" && (sub === "list" || sub === "board-row" && args[2] === "list") ? parseIssueListState(args) : "open",
|
||
prListState: top === "pr" && sub === "list" ? parsePrListState(args) : "all",
|
||
mode: parseBodyUpdateMode(args),
|
||
expectUpdatedAt: optionValue(args, "--expect-updated-at"),
|
||
expectBodySha: optionValue(args, "--expect-body-sha"),
|
||
bodyProfile: parseIssueBodyProfile(args),
|
||
boardRowField: parseBoardRowField(args),
|
||
boardRowValue: optionValue(args, "--value"),
|
||
boardRowFile: optionValue(args, "--row-file"),
|
||
boardSection: parseBoardMutationSection(args, "--section"),
|
||
boardMoveTo: parseBoardMutationSection(args, "--to"),
|
||
boardGithubStatus: parseBoardGithubStatus(args),
|
||
boardRowUpsertValues: parseBoardRowUpsertValues(args),
|
||
};
|
||
}
|
||
|
||
function parseNumber(raw: string | undefined, label: string): number {
|
||
if (raw === undefined) throw new Error(`${label} requires a number`);
|
||
const value = Number(raw);
|
||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`);
|
||
return value;
|
||
}
|
||
|
||
function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult {
|
||
try {
|
||
return parseNumber(raw, label);
|
||
} catch (error) {
|
||
return validationError(label, repo, error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
|
||
function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthandReference | null {
|
||
if (raw === undefined) return null;
|
||
const match = /^([^/#\s]+)\/([^/#\s]+)#([1-9]\d*)$/u.exec(raw);
|
||
if (match === null) return null;
|
||
return {
|
||
input: raw,
|
||
repo: `${match[1]}/${match[2]}`,
|
||
number: Number(match[3]),
|
||
};
|
||
}
|
||
|
||
function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] {
|
||
return [
|
||
`bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`,
|
||
`bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`,
|
||
];
|
||
}
|
||
|
||
function readViewSupportedJsonFields(kind: "issue" | "pr"): string {
|
||
return kind === "issue"
|
||
? "body,title,state,comments"
|
||
: "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
|
||
}
|
||
|
||
function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "view", raw: string | undefined, options: GitHubOptions, args: string[]): GitHubResolvedNumberReference | GitHubCommandResult {
|
||
const command = `${kind} ${sub}`;
|
||
const shorthand = parseOwnerRepoNumberShorthand(raw);
|
||
if (shorthand !== null) {
|
||
const explicitRepo = optionValue(args, "--repo");
|
||
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
|
||
const message = `${command} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided. Use either the shorthand or a matching --repo, not both.`;
|
||
return validationError(command, explicitRepo, message, {
|
||
message,
|
||
shorthand,
|
||
explicitRepo,
|
||
supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number),
|
||
});
|
||
}
|
||
return { repo: shorthand.repo, number: shorthand.number, shorthand };
|
||
}
|
||
const parsed = parseNumberForCommand(options.repo, raw, command);
|
||
if (typeof parsed !== "number") {
|
||
return {
|
||
...parsed,
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${kind} read <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
|
||
`bun scripts/cli.ts gh ${kind} read owner/name#<number> --raw`,
|
||
],
|
||
};
|
||
}
|
||
return { repo: options.repo, number: parsed };
|
||
}
|
||
|
||
function issueReadJsonFields(options: GitHubOptions): IssueViewJsonField[] | undefined {
|
||
return options.raw || options.full ? ISSUE_VIEW_JSON_FIELDS.slice() : options.jsonFields;
|
||
}
|
||
|
||
function prReadJsonFields(options: GitHubOptions): PrReadJsonField[] | undefined {
|
||
return options.raw || options.full ? PR_READ_JSON_FIELDS.slice() : options.prJsonFields;
|
||
}
|
||
|
||
function readDisclosureOptions(options: GitHubOptions, shorthand: GitHubShorthandReference | undefined): Record<string, unknown> | null {
|
||
if (!options.raw && !options.full && shorthand === undefined) return null;
|
||
return {
|
||
...(options.raw ? { raw: true } : {}),
|
||
...(options.full ? { full: true } : {}),
|
||
fullDisclosure: options.raw || options.full,
|
||
shorthand: shorthand ?? null,
|
||
};
|
||
}
|
||
|
||
function isGitHubCommandResult(value: GitHubResolvedNumberReference | GitHubCommandResult): value is GitHubCommandResult {
|
||
return typeof (value as { ok?: unknown }).ok === "boolean";
|
||
}
|
||
|
||
function unknownGhOptionDetails(args: string[], option: string): Record<string, unknown> {
|
||
const [top, sub, third] = args;
|
||
const details: Record<string, unknown> = {
|
||
unsupportedOption: option,
|
||
helpCommand: "bun scripts/cli.ts gh help",
|
||
};
|
||
if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) {
|
||
const shorthand = parseOwnerRepoNumberShorthand(third);
|
||
const repo = shorthand?.repo ?? optionValue(args, "--repo") ?? "owner/name";
|
||
const number = shorthand?.number ?? (third !== undefined && /^\d+$/u.test(third) ? Number(third) : 0);
|
||
details.supportedCommands = number > 0
|
||
? readViewSupportedCommands(top, repo, number)
|
||
: [
|
||
`bun scripts/cli.ts gh ${top} read <number> --repo owner/name --json ${readViewSupportedJsonFields(top)}`,
|
||
`bun scripts/cli.ts gh ${top} read owner/name#<number> --raw`,
|
||
];
|
||
}
|
||
return details;
|
||
}
|
||
|
||
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 readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource: Record<string, unknown> } {
|
||
if (path === "-") {
|
||
return { body: readFileSync(0, "utf8"), bodySource: { kind: "stdin", path: "-" } };
|
||
}
|
||
if (!existsSync(path)) throw new Error(`body file not found: ${path}`);
|
||
return { body: readFileSync(path, "utf8"), bodySource: { kind: "body-file", path } };
|
||
}
|
||
|
||
function readOptionalMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
|
||
if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file or --body`);
|
||
if (options.bodyFile !== undefined) {
|
||
return readMarkdownBodyFileOrStdin(options.bodyFile);
|
||
}
|
||
if (options.body !== undefined) {
|
||
return {
|
||
body: options.body,
|
||
bodySource: {
|
||
kind: "inline",
|
||
warning: options.body.includes("\n") ? "inline body contains real newlines; --body-file is safer for generated Markdown" : "inline body is intended only for short single-line text",
|
||
},
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } {
|
||
const body = readOptionalMarkdownBody(options, command);
|
||
if (body !== null) return body;
|
||
throw new Error(`${command} requires --body-file <file> or --body <text>`);
|
||
}
|
||
|
||
function 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 isCommanderBriefUpdateHeading(line: string): boolean {
|
||
const trimmed = line.trimEnd();
|
||
return COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS.some((pattern) => pattern.test(trimmed));
|
||
}
|
||
|
||
function commanderBriefUpdateHeadings(body: string): string[] {
|
||
return normalizeNewlines(body)
|
||
.split("\n")
|
||
.map((line) => line.trimEnd())
|
||
.filter((line) => isCommanderBriefUpdateHeading(line));
|
||
}
|
||
|
||
function codeQueueBoardHwlabProductRoutingFindings(body: string | undefined): Array<Record<string, unknown>> {
|
||
if (body === undefined) return [];
|
||
const findings: Array<Record<string, unknown>> = [];
|
||
const directIssuePatterns = [
|
||
{ kind: "hwlab-repo-issue-url", pattern: /(?:https?:\/\/github\.com\/)?pikasTech\/HWLAB\/issues\/\d+/iu },
|
||
{ kind: "hwlab-repo-issue-short-ref", pattern: /\bpikasTech\/HWLAB#\d+\b/iu },
|
||
{ kind: "hwlab-short-issue-ref", pattern: /\bHWLAB#\d+\b/iu },
|
||
];
|
||
const hwlabContextPattern = /\bHWLAB\b|pikasTech\/HWLAB|hwlab-/iu;
|
||
const strongProductPattern = /hwlab-cloud-web|Cloud Workbench|DEV-LIVE|res_boxsimu|hwlab-patch-panel|patch panel|M3\s*(?:虚拟硬件|可信闭环)/iu;
|
||
const genericProductPattern = /用户反馈|user feedback/iu;
|
||
const governanceContextPattern = /\b(?:commander|Code Queue|CLI|infra|governance|guard|guardrail|routing|misfile|board)\b|治理|调度|基础设施|守卫|边界|分流|看板/iu;
|
||
const lines = normalizeNewlines(body).split("\n");
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const line = lines[index];
|
||
const isTableRow = line.trim().startsWith("|") && !isMarkdownTableSeparator(line);
|
||
const strongProductSignal = strongProductPattern.test(line);
|
||
const genericProductSignal = genericProductPattern.test(line);
|
||
const governanceContext = governanceContextPattern.test(line);
|
||
for (const definition of directIssuePatterns) {
|
||
const match = definition.pattern.exec(line);
|
||
if (match !== null && !governanceContext && (isTableRow || strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) {
|
||
findings.push({
|
||
kind: definition.kind,
|
||
lineNumber: index + 1,
|
||
match: match[0],
|
||
snippet: preview(line.trim()),
|
||
});
|
||
}
|
||
}
|
||
if (isTableRow && !governanceContext && (strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) {
|
||
const match = strongProductSignal ? strongProductPattern.exec(line) : genericProductPattern.exec(line);
|
||
findings.push({
|
||
kind: "hwlab-product-signal",
|
||
lineNumber: index + 1,
|
||
match: match?.[0] ?? "HWLAB product signal",
|
||
snippet: preview(line.trim()),
|
||
});
|
||
}
|
||
if (findings.length >= 12) break;
|
||
}
|
||
return findings;
|
||
}
|
||
|
||
function codeQueueBoardHwlabProductRoutingHint(boardIssueNumber: number, body?: string): Record<string, unknown> | null {
|
||
if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null;
|
||
const findings = codeQueueBoardHwlabProductRoutingFindings(body);
|
||
return {
|
||
warning: "#20 is only for UniDesk commander/Code Queue/CLI/infra governance; HWLAB user/product issues belong in pikasTech/HWLAB.",
|
||
route: "Create or update the corresponding issue in pikasTech/HWLAB; keep #20 rows limited to UniDesk governance or infrastructure support work.",
|
||
forbiddenPatterns: [
|
||
"pikasTech/HWLAB#<N>",
|
||
"github.com/pikasTech/HWLAB/issues/<N>",
|
||
"HWLAB#<N>",
|
||
"HWLAB product/live validation rows",
|
||
],
|
||
allowedScope: ["commander governance", "Code Queue supervision", "UniDesk CLI guardrails", "UniDesk infrastructure"],
|
||
detected: findings.length > 0,
|
||
findings,
|
||
};
|
||
}
|
||
|
||
function codeQueueBoardHintHasHwlabProductRouting(hint: Record<string, unknown> | null): boolean {
|
||
if (hint === null) return false;
|
||
const routing = hint.hwlabProductRouting;
|
||
return typeof routing === "object" && routing !== null && (routing as { detected?: unknown }).detected === true;
|
||
}
|
||
|
||
function codeQueueBoardHintHasCommanderBriefUpdates(hint: Record<string, unknown> | null): boolean {
|
||
if (hint === null) return false;
|
||
const commanderBrief = hint.commanderBrief;
|
||
return typeof commanderBrief === "object" && commanderBrief !== null && (commanderBrief as { detected?: unknown }).detected === true;
|
||
}
|
||
|
||
function codeQueueBoardCommanderBriefHint(boardIssueNumber: number, body?: string): Record<string, unknown> | null {
|
||
if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null;
|
||
const headings = body === undefined ? [] : commanderBriefUpdateHeadings(body);
|
||
const hwlabProductRouting = codeQueueBoardHwlabProductRoutingHint(boardIssueNumber, body);
|
||
const commanderBriefDetected = headings.length > 0;
|
||
const hwlabProductDetected = codeQueueBoardHintHasHwlabProductRouting({ hwlabProductRouting });
|
||
return {
|
||
warning: "#20 is the long-term UniDesk commander/Code Queue/CLI/infra governance board only; do not write daily commander brief updates or HWLAB product/user issue tracking into #20.",
|
||
route: commanderBriefDetected
|
||
? "Move daily progress notes to the daily rolling commander brief issue referenced in #20's 指挥简报索引, using --body-profile commander-brief."
|
||
: hwlabProductDetected
|
||
? "Move HWLAB product/user issue tracking to pikasTech/HWLAB; keep #20 limited to UniDesk commander/Code Queue/CLI/infra governance."
|
||
: "Use #20 only for UniDesk commander/Code Queue/CLI/infra governance rows.",
|
||
forbiddenHeadings: ["## 更新 YYYY-MM-DD HH:mm 北京时间", "## YYYY-MM-DD HH:mm 北京时间指挥更新", "### YYYY-MM-DD HH:mm CST:..."],
|
||
suggestedCommand: "bun scripts/cli.ts gh issue update <daily-brief-issue> --mode replace --body-file <file> --body-profile commander-brief --expect-body-sha <sha>",
|
||
detected: commanderBriefDetected || hwlabProductDetected,
|
||
detectedHeadings: headings,
|
||
commanderBrief: {
|
||
detected: commanderBriefDetected,
|
||
detectedHeadings: headings,
|
||
},
|
||
hwlabProductRouting,
|
||
};
|
||
}
|
||
|
||
function extractTimelineSections(markdown: string): CommanderBriefSection[] {
|
||
const normalized = normalizeNewlines(markdown);
|
||
const lines = normalized.split("\n");
|
||
const sections: CommanderBriefSection[] = [];
|
||
let currentStart = -1;
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
if (!isTimelineHeading(lines[index])) continue;
|
||
if (currentStart !== -1) {
|
||
sections.push({
|
||
heading: lines[currentStart].trimEnd(),
|
||
text: lines.slice(currentStart, index).join("\n").trimEnd(),
|
||
startLine: currentStart + 1,
|
||
});
|
||
}
|
||
currentStart = index;
|
||
}
|
||
if (currentStart !== -1) {
|
||
sections.push({
|
||
heading: lines[currentStart].trimEnd(),
|
||
text: lines.slice(currentStart).join("\n").trimEnd(),
|
||
startLine: currentStart + 1,
|
||
});
|
||
}
|
||
return sections.filter((section) => section.text.length > 0);
|
||
}
|
||
|
||
export function commanderBriefDiff(oldBodyRaw: string, newBodyRaw: string): CommanderBriefDiff {
|
||
const oldBody = normalizeNewlines(oldBodyRaw);
|
||
const newBody = normalizeNewlines(newBodyRaw);
|
||
if (oldBody === newBody) {
|
||
return {
|
||
ok: false,
|
||
mode: "identical",
|
||
message: "",
|
||
chars: 0,
|
||
sections: [],
|
||
sectionCount: 0,
|
||
skippedReason: "issue body is unchanged",
|
||
};
|
||
}
|
||
|
||
if (newBody.startsWith(oldBody)) {
|
||
const suffix = newBody.slice(oldBody.length);
|
||
const sections = extractTimelineSections(suffix).map((section) => section.text);
|
||
const message = sections.join("\n\n").trim();
|
||
if (message.length === 0) {
|
||
return {
|
||
ok: false,
|
||
mode: "append-only",
|
||
message: "",
|
||
chars: 0,
|
||
sections: [],
|
||
sectionCount: 0,
|
||
skippedReason: "append-only suffix contains no new commander brief update section",
|
||
};
|
||
}
|
||
return { ok: true, mode: "append-only", message, chars: message.length, sections, sectionCount: sections.length };
|
||
}
|
||
|
||
const oldSections = new Set(extractTimelineSections(oldBody).map((section) => section.text));
|
||
const allNewSections = extractTimelineSections(newBody);
|
||
const newSections = allNewSections.map((section) => section.text).filter((section) => !oldSections.has(section));
|
||
if (newSections.length === 0) {
|
||
return {
|
||
ok: false,
|
||
mode: "heading-diff",
|
||
message: "",
|
||
chars: 0,
|
||
sections: [],
|
||
sectionCount: 0,
|
||
skippedReason: "no new commander brief update section found; non-timeline edits are not notified",
|
||
};
|
||
}
|
||
const duplicateNewHeadings = allNewSections.some((section, index) => allNewSections.findIndex((candidate) => candidate.heading === section.heading) !== index);
|
||
const oldHeadings = new Set(extractTimelineSections(oldBody).map((section) => section.heading));
|
||
const ambiguousChangedHeadings = allNewSections.some((section) => oldHeadings.has(section.heading) && !oldSections.has(section.text));
|
||
if (duplicateNewHeadings || ambiguousChangedHeadings) {
|
||
return {
|
||
ok: false,
|
||
mode: "unreliable",
|
||
message: "",
|
||
chars: 0,
|
||
sections: [],
|
||
sectionCount: 0,
|
||
skippedReason: duplicateNewHeadings
|
||
? "duplicate update headings make commander brief diff unreliable"
|
||
: "an existing update section changed; cannot reliably isolate only new timeline content",
|
||
};
|
||
}
|
||
const message = newSections.join("\n\n").trim();
|
||
return { ok: true, mode: "heading-diff", message, chars: message.length, sections: newSections, sectionCount: newSections.length };
|
||
}
|
||
|
||
function envEnabled(name: string, defaultValue: boolean): boolean {
|
||
const raw = process.env[name];
|
||
if (raw === undefined || raw.length === 0) return defaultValue;
|
||
return !["0", "false", "no", "off", "disabled"].includes(raw.toLowerCase());
|
||
}
|
||
|
||
function positiveEnvInteger(name: string, defaultValue: number): number {
|
||
const raw = process.env[name];
|
||
if (raw === undefined || raw.length === 0) return defaultValue;
|
||
const value = Number(raw);
|
||
return Number.isInteger(value) && value > 0 ? value : defaultValue;
|
||
}
|
||
|
||
function commanderBriefClaudeQqConfig(): ClaudeQqConfig {
|
||
const targetTypeRaw = (process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE ?? "private").toLowerCase();
|
||
const targetType: ClaudeQqTargetType = targetTypeRaw === "group" ? "group" : "private";
|
||
return {
|
||
enabled: envEnabled("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED", true),
|
||
baseUrl: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL,
|
||
targetType,
|
||
userId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID,
|
||
groupId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID,
|
||
timeoutMs: positiveEnvInteger("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS", 15_000),
|
||
};
|
||
}
|
||
|
||
function maskedTarget(config: ClaudeQqConfig): Record<string, unknown> {
|
||
if (config.targetType === "group") return { targetType: "group", groupId: config.groupId === undefined ? null : maskId(config.groupId) };
|
||
return { targetType: "private", userId: config.userId === undefined ? null : maskId(config.userId) };
|
||
}
|
||
|
||
function maskId(value: string): string {
|
||
if (value.length <= 4) return "*".repeat(value.length);
|
||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||
}
|
||
|
||
function sanitizeUrlForOutput(value: string): string {
|
||
try {
|
||
const parsed = new URL(value);
|
||
parsed.username = parsed.username.length > 0 ? "***" : "";
|
||
parsed.password = parsed.password.length > 0 ? "***" : "";
|
||
parsed.search = parsed.search.length > 0 ? "?..." : "";
|
||
parsed.hash = "";
|
||
return parsed.toString();
|
||
} catch {
|
||
return value.includes("?") ? `${value.split("?")[0]}?...` : value;
|
||
}
|
||
}
|
||
|
||
function claudeQqPayload(config: ClaudeQqConfig, message: string): Record<string, unknown> {
|
||
if (config.targetType === "group") return { targetType: "group", groupId: config.groupId ?? "", message };
|
||
return { targetType: "private", userId: config.userId ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, message };
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function proxiedClaudeQqBasePath(baseUrl: string): string | null {
|
||
try {
|
||
const parsed = new URL(baseUrl);
|
||
const path = parsed.pathname.replace(/\/$/u, "");
|
||
if (parsed.hostname === "backend-core" && path === "/api/microservices/claudeqq/proxy") return path;
|
||
if ((parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.port === "8080" && path === "/api/microservices/claudeqq/proxy") return path;
|
||
return null;
|
||
} catch {
|
||
return baseUrl === "/api/microservices/claudeqq/proxy" ? baseUrl : null;
|
||
}
|
||
}
|
||
|
||
function normalizeClaudeQqEndpoint(basePath: string, endpoint: string): string {
|
||
return `${basePath.replace(/\/$/u, "")}${endpoint}`;
|
||
}
|
||
|
||
function claudeQqResponseOk(response: unknown): boolean {
|
||
if (!isRecord(response)) return false;
|
||
if (response.ok === false) return false;
|
||
if (typeof response.status === "number" && (response.status < 200 || response.status >= 300)) return false;
|
||
const body = response.body;
|
||
if (isRecord(body) && (body.ok === false || body.success === false)) return false;
|
||
return response.ok === true || typeof response.status === "number";
|
||
}
|
||
|
||
function summarizeClaudeQqProxyFailure(response: unknown, endpoint: string): ClaudeQqEndpointResult {
|
||
if (!isRecord(response)) return { ok: false, endpoint, degradedReason: "invalid-response", message: "ClaudeQQ proxy returned a non-object response", response };
|
||
const status = typeof response.status === "number" ? response.status : undefined;
|
||
const stdoutTail = typeof response.stdoutTail === "string" ? response.stdoutTail : "";
|
||
let parsedTail: unknown = null;
|
||
if (stdoutTail.length > 0) {
|
||
try {
|
||
parsedTail = JSON.parse(stdoutTail.trim()) as unknown;
|
||
} catch {
|
||
parsedTail = null;
|
||
}
|
||
}
|
||
return {
|
||
ok: false,
|
||
endpoint,
|
||
status,
|
||
degradedReason: response.ok === false ? "microservice-proxy-failed" : "invalid-response",
|
||
message: typeof response.error === "string"
|
||
? response.error
|
||
: typeof response.stderrTail === "string" && response.stderrTail.length > 0
|
||
? response.stderrTail.slice(-500)
|
||
: "ClaudeQQ proxy request failed",
|
||
response: parsedTail ?? response,
|
||
};
|
||
}
|
||
|
||
async function sendClaudeQqEndpoint(config: ClaudeQqConfig, endpoint: string, payload: Record<string, unknown>): Promise<ClaudeQqEndpointResult> {
|
||
const basePath = proxiedClaudeQqBasePath(config.baseUrl);
|
||
if (basePath !== null) {
|
||
const response = coreInternalFetch(normalizeClaudeQqEndpoint(basePath, endpoint), {
|
||
method: "POST",
|
||
body: payload,
|
||
maxResponseBytes: 240_000,
|
||
});
|
||
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 errorProperty(value: unknown, key: string): string | null {
|
||
if (!isRecord(value)) return null;
|
||
const item = value[key];
|
||
return typeof item === "string" && item.length > 0 ? item : null;
|
||
}
|
||
|
||
function errorChainText(error: unknown): string {
|
||
const parts: string[] = [];
|
||
let current: unknown = error;
|
||
const seen = new Set<unknown>();
|
||
while (current !== null && current !== undefined && !seen.has(current)) {
|
||
seen.add(current);
|
||
if (current instanceof Error) {
|
||
parts.push(current.name, current.message);
|
||
current = (current as Error & { cause?: unknown }).cause;
|
||
continue;
|
||
}
|
||
if (isRecord(current)) {
|
||
for (const key of ["name", "message", "code", "errno", "syscall", "hostname", "host"]) {
|
||
const value = errorProperty(current, key);
|
||
if (value !== null) parts.push(value);
|
||
}
|
||
current = current.cause;
|
||
continue;
|
||
}
|
||
parts.push(String(current));
|
||
break;
|
||
}
|
||
return parts.join(" ").replace(/\s+/gu, " ").trim();
|
||
}
|
||
|
||
function firstErrorProperty(error: unknown, key: string): string | null {
|
||
let current: unknown = error;
|
||
const seen = new Set<unknown>();
|
||
while (current !== null && current !== undefined && !seen.has(current)) {
|
||
seen.add(current);
|
||
const value = errorProperty(current, key);
|
||
if (value !== null) return value;
|
||
current = isRecord(current) ? current.cause : current instanceof Error ? (current as Error & { cause?: unknown }).cause : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function classifyGitHubFetchFailure(error: unknown): {
|
||
reason: GitHubDegradedReason;
|
||
message: string;
|
||
retryable?: boolean;
|
||
commanderAction?: string;
|
||
details: Record<string, unknown>;
|
||
} {
|
||
const text = errorChainText(error);
|
||
const lower = text.toLowerCase();
|
||
const isProxyFailure = /\bproxy\b/u.test(lower) && /could not resolve|connect|tunnel|proxy/i.test(text);
|
||
const isGitHubTransient = !isProxyFailure && (
|
||
/temporary failure in name resolution/iu.test(text)
|
||
|| /\b(eai_again|enotfound|etimedout|econnreset|econnrefused|ehostunreach|enetunreach)\b/iu.test(text)
|
||
|| /getaddrinfo|could not resolve host|name or service not known|failed to connect|connection timed out|connection reset|connection closed|other side closed|network is unreachable|socket connection/iu.test(text)
|
||
);
|
||
if (!isGitHubTransient) {
|
||
return {
|
||
reason: "network-proxy-failed",
|
||
message: text.length > 0 ? text : "GitHub request failed before receiving an HTTP response",
|
||
details: {
|
||
scope: "github-api",
|
||
transient: false,
|
||
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
|
||
errorCode: firstErrorProperty(error, "code"),
|
||
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
reason: "github-transient",
|
||
message: text.length > 0 ? text : "GitHub DNS/API connection failed before receiving an HTTP response",
|
||
retryable: true,
|
||
commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh",
|
||
details: {
|
||
scope: "github-api",
|
||
transient: true,
|
||
retryable: true,
|
||
authFailure: false,
|
||
semanticFailure: false,
|
||
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
|
||
errorCode: firstErrorProperty(error, "code"),
|
||
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
|
||
commanderAction: "retry/backoff; if the Code Queue task heartbeat or trace is fresh, keep supervising instead of closing or requeueing business work",
|
||
valuesPrinted: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload {
|
||
return {
|
||
ok: false,
|
||
degradedReason: reason,
|
||
runnerDisposition: runnerDisposition(reason),
|
||
message,
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record<string, unknown> = {}): GitHubCommandResult {
|
||
return {
|
||
ok: false,
|
||
command,
|
||
repo,
|
||
degradedReason: error.degradedReason,
|
||
runnerDisposition: error.runnerDisposition,
|
||
details: error,
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
function validationError(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
|
||
const error = errorPayload("validation-failed", message, { details: extra });
|
||
return commandError(command, repo, error, extra);
|
||
}
|
||
|
||
function unsupportedCommand(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
|
||
const error = errorPayload("unsupported-command", message, { details: extra });
|
||
return commandError(command, repo, error, { message, ...extra });
|
||
}
|
||
|
||
async function parseGitHubResponse(response: Response): Promise<unknown> {
|
||
const text = await response.text();
|
||
if (text.length === 0) return null;
|
||
try {
|
||
return JSON.parse(text) as unknown;
|
||
} catch {
|
||
return { raw: preview(text) };
|
||
}
|
||
}
|
||
|
||
function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason {
|
||
const lower = message.toLowerCase();
|
||
const acceptedScopes = response.headers.get("x-accepted-oauth-scopes");
|
||
if (status === 401) return "auth-failed";
|
||
if (status === 403) {
|
||
if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient";
|
||
if (path.startsWith("/repos/")) return "repo-forbidden";
|
||
return "permission-denied";
|
||
}
|
||
if (status === 404) {
|
||
if (/\/pulls\/\d+/u.test(path)) return "pr-not-found";
|
||
if (/\/issues\/\d+/u.test(path)) return "issue-not-found";
|
||
if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed";
|
||
return "repo-not-found";
|
||
}
|
||
if (status === 422) return "validation-failed";
|
||
return "invalid-response";
|
||
}
|
||
|
||
async function githubRequest<T>(
|
||
token: string,
|
||
method: string,
|
||
path: string,
|
||
body?: Record<string, unknown>,
|
||
): Promise<T | GitHubErrorPayload> {
|
||
let response: Response;
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||
try {
|
||
response = await fetch(`${GITHUB_API}${path}`, {
|
||
method,
|
||
signal: controller.signal,
|
||
headers: {
|
||
Accept: "application/vnd.github+json",
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
"User-Agent": USER_AGENT,
|
||
"X-GitHub-Api-Version": "2022-11-28",
|
||
},
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
});
|
||
} catch (error) {
|
||
const classified = classifyGitHubFetchFailure(error);
|
||
return errorPayload(classified.reason, classified.message, {
|
||
request: { method, path },
|
||
details: classified.details,
|
||
retryable: classified.retryable,
|
||
commanderAction: classified.commanderAction,
|
||
});
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
const parsed = await parseGitHubResponse(response);
|
||
if (!response.ok) {
|
||
const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
|
||
return errorPayload(classifyHttpStatus(response.status, message, path, response), message, {
|
||
status: response.status,
|
||
details: sanitizedErrorDetails(parsed),
|
||
scopes: {
|
||
accepted: response.headers.get("x-accepted-oauth-scopes"),
|
||
token: response.headers.get("x-oauth-scopes"),
|
||
},
|
||
request: { method, path },
|
||
});
|
||
}
|
||
return parsed as T;
|
||
}
|
||
|
||
async function githubGraphqlRequest<T>(
|
||
token: string,
|
||
query: string,
|
||
variables: Record<string, unknown>,
|
||
): Promise<T | GitHubErrorPayload> {
|
||
let response: Response;
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||
try {
|
||
response = await fetch(`${GITHUB_API}/graphql`, {
|
||
method: "POST",
|
||
signal: controller.signal,
|
||
headers: {
|
||
Accept: "application/vnd.github+json",
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
"User-Agent": USER_AGENT,
|
||
"X-GitHub-Api-Version": "2022-11-28",
|
||
},
|
||
body: JSON.stringify({ query, variables }),
|
||
});
|
||
} catch (error) {
|
||
const classified = classifyGitHubFetchFailure(error);
|
||
return errorPayload(classified.reason, classified.message, {
|
||
request: { method: "POST", path: "/graphql" },
|
||
details: classified.details,
|
||
retryable: classified.retryable,
|
||
commanderAction: classified.commanderAction,
|
||
});
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
const parsed = await parseGitHubResponse(response);
|
||
if (!response.ok) {
|
||
const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
|
||
return errorPayload(classifyHttpStatus(response.status, message, "/graphql", response), message, {
|
||
status: response.status,
|
||
details: sanitizedErrorDetails(parsed),
|
||
scopes: {
|
||
accepted: response.headers.get("x-accepted-oauth-scopes"),
|
||
token: response.headers.get("x-oauth-scopes"),
|
||
},
|
||
request: { method: "POST", path: "/graphql" },
|
||
});
|
||
}
|
||
if (!isRecord(parsed)) {
|
||
return errorPayload("invalid-response", "GitHub GraphQL response was not an object", { details: parsed });
|
||
}
|
||
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
||
const firstError = parsed.errors[0];
|
||
const message = isRecord(firstError) && typeof firstError.message === "string" ? firstError.message : "GitHub GraphQL query failed";
|
||
return errorPayload("validation-failed", message, {
|
||
details: sanitizedErrorDetails(parsed),
|
||
request: { method: "POST", path: "/graphql" },
|
||
});
|
||
}
|
||
const data = parsed.data;
|
||
if (!isRecord(data)) {
|
||
return errorPayload("invalid-response", "GitHub GraphQL response did not include an object data payload", { details: sanitizedErrorDetails(parsed) });
|
||
}
|
||
return data as T;
|
||
}
|
||
|
||
function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null {
|
||
if (!tokenProbe.present) {
|
||
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) {
|
||
return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
|
||
}
|
||
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) {
|
||
return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
|
||
}
|
||
return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), {
|
||
degraded: ["missing-token"],
|
||
token: tokenProbe,
|
||
});
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isGitHubError(value: unknown): value is GitHubErrorPayload {
|
||
return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value;
|
||
}
|
||
|
||
function issueSummary(issue: GitHubIssue): Record<string, unknown> {
|
||
return {
|
||
id: issue.id,
|
||
number: issue.number,
|
||
title: issue.title,
|
||
body: issue.body ?? "",
|
||
state: issue.state,
|
||
url: issue.html_url,
|
||
author: issue.user?.login ?? null,
|
||
createdAt: issue.created_at ?? null,
|
||
updatedAt: issue.updated_at ?? null,
|
||
};
|
||
}
|
||
|
||
function labelSummary(label: string | { name?: string; color?: string; description?: string | null }): Record<string, unknown> {
|
||
if (typeof label === "string") return { name: label, color: null, description: null };
|
||
return {
|
||
name: label.name ?? "",
|
||
color: label.color ?? null,
|
||
description: label.description ?? null,
|
||
};
|
||
}
|
||
|
||
function issueLabelNames(issue: GitHubIssue): string[] {
|
||
return (issue.labels ?? []).map((label) => typeof label === "string" ? label : label.name ?? "").filter((label) => label.length > 0);
|
||
}
|
||
|
||
function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Record<string, unknown> {
|
||
const summary: Record<IssueListJsonField, unknown> = {
|
||
number: issue.number,
|
||
title: issue.title,
|
||
state: issue.state,
|
||
url: issue.html_url,
|
||
updatedAt: issue.updated_at ?? null,
|
||
createdAt: issue.created_at ?? null,
|
||
author: issue.user?.login ?? null,
|
||
labels: (issue.labels ?? []).map(labelSummary),
|
||
};
|
||
const selected: Record<string, unknown> = {};
|
||
for (const field of fields) selected[field] = summary[field];
|
||
return selected;
|
||
}
|
||
|
||
function boardIssueEntry(issue: GitHubIssue): BoardIssueEntry {
|
||
return {
|
||
number: issue.number,
|
||
title: issue.title,
|
||
state: issue.state,
|
||
url: issue.html_url,
|
||
};
|
||
}
|
||
|
||
function sortedIssueEntries(issues: BoardIssueEntry[]): BoardIssueEntry[] {
|
||
return issues.slice().sort((a, b) => a.number - b.number);
|
||
}
|
||
|
||
function mergedKnownMetaIssues(options: GitHubOptions): number[] {
|
||
const seen = new Set<number>();
|
||
const values: number[] = [];
|
||
for (const issueNumber of [...DEFAULT_BOARD_KNOWN_META_ISSUES, ...options.knownMetaIssues]) {
|
||
if (seen.has(issueNumber)) continue;
|
||
seen.add(issueNumber);
|
||
values.push(issueNumber);
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function boardIgnoreMap(options: GitHubOptions, issues: BoardIssueEntry[] = []): Map<number, BoardIgnoreReason> {
|
||
const ignored = new Map<number, BoardIgnoreReason>();
|
||
for (const issueNumber of mergedKnownMetaIssues(options)) ignored.set(issueNumber, "known-meta");
|
||
for (const issueNumber of options.ignoredIssues) ignored.set(issueNumber, "ignored");
|
||
if (options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE) {
|
||
for (const issue of issues) {
|
||
if (!ignored.has(issue.number) && isDailyCommanderBriefTitle(issue.title)) ignored.set(issue.number, "brief-index-managed");
|
||
}
|
||
}
|
||
return ignored;
|
||
}
|
||
|
||
function stripMarkdownInline(text: string): string {
|
||
return text
|
||
.replace(/\[[^\]]*]\(([^)]*)\)/g, "$1")
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/[*_`~]/g, "")
|
||
.trim();
|
||
}
|
||
|
||
function normalizeBoardHeader(header: string): string {
|
||
return stripMarkdownInline(header).toLowerCase().replace(/\s+/g, "");
|
||
}
|
||
|
||
function boardHeaderColumnKind(header: string): BoardColumnKind | null {
|
||
const normalized = normalizeBoardHeader(header);
|
||
if (["category", "类别", "分类", "类型", "任务类型"].includes(normalized)) return "category";
|
||
if (["branch", "分支", "目标分支", "工作分支"].includes(normalized)) return "branch";
|
||
if (["acceptance", "验收", "验收状态", "验收结果", "验收标准"].includes(normalized)) return "acceptance";
|
||
if (["relatedtask", "task", "codequeue", "codequeuetask", "相关任务", "任务", "codequeue任务", "cq任务", "相关codequeue任务", "相关codequeuetask"].includes(normalized)) return "relatedTask";
|
||
if (["githubstatus", "githubstate", "github状态", "githubissue状态", "issuestate", "issue状态", "issuegithub状态", "开闭状态", "openclosed", "openclosed状态"].includes(normalized)) return "githubStatus";
|
||
if (["summary", "摘要", "概述", "任务摘要", "任务概述", "标题"].includes(normalized)) return "summary";
|
||
if (["focus", "currentfocus", "关注点", "当前关注点", "当前focus"].includes(normalized)) return "focus";
|
||
if (["progress", "进度", "状态", "当前进度"].includes(normalized)) return "progress";
|
||
return null;
|
||
}
|
||
|
||
function boardColumnIndexMap(headers: string[]): Map<BoardColumnKind, number> {
|
||
const map = new Map<BoardColumnKind, number>();
|
||
headers.forEach((header, headerIndex) => {
|
||
const kind = boardHeaderColumnKind(header);
|
||
if (kind !== null && !map.has(kind)) map.set(kind, headerIndex);
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function boardRequiredColumnIndexMap(headers: string[]): Map<BoardRequiredColumn, number> {
|
||
const map = new Map<BoardRequiredColumn, number>();
|
||
const allColumns = boardColumnIndexMap(headers);
|
||
for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) {
|
||
const index = allColumns.get(column);
|
||
if (index !== undefined) map.set(column, index);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function boardColumnKindForField(field: BoardRowField): BoardColumnKind {
|
||
if (field === "branch") return "branch";
|
||
if (field === "tasks") return "relatedTask";
|
||
if (field === "focus") return "focus";
|
||
if (field === "progress") return "progress";
|
||
return "acceptance";
|
||
}
|
||
|
||
function boardIssueColumnIndex(headers: string[]): number | null {
|
||
for (let index = 0; index < headers.length; index += 1) {
|
||
const normalized = normalizeBoardHeader(headers[index]);
|
||
if (["issue", "issue#", "issuenumber", "issueid", "issue编号", "issue号", "编号"].includes(normalized)) return index;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function markdownCells(line: string): string[] {
|
||
const trimmed = line.trim();
|
||
const withoutOuterPipes = trimmed.replace(/^\|/u, "").replace(/\|$/u, "");
|
||
const cells: string[] = [];
|
||
let current = "";
|
||
for (let index = 0; index < withoutOuterPipes.length; index += 1) {
|
||
const char = withoutOuterPipes[index];
|
||
const previous = withoutOuterPipes[index - 1];
|
||
if (char === "|" && previous !== "\\") {
|
||
cells.push(current.trim().replace(/\\\|/g, "|"));
|
||
current = "";
|
||
continue;
|
||
}
|
||
current += char;
|
||
}
|
||
cells.push(current.trim().replace(/\\\|/g, "|"));
|
||
return cells;
|
||
}
|
||
|
||
function isMarkdownTableSeparator(line: string): boolean {
|
||
const cells = markdownCells(line);
|
||
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/u.test(cell.trim()));
|
||
}
|
||
|
||
function boardSectionKindFromHeading(line: string): BoardSectionKind | null {
|
||
const normalized = line.toLowerCase();
|
||
if (!line.trimStart().startsWith("#")) return null;
|
||
if (normalized.includes("open") || normalized.includes("看板(open)") || normalized.includes("看板(open)") || normalized.includes("开放")) return "open";
|
||
if (normalized.includes("closed") || normalized.includes("看板(closed)") || normalized.includes("看板(closed)") || normalized.includes("关闭") || normalized.includes("已关闭")) return "closed";
|
||
return null;
|
||
}
|
||
|
||
function extractIssueNumbers(text: string): number[] {
|
||
const numbers: number[] = [];
|
||
const seen = new Set<number>();
|
||
const patterns = [/#(\d+)/g, /\/issues\/(\d+)/g];
|
||
for (const pattern of patterns) {
|
||
pattern.lastIndex = 0;
|
||
let match = pattern.exec(text);
|
||
while (match !== null) {
|
||
const value = Number(match[1]);
|
||
if (Number.isInteger(value) && value > 0 && !seen.has(value)) {
|
||
seen.add(value);
|
||
numbers.push(value);
|
||
}
|
||
match = pattern.exec(text);
|
||
}
|
||
}
|
||
return numbers;
|
||
}
|
||
|
||
function primaryBoardIssueNumberFromIssueCell(issueCell: string | undefined): number | null {
|
||
if (issueCell === undefined) return null;
|
||
const markdownLinkPattern = /\[[^\]]*]\(([^)]*)\)/g;
|
||
let match = markdownLinkPattern.exec(issueCell);
|
||
while (match !== null) {
|
||
const issueNumber = /\/issues\/(\d+)/u.exec(match[1] ?? "")?.[1];
|
||
if (issueNumber !== undefined) {
|
||
const value = Number(issueNumber);
|
||
if (Number.isInteger(value) && value > 0) return value;
|
||
}
|
||
match = markdownLinkPattern.exec(issueCell);
|
||
}
|
||
const leadingIssueReference = /^\s*#(\d+)\b/u.exec(issueCell)?.[1];
|
||
if (leadingIssueReference !== undefined) {
|
||
const value = Number(leadingIssueReference);
|
||
if (Number.isInteger(value) && value > 0) return value;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function boardRowFromCells(section: BoardTableSection, raw: string, cells: string[], lineNumber: number): BoardTableRow {
|
||
const columnMap = boardRequiredColumnIndexMap(section.headers);
|
||
const issueColumnIndex = boardIssueColumnIndex(section.headers);
|
||
const issueCell = issueColumnIndex === null ? undefined : cells[issueColumnIndex];
|
||
const primaryIssueNumber = issueColumnIndex === null ? null : primaryBoardIssueNumberFromIssueCell(issueCell);
|
||
const issueNumbers = issueColumnIndex === null
|
||
? extractIssueNumbers(raw)
|
||
: (primaryIssueNumber === null ? extractIssueNumbers(issueCell ?? "") : [primaryIssueNumber]);
|
||
const columns: Partial<Record<BoardRequiredColumn, string>> = {};
|
||
for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) {
|
||
const cellIndex = columnMap.get(column);
|
||
if (cellIndex !== undefined) columns[column] = cells[cellIndex] ?? "";
|
||
}
|
||
return {
|
||
section: section.kind,
|
||
lineNumber,
|
||
raw,
|
||
cells,
|
||
issueNumbers,
|
||
issueNumber: issueNumbers[0] ?? null,
|
||
title: issueColumnIndex === null
|
||
? (cells.find((cell) => extractIssueNumbers(cell).length > 0) ?? cells[0] ?? null)
|
||
: (issueCell ?? cells[0] ?? null),
|
||
columns,
|
||
};
|
||
}
|
||
|
||
function normalizedBoardCellPlaceholder(value: string): { spaced: string; compact: string } {
|
||
const spaced = stripMarkdownInline(value).toLowerCase().replace(/\s+/g, " ").trim();
|
||
return { spaced, compact: spaced.replace(/\s+/g, "") };
|
||
}
|
||
|
||
function isRelatedTaskNoTaskPlaceholder(value: string): boolean {
|
||
const normalized = normalizedBoardCellPlaceholder(value);
|
||
return [
|
||
"-",
|
||
"—",
|
||
"–",
|
||
"n/a",
|
||
"na",
|
||
"none",
|
||
"no task",
|
||
"no-task",
|
||
"no code queue task",
|
||
"not applicable",
|
||
].includes(normalized.spaced) || [
|
||
"无",
|
||
"无任务",
|
||
"无关联任务",
|
||
"无相关任务",
|
||
"无codequeue任务",
|
||
"无codequeuetask",
|
||
"不适用",
|
||
].includes(normalized.compact);
|
||
}
|
||
|
||
function cellHasMeaningfulValue(value: string | undefined, column: BoardRequiredColumn): boolean {
|
||
if (value === undefined) return false;
|
||
const stripped = stripMarkdownInline(value);
|
||
if (stripped.length === 0) return false;
|
||
if (column === "relatedTask" && isRelatedTaskNoTaskPlaceholder(value)) return true;
|
||
return !["-", "—", "n/a", "na", "todo", "tbd", "待补", "未填", "无"].includes(stripped.toLowerCase());
|
||
}
|
||
|
||
function parseBoardTables(body: string): { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] } {
|
||
const lines = normalizeNewlines(body).split("\n");
|
||
const sections: BoardTableSection[] = [];
|
||
const warnings: BoardRowValidationWarning[] = [];
|
||
let currentKind: BoardSectionKind | null = null;
|
||
let currentHeading = "";
|
||
let currentHeadingLine = 0;
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const headingKind = boardSectionKindFromHeading(lines[index]);
|
||
if (headingKind !== null) {
|
||
currentKind = headingKind;
|
||
currentHeading = lines[index].trim();
|
||
currentHeadingLine = index + 1;
|
||
continue;
|
||
}
|
||
if (currentKind === null) continue;
|
||
if (!lines[index].trim().startsWith("|")) continue;
|
||
const separator = lines[index + 1];
|
||
if (separator === undefined || !separator.trim().startsWith("|") || !isMarkdownTableSeparator(separator)) continue;
|
||
const headers = markdownCells(lines[index]);
|
||
const rows: BoardTableRow[] = [];
|
||
let rowIndex = index + 2;
|
||
while (rowIndex < lines.length && lines[rowIndex].trim().startsWith("|")) {
|
||
if (!isMarkdownTableSeparator(lines[rowIndex])) {
|
||
const cells = markdownCells(lines[rowIndex]);
|
||
const row = boardRowFromCells(
|
||
{ kind: currentKind, heading: currentHeading, headingLine: currentHeadingLine, headerLine: index + 1, headers, rows: [] },
|
||
lines[rowIndex],
|
||
cells,
|
||
rowIndex + 1,
|
||
);
|
||
rows.push(row);
|
||
if (row.issueNumbers.length === 0) {
|
||
warnings.push({
|
||
issueNumber: null,
|
||
section: currentKind,
|
||
lineNumber: row.lineNumber,
|
||
kind: "missing-issue-reference",
|
||
message: "Board table row does not contain a GitHub issue reference such as #36.",
|
||
rowPreview: preview(row.raw),
|
||
});
|
||
}
|
||
if (row.issueNumbers.length > 1) {
|
||
warnings.push({
|
||
issueNumber: row.issueNumber,
|
||
section: currentKind,
|
||
lineNumber: row.lineNumber,
|
||
kind: "multiple-issue-references",
|
||
message: boardIssueColumnIndex(headers) === null
|
||
? "Board table row contains multiple issue references; audit uses the first number as the row key."
|
||
: "Board table row contains multiple issue references in the Issue column; audit uses the first number as the row key.",
|
||
rowPreview: preview(row.raw),
|
||
});
|
||
}
|
||
const missingColumns = BOARD_AUDIT_REQUIRED_COLUMNS.filter((column) => !cellHasMeaningfulValue(row.columns[column], column));
|
||
if (missingColumns.length > 0) {
|
||
warnings.push({
|
||
issueNumber: row.issueNumber,
|
||
section: currentKind,
|
||
lineNumber: row.lineNumber,
|
||
kind: "missing-required-columns",
|
||
message: "Board table row is missing branch, acceptance, related task, or progress information.",
|
||
missingColumns,
|
||
columns: row.columns,
|
||
rowPreview: preview(row.raw),
|
||
});
|
||
}
|
||
}
|
||
rowIndex += 1;
|
||
}
|
||
sections.push({
|
||
kind: currentKind,
|
||
heading: currentHeading,
|
||
headingLine: currentHeadingLine,
|
||
headerLine: index + 1,
|
||
headers,
|
||
rows,
|
||
});
|
||
index = rowIndex - 1;
|
||
}
|
||
return { sections, warnings };
|
||
}
|
||
|
||
function boardRowsByIssue(rows: BoardTableRow[]): Map<number, BoardTableRow[]> {
|
||
const map = new Map<number, BoardTableRow[]>();
|
||
for (const row of rows) {
|
||
if (row.issueNumber === null) continue;
|
||
const list = map.get(row.issueNumber);
|
||
if (list === undefined) map.set(row.issueNumber, [row]);
|
||
else list.push(row);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function boardRowFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowField, string | null> {
|
||
const columnMap = boardColumnIndexMap(section.headers);
|
||
const valueFor = (kind: BoardColumnKind): string | null => {
|
||
const index = columnMap.get(kind);
|
||
return index === undefined ? null : row.cells[index] ?? "";
|
||
};
|
||
const acceptance = valueFor("acceptance");
|
||
return {
|
||
progress: valueFor("progress"),
|
||
status: acceptance,
|
||
validation: acceptance,
|
||
branch: valueFor("branch"),
|
||
tasks: valueFor("relatedTask"),
|
||
focus: valueFor("focus"),
|
||
};
|
||
}
|
||
|
||
function boardRowUpsertFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowUpsertField, string | null> {
|
||
const columnMap = boardColumnIndexMap(section.headers);
|
||
const valueFor = (kind: BoardColumnKind): string | null => {
|
||
const index = columnMap.get(kind);
|
||
return index === undefined ? null : row.cells[index] ?? "";
|
||
};
|
||
const acceptance = valueFor("acceptance");
|
||
return {
|
||
category: valueFor("category"),
|
||
branch: valueFor("branch"),
|
||
tasks: valueFor("relatedTask"),
|
||
summary: valueFor("summary"),
|
||
focus: valueFor("focus"),
|
||
validation: acceptance,
|
||
progress: valueFor("progress"),
|
||
status: valueFor("githubStatus"),
|
||
};
|
||
}
|
||
|
||
function boardRowSummary(section: BoardTableSection, row: BoardTableRow): Record<string, unknown> {
|
||
return {
|
||
issueNumber: row.issueNumber,
|
||
section: row.section,
|
||
lineNumber: row.lineNumber,
|
||
heading: section.heading,
|
||
headers: section.headers,
|
||
title: row.title,
|
||
cells: row.cells,
|
||
fields: boardRowFieldValues(section, row),
|
||
raw: row.raw,
|
||
rowPreview: preview(row.raw),
|
||
};
|
||
}
|
||
|
||
function boardRowsExactMatch(
|
||
sections: BoardTableSection[],
|
||
issueNumber: number,
|
||
): Array<{ section: BoardTableSection; row: BoardTableRow }> {
|
||
return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber);
|
||
}
|
||
|
||
function boardRowsForState(sections: BoardTableSection[], state: IssueListState): Array<{ section: BoardTableSection; row: BoardTableRow }> {
|
||
return sections
|
||
.filter((section) => state === "all" || section.kind === state)
|
||
.flatMap((section) => section.rows.map((row) => ({ section, row })));
|
||
}
|
||
|
||
function boardDuplicateRows(
|
||
sections: BoardTableSection[],
|
||
issueNumber: number,
|
||
): Array<{ section: BoardTableSection; row: BoardTableRow }> {
|
||
return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber);
|
||
}
|
||
|
||
function targetBoardSection(
|
||
repo: string,
|
||
sections: BoardTableSection[],
|
||
kind: BoardMutationSection,
|
||
commandName: string,
|
||
): { ok: true; section: BoardTableSection } | GitHubCommandFailure {
|
||
const matches = sections.filter((section) => section.kind === kind);
|
||
if (matches.length === 0) {
|
||
return validationError(commandName, repo, `board ${kind.toUpperCase()} table was not found`, {
|
||
section: kind,
|
||
availableSections: sections.map((section) => ({ kind: section.kind, heading: section.heading, headerLine: section.headerLine })),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
if (matches.length > 1) {
|
||
return validationError(commandName, repo, `board ${kind.toUpperCase()} table is ambiguous`, {
|
||
section: kind,
|
||
matches: matches.map((section) => ({ heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine })),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return { ok: true, section: matches[0] };
|
||
}
|
||
|
||
function findBoardRow(
|
||
repo: string,
|
||
sections: BoardTableSection[],
|
||
issueNumber: number,
|
||
state: IssueListState = "all",
|
||
): BoardRowLookup | GitHubCommandFailure {
|
||
const matches = boardRowsForState(sections, state).filter(({ row }) => row.issueNumber === issueNumber);
|
||
if (matches.length === 0) {
|
||
return validationError("issue board-row", repo, `board row for issue #${issueNumber} was not found`, {
|
||
issueNumber,
|
||
state,
|
||
availableIssueNumbers: boardRowsForState(sections, state)
|
||
.map(({ row }) => row.issueNumber)
|
||
.filter((value): value is number => value !== null)
|
||
.sort((a, b) => a - b),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
if (matches.length > 1) {
|
||
return validationError("issue board-row", repo, `board row for issue #${issueNumber} is ambiguous`, {
|
||
issueNumber,
|
||
state,
|
||
matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return { ok: true, ...matches[0], matches };
|
||
}
|
||
|
||
function escapeMarkdownTableCell(value: string): string {
|
||
return value
|
||
.replace(/\r\n/g, " ")
|
||
.replace(/[\r\n]+/g, " ")
|
||
.replace(/\|/g, "\\|")
|
||
.trim();
|
||
}
|
||
|
||
function boardRowValuePollutionEvidence(value: string): string[] {
|
||
return shellPollutionEvidence(value).filter((item) => item !== "bare-carriage-return");
|
||
}
|
||
|
||
function renderMarkdownTableRow(cells: string[]): string {
|
||
return `| ${cells.map(escapeMarkdownTableCell).join(" | ")} |`;
|
||
}
|
||
|
||
function validateMarkdownTableRowLine(line: string): string | null {
|
||
if (!line.trim().startsWith("|") || !line.trim().endsWith("|")) return "row-file must contain a Markdown table row starting and ending with |";
|
||
if (isMarkdownTableSeparator(line)) return "row-file must contain a data row, not a Markdown table separator";
|
||
return null;
|
||
}
|
||
|
||
function readBoardRowFile(path: string | undefined, command: string): { path: string; raw: string; cells: string[] } {
|
||
if (path === undefined) throw new Error(`${command} requires --row-file <markdown-row-file>`);
|
||
if (!existsSync(path)) throw new Error(`row file not found: ${path}`);
|
||
const normalized = normalizeNewlines(readFileSync(path, "utf8")).trim();
|
||
if (normalized.length === 0) throw new Error("row-file must contain exactly one non-empty Markdown table row");
|
||
const lines = normalized.split("\n");
|
||
if (lines.length !== 1) throw new Error("row-file must contain exactly one Markdown table row");
|
||
const line = lines[0];
|
||
const lineError = validateMarkdownTableRowLine(line);
|
||
if (lineError !== null) throw new Error(lineError);
|
||
return { path, raw: line, cells: markdownCells(line) };
|
||
}
|
||
|
||
function replaceBoardBodyLine(body: string, lineNumber: number, newLine: string): string {
|
||
const normalized = normalizeNewlines(body);
|
||
const lines = normalized.split("\n");
|
||
if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`);
|
||
lines[lineNumber - 1] = newLine;
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function removeBoardBodyLine(body: string, lineNumber: number): string {
|
||
const normalized = normalizeNewlines(body);
|
||
const lines = normalized.split("\n");
|
||
if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`);
|
||
lines.splice(lineNumber - 1, 1);
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function insertBoardBodyLineAfter(body: string, lineNumber: number, newLine: string): string {
|
||
const normalized = normalizeNewlines(body);
|
||
const lines = normalized.split("\n");
|
||
if (lineNumber < 0 || lineNumber > lines.length) throw new Error(`insert line ${lineNumber} is outside the board body`);
|
||
lines.splice(lineNumber, 0, newLine);
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function boardSectionInsertAfterLine(section: BoardTableSection): number {
|
||
if (section.rows.length === 0) return section.headerLine + 1;
|
||
return section.rows[section.rows.length - 1].lineNumber;
|
||
}
|
||
|
||
function boardBodySafetyIssue(repo: string, boardIssueNumber: number, body: string, options: GitHubOptions): GitHubCommandResult | null {
|
||
const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile };
|
||
return validateIssueBodyGuard(repo, boardIssueNumber, body, guardOptions);
|
||
}
|
||
|
||
function boardBodyGuardSummary(boardIssueNumber: number, body: string, options: GitHubOptions): Record<string, unknown> {
|
||
const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile };
|
||
return issueEditGuardSummary(boardIssueNumber, body, guardOptions);
|
||
}
|
||
|
||
function boardRowUpdatePlan(
|
||
repo: string,
|
||
boardIssue: GitHubIssue,
|
||
issueNumber: number,
|
||
field: BoardRowField,
|
||
value: string,
|
||
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
|
||
): BoardRowUpdatePlanResult | GitHubCommandFailure {
|
||
const found = findBoardRow(repo, parsed.sections, issueNumber);
|
||
if (found.ok === false) return found;
|
||
const { section, row } = found;
|
||
const targetColumn = boardColumnKindForField(field);
|
||
const columnMap = boardColumnIndexMap(section.headers);
|
||
const columnIndex = columnMap.get(targetColumn);
|
||
if (columnIndex === undefined) {
|
||
return validationError("issue board-row update", repo, `board table does not contain a column for --field ${field}`, {
|
||
issueNumber,
|
||
field,
|
||
targetColumn,
|
||
section: section.kind,
|
||
headers: section.headers,
|
||
supportedFields: BOARD_ROW_FIELDS.slice(),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const oldCells = row.cells.slice();
|
||
const newCells = oldCells.slice();
|
||
while (newCells.length < section.headers.length) newCells.push("");
|
||
newCells[columnIndex] = value;
|
||
const newRow = renderMarkdownTableRow(newCells);
|
||
const oldBody = boardIssue.body ?? "";
|
||
let newBody: string;
|
||
try {
|
||
newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow);
|
||
} catch (error) {
|
||
return validationError("issue board-row update", repo, error instanceof Error ? error.message : String(error), {
|
||
issueNumber,
|
||
field,
|
||
lineNumber: row.lineNumber,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return {
|
||
ok: true,
|
||
newBody,
|
||
plan: {
|
||
repo,
|
||
boardIssue: boardIssue.number,
|
||
issueNumber,
|
||
field,
|
||
targetColumn,
|
||
targetColumnIndex: columnIndex,
|
||
section: section.kind,
|
||
lineNumber: row.lineNumber,
|
||
oldRow: row.raw,
|
||
newRow,
|
||
oldCells,
|
||
newCells,
|
||
oldValue: oldCells[columnIndex] ?? "",
|
||
newValue: value,
|
||
row: {
|
||
old: boardRowSummary(section, row),
|
||
new: {
|
||
...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }),
|
||
updatedField: field,
|
||
},
|
||
},
|
||
body: {
|
||
oldBodyChars: oldBody.length,
|
||
oldBodySha: bodySha(oldBody),
|
||
newBodyChars: newBody.length,
|
||
newBodySha: bodySha(newBody),
|
||
changed: oldBody !== newBody,
|
||
},
|
||
tableParser: {
|
||
reused: "parseBoardTables",
|
||
rowValidationWarnings: parsed.warnings.length,
|
||
},
|
||
request: {
|
||
method: "PATCH",
|
||
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
|
||
body: { bodyChars: newBody.length },
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function expectedBoardGithubStatus(section: BoardMutationSection): BoardGithubStatus {
|
||
return section === "open" ? "OPEN" : "CLOSED";
|
||
}
|
||
|
||
function normalizeBoardGithubStatusCell(value: string | undefined): BoardGithubStatus | null {
|
||
const normalized = stripMarkdownInline(value ?? "").toUpperCase().replace(/\s+/g, "");
|
||
if (normalized === "OPEN") return "OPEN";
|
||
if (normalized === "CLOSED") return "CLOSED";
|
||
return null;
|
||
}
|
||
|
||
function boardHeaderShape(headers: string[]): string[] {
|
||
return headers.map((header) => normalizeBoardHeader(header));
|
||
}
|
||
|
||
function sameBoardHeaderShape(a: string[], b: string[]): boolean {
|
||
const left = boardHeaderShape(a);
|
||
const right = boardHeaderShape(b);
|
||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||
}
|
||
|
||
function boardRowDuplicateFailure(
|
||
repo: string,
|
||
commandName: string,
|
||
sections: BoardTableSection[],
|
||
issueNumber: number,
|
||
): GitHubCommandFailure | null {
|
||
const duplicates = boardDuplicateRows(sections, issueNumber);
|
||
if (duplicates.length === 0) return null;
|
||
return validationError(commandName, repo, `board row for issue #${issueNumber} already exists; refusing duplicate row`, {
|
||
issueNumber,
|
||
matches: duplicates.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
|
||
function boardStatusOptionFailure(
|
||
repo: string,
|
||
commandName: string,
|
||
targetSection: BoardMutationSection,
|
||
status: BoardGithubStatus | undefined,
|
||
): GitHubCommandFailure | null {
|
||
if (status === undefined) return null;
|
||
const expected = expectedBoardGithubStatus(targetSection);
|
||
if (status === expected) return null;
|
||
return validationError(commandName, repo, `--status ${status} conflicts with --to ${targetSection}; expected ${expected}`, {
|
||
targetSection,
|
||
status,
|
||
expectedStatus: expected,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
|
||
function boardRowStatusConsistency(
|
||
repo: string,
|
||
commandName: string,
|
||
section: BoardTableSection,
|
||
row: BoardTableRow,
|
||
role: "source" | "target",
|
||
): { ok: true; githubStatusColumnIndex: number; actualStatus: BoardGithubStatus | null; expectedStatus: BoardGithubStatus } | GitHubCommandFailure {
|
||
const columnMap = boardColumnIndexMap(section.headers);
|
||
const githubStatusColumnIndex = columnMap.get("githubStatus");
|
||
if (githubStatusColumnIndex === undefined) {
|
||
return validationError(commandName, repo, `${role} board table does not contain a GitHub 状态 column`, {
|
||
section: section.kind,
|
||
headers: section.headers,
|
||
lineNumber: row.lineNumber,
|
||
rowPreview: preview(row.raw),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const expectedStatus = expectedBoardGithubStatus(section.kind);
|
||
const actualStatus = normalizeBoardGithubStatusCell(row.cells[githubStatusColumnIndex]);
|
||
if (actualStatus !== expectedStatus) {
|
||
return validationError(commandName, repo, `${role} row GitHub 状态 ${actualStatus ?? "missing"} conflicts with ${section.kind.toUpperCase()} section; expected ${expectedStatus}`, {
|
||
section: section.kind,
|
||
lineNumber: row.lineNumber,
|
||
rowPreview: preview(row.raw),
|
||
actualStatus,
|
||
expectedStatus,
|
||
githubStatusColumnIndex,
|
||
headers: section.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return { ok: true, githubStatusColumnIndex, actualStatus, expectedStatus };
|
||
}
|
||
|
||
function boardRowUpsertValueEntries(values: BoardRowUpsertValues): Array<{ field: BoardRowUpsertField; value: string }> {
|
||
const entries: Array<{ field: BoardRowUpsertField; value: string }> = [];
|
||
for (const field of BOARD_ROW_UPSERT_TEXT_FIELDS) {
|
||
const value = values[field];
|
||
if (value !== undefined) entries.push({ field, value });
|
||
}
|
||
if (values.status !== undefined) entries.push({ field: "status", value: values.status });
|
||
return entries;
|
||
}
|
||
|
||
function boardColumnKindForUpsertField(field: BoardRowUpsertField): BoardColumnKind {
|
||
if (field === "category") return "category";
|
||
if (field === "branch") return "branch";
|
||
if (field === "tasks") return "relatedTask";
|
||
if (field === "summary") return "summary";
|
||
if (field === "focus") return "focus";
|
||
if (field === "validation") return "acceptance";
|
||
if (field === "status") return "githubStatus";
|
||
return "progress";
|
||
}
|
||
|
||
function renderBoardIssueCell(repo: string, issueNumber: number, summary: string | undefined): string {
|
||
const trimmed = (summary ?? "").trim();
|
||
if (trimmed.length === 0) return `#${issueNumber}`;
|
||
const targetHref = `https://github.com/${repo}/issues/${issueNumber}`;
|
||
const firstMarkdownIssue = /^\s*\[[^\]]*\]\(([^)]*)\)/u.exec(trimmed)?.[1] ?? "";
|
||
if (new RegExp(`/issues/${issueNumber}(?:\\b|$)`, "u").test(firstMarkdownIssue)) return trimmed;
|
||
return `[#${issueNumber}](${targetHref}) ${trimmed}`;
|
||
}
|
||
|
||
function boardRowUpsertTargetColumn(
|
||
repo: string,
|
||
commandName: string,
|
||
section: BoardTableSection,
|
||
issueNumber: number,
|
||
field: BoardRowUpsertField,
|
||
): { ok: true; columnIndex: number; targetColumn: BoardColumnKind | "issue"; usesIssueCell: boolean } | GitHubCommandFailure {
|
||
const columnMap = boardColumnIndexMap(section.headers);
|
||
if (field === "summary") {
|
||
const summaryIndex = columnMap.get("summary");
|
||
if (summaryIndex !== undefined) return { ok: true, columnIndex: summaryIndex, targetColumn: "summary", usesIssueCell: false };
|
||
const issueColumnIndex = boardIssueColumnIndex(section.headers);
|
||
if (issueColumnIndex !== null) return { ok: true, columnIndex: issueColumnIndex, targetColumn: "issue", usesIssueCell: true };
|
||
}
|
||
const targetColumn = boardColumnKindForUpsertField(field);
|
||
const columnIndex = columnMap.get(targetColumn);
|
||
if (columnIndex === undefined) {
|
||
return validationError(commandName, repo, `board table does not contain a column for --${field}`, {
|
||
issueNumber,
|
||
field,
|
||
targetColumn,
|
||
section: section.kind,
|
||
headers: section.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return { ok: true, columnIndex, targetColumn, usesIssueCell: false };
|
||
}
|
||
|
||
function boardRowUpsertRenderedValue(
|
||
repo: string,
|
||
issueNumber: number,
|
||
field: BoardRowUpsertField,
|
||
value: string,
|
||
targetColumn: BoardColumnKind | "issue",
|
||
): string {
|
||
if (field === "summary" && targetColumn === "issue") return renderBoardIssueCell(repo, issueNumber, value);
|
||
return value;
|
||
}
|
||
|
||
function boardRowUpsertPollutionFailure(
|
||
repo: string,
|
||
commandName: string,
|
||
issueNumber: number,
|
||
values: BoardRowUpsertValues,
|
||
): GitHubCommandFailure | null {
|
||
const findings = BOARD_ROW_UPSERT_TEXT_FIELDS
|
||
.map((field) => {
|
||
const value = values[field];
|
||
if (value === undefined) return null;
|
||
const evidence = boardRowValuePollutionEvidence(value);
|
||
return evidence.length === 0 ? null : { field, valuePreview: preview(value), evidence };
|
||
})
|
||
.filter((item): item is { field: BoardRowUpsertTextField; valuePreview: string; evidence: string[] } => item !== null);
|
||
if (findings.length === 0) return null;
|
||
return validationError(commandName, repo, "issue board-row upsert field value contains literal shell escape text that would pollute a board cell; pass real newlines or plain text instead", {
|
||
issueNumber,
|
||
shellPollution: { suspected: true, findings },
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
|
||
function boardRowUpsertUpdatePlan(
|
||
repo: string,
|
||
boardIssue: GitHubIssue,
|
||
issueNumber: number,
|
||
found: { section: BoardTableSection; row: BoardTableRow },
|
||
values: BoardRowUpsertValues,
|
||
requestedSection: BoardMutationSection | undefined,
|
||
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
|
||
): BoardRowMutationPlanResult | GitHubCommandFailure {
|
||
const commandName = "issue board-row upsert";
|
||
const { section, row } = found;
|
||
if (requestedSection !== undefined && requestedSection !== section.kind) {
|
||
return validationError(commandName, repo, `--section ${requestedSection} conflicts with existing row in ${section.kind}; use gh issue board-row move for section migration`, {
|
||
issueNumber,
|
||
requestedSection,
|
||
existingSection: section.kind,
|
||
moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${requestedSection}`,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
if (values.status !== undefined) {
|
||
const expectedStatus = expectedBoardGithubStatus(section.kind);
|
||
if (values.status !== expectedStatus) {
|
||
return validationError(commandName, repo, `--status ${values.status} conflicts with existing ${section.kind.toUpperCase()} section; use gh issue board-row move for section migration`, {
|
||
issueNumber,
|
||
existingSection: section.kind,
|
||
requestedStatus: values.status,
|
||
expectedStatus,
|
||
moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${values.status === "OPEN" ? "open" : "closed"} --status ${values.status}`,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
}
|
||
const entries = boardRowUpsertValueEntries(values);
|
||
if (entries.length === 0) {
|
||
return validationError(commandName, repo, "issue board-row upsert found an existing row but no upsert fields were provided", {
|
||
issueNumber,
|
||
existingSection: section.kind,
|
||
supportedFields: ["--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--status"],
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
if (row.cells.length !== section.headers.length) {
|
||
return validationError(commandName, repo, "existing row column count does not match the board table header", {
|
||
issueNumber,
|
||
section: section.kind,
|
||
expectedColumns: section.headers.length,
|
||
actualColumns: row.cells.length,
|
||
headers: section.headers,
|
||
lineNumber: row.lineNumber,
|
||
rowPreview: preview(row.raw),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const oldCells = row.cells.slice();
|
||
const newCells = oldCells.slice();
|
||
const changedFields: Record<string, unknown>[] = [];
|
||
for (const entry of entries) {
|
||
const target = boardRowUpsertTargetColumn(repo, commandName, section, issueNumber, entry.field);
|
||
if (target.ok === false) return target;
|
||
const newValue = boardRowUpsertRenderedValue(repo, issueNumber, entry.field, entry.value, target.targetColumn);
|
||
const oldValue = oldCells[target.columnIndex] ?? "";
|
||
newCells[target.columnIndex] = newValue;
|
||
changedFields.push({
|
||
field: entry.field,
|
||
targetColumn: target.targetColumn,
|
||
targetColumnIndex: target.columnIndex,
|
||
requestedValue: entry.value,
|
||
oldValue,
|
||
newValue,
|
||
changed: oldValue !== newValue,
|
||
});
|
||
}
|
||
const newRow = renderMarkdownTableRow(newCells);
|
||
const oldBody = boardIssue.body ?? "";
|
||
let newBody: string;
|
||
try {
|
||
newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow);
|
||
} catch (error) {
|
||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
|
||
issueNumber,
|
||
lineNumber: row.lineNumber,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return {
|
||
ok: true,
|
||
newBody,
|
||
plan: {
|
||
repo,
|
||
boardIssue: boardIssue.number,
|
||
issueNumber,
|
||
operation: "update",
|
||
action: "update",
|
||
section: section.kind,
|
||
lineNumber: row.lineNumber,
|
||
requestedSection: requestedSection ?? null,
|
||
providedFields: entries.map((entry) => entry.field),
|
||
changedFields,
|
||
oldRow: row.raw,
|
||
newRow,
|
||
oldCells,
|
||
newCells,
|
||
row: {
|
||
old: boardRowSummary(section, row),
|
||
new: {
|
||
...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }),
|
||
upsertFields: boardRowUpsertFieldValues(section, { ...row, raw: newRow, cells: newCells }),
|
||
},
|
||
},
|
||
linePlan: {
|
||
action: "replace",
|
||
section: section.kind,
|
||
lineNumber: row.lineNumber,
|
||
},
|
||
sectionPlan: {
|
||
kind: section.kind,
|
||
heading: section.heading,
|
||
headerLine: section.headerLine,
|
||
rowCount: section.rows.length,
|
||
},
|
||
body: {
|
||
oldBodyChars: oldBody.length,
|
||
oldBodySha: bodySha(oldBody),
|
||
newBodyChars: newBody.length,
|
||
newBodySha: bodySha(newBody),
|
||
changed: oldBody !== newBody,
|
||
},
|
||
tableParser: {
|
||
reused: "parseBoardTables",
|
||
rowValidationWarnings: parsed.warnings.length,
|
||
},
|
||
request: {
|
||
method: "PATCH",
|
||
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
|
||
body: { bodyChars: newBody.length },
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function boardRowUpsertAddPlan(
|
||
repo: string,
|
||
boardIssue: GitHubIssue,
|
||
issueNumber: number,
|
||
sectionKind: BoardMutationSection,
|
||
values: BoardRowUpsertValues,
|
||
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
|
||
): BoardRowMutationPlanResult | GitHubCommandFailure {
|
||
const commandName = "issue board-row upsert";
|
||
const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName);
|
||
if (targetLookup.ok === false) return targetLookup;
|
||
const target = targetLookup.section;
|
||
const issueColumnIndex = boardIssueColumnIndex(target.headers);
|
||
if (issueColumnIndex === null) {
|
||
return validationError(commandName, repo, "board table does not contain an Issue column required for generated upsert rows", {
|
||
boardIssue: boardIssue.number,
|
||
section: sectionKind,
|
||
headers: target.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const columnMap = boardColumnIndexMap(target.headers);
|
||
const githubStatusColumnIndex = columnMap.get("githubStatus");
|
||
if (githubStatusColumnIndex === undefined) {
|
||
return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for generated upsert rows", {
|
||
boardIssue: boardIssue.number,
|
||
section: sectionKind,
|
||
headers: target.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const expectedStatus = expectedBoardGithubStatus(sectionKind);
|
||
const requestedStatus = values.status ?? expectedStatus;
|
||
if (requestedStatus !== expectedStatus) {
|
||
return validationError(commandName, repo, `--status ${requestedStatus} conflicts with --section ${sectionKind}; expected ${expectedStatus}`, {
|
||
issueNumber,
|
||
section: sectionKind,
|
||
requestedStatus,
|
||
expectedStatus,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const appliedFields = new Set<BoardRowUpsertField>();
|
||
const missingFields: BoardRowUpsertTextField[] = [];
|
||
const unsupportedColumns: Array<{ index: number; header: string }> = [];
|
||
const summaryColumnIndex = columnMap.get("summary");
|
||
const cells = target.headers.map((header, index) => {
|
||
if (index === issueColumnIndex) {
|
||
if (summaryColumnIndex === undefined && values.summary !== undefined) appliedFields.add("summary");
|
||
return summaryColumnIndex === undefined ? renderBoardIssueCell(repo, issueNumber, values.summary) : `#${issueNumber}`;
|
||
}
|
||
const kind = boardHeaderColumnKind(header);
|
||
if (kind === "githubStatus") {
|
||
if (values.status !== undefined) appliedFields.add("status");
|
||
return requestedStatus;
|
||
}
|
||
const valueFor = (field: BoardRowUpsertTextField, value: string | undefined): string => {
|
||
if (value === undefined) {
|
||
missingFields.push(field);
|
||
return "";
|
||
}
|
||
appliedFields.add(field);
|
||
return value;
|
||
};
|
||
if (kind === "category") return valueFor("category", values.category);
|
||
if (kind === "branch") return valueFor("branch", values.branch);
|
||
if (kind === "relatedTask") return valueFor("tasks", values.tasks);
|
||
if (kind === "summary") return valueFor("summary", values.summary);
|
||
if (kind === "focus") return valueFor("focus", values.focus);
|
||
if (kind === "acceptance") return valueFor("validation", values.validation);
|
||
if (kind === "progress") return valueFor("progress", values.progress);
|
||
unsupportedColumns.push({ index, header });
|
||
return "";
|
||
});
|
||
if (unsupportedColumns.length > 0) {
|
||
return validationError(commandName, repo, "target board table contains unsupported column(s) for generated upsert rows", {
|
||
issueNumber,
|
||
section: sectionKind,
|
||
unsupportedColumns,
|
||
headers: target.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const unappliedFields = boardRowUpsertValueEntries(values)
|
||
.map((entry) => entry.field)
|
||
.filter((field) => !appliedFields.has(field));
|
||
if (unappliedFields.length > 0) {
|
||
return validationError(commandName, repo, "target board table does not contain column(s) for supplied upsert field(s)", {
|
||
issueNumber,
|
||
section: sectionKind,
|
||
unappliedFields,
|
||
headers: target.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const uniqueMissingFields = Array.from(new Set(missingFields));
|
||
if (uniqueMissingFields.length > 0) {
|
||
return validationError(commandName, repo, "issue board-row upsert add requires values for every generated row column", {
|
||
issueNumber,
|
||
section: sectionKind,
|
||
missingFields: uniqueMissingFields,
|
||
requiredOptions: uniqueMissingFields.map((field) => `--${field}`),
|
||
headers: target.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const newRow = renderMarkdownTableRow(cells);
|
||
const row = boardRowFromCells(target, newRow, cells, 0);
|
||
if (row.issueNumber !== issueNumber) {
|
||
return validationError(commandName, repo, "generated Issue column does not match the requested issue number", {
|
||
issueNumber,
|
||
issueColumnIndex,
|
||
issueCell: cells[issueColumnIndex] ?? "",
|
||
detectedIssueNumber: row.issueNumber,
|
||
rowPreview: preview(newRow),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const actualStatus = normalizeBoardGithubStatusCell(cells[githubStatusColumnIndex]);
|
||
if (actualStatus !== expectedStatus) {
|
||
return validationError(commandName, repo, "generated GitHub 状态 column does not match the target section", {
|
||
issueNumber,
|
||
section: sectionKind,
|
||
expectedStatus,
|
||
actualStatus,
|
||
statusColumnIndex: githubStatusColumnIndex,
|
||
statusCell: cells[githubStatusColumnIndex] ?? "",
|
||
rowPreview: preview(newRow),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const oldBody = boardIssue.body ?? "";
|
||
const insertAfterLine = boardSectionInsertAfterLine(target);
|
||
let newBody: string;
|
||
try {
|
||
newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, newRow);
|
||
} catch (error) {
|
||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
|
||
issueNumber,
|
||
section: sectionKind,
|
||
insertAfterLine,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return {
|
||
ok: true,
|
||
newBody,
|
||
plan: {
|
||
repo,
|
||
boardIssue: boardIssue.number,
|
||
issueNumber,
|
||
operation: "add",
|
||
action: "add",
|
||
section: sectionKind,
|
||
insertAfterLine,
|
||
providedFields: boardRowUpsertValueEntries(values).map((entry) => entry.field),
|
||
newRow,
|
||
newCells: cells,
|
||
row: {
|
||
old: null,
|
||
new: {
|
||
...boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }),
|
||
upsertFields: boardRowUpsertFieldValues(target, { ...row, lineNumber: insertAfterLine + 1 }),
|
||
},
|
||
},
|
||
linePlan: {
|
||
action: "insert",
|
||
section: sectionKind,
|
||
insertAfterLine,
|
||
newLineNumber: insertAfterLine + 1,
|
||
},
|
||
sectionPlan: {
|
||
kind: sectionKind,
|
||
heading: target.heading,
|
||
headerLine: target.headerLine,
|
||
rowCount: target.rows.length,
|
||
},
|
||
validation: {
|
||
issueColumnIndex,
|
||
githubStatusColumnIndex,
|
||
expectedStatus,
|
||
actualStatus,
|
||
columnCount: cells.length,
|
||
},
|
||
body: {
|
||
oldBodyChars: oldBody.length,
|
||
oldBodySha: bodySha(oldBody),
|
||
newBodyChars: newBody.length,
|
||
newBodySha: bodySha(newBody),
|
||
changed: oldBody !== newBody,
|
||
},
|
||
tableParser: {
|
||
reused: "parseBoardTables",
|
||
rowValidationWarnings: parsed.warnings.length,
|
||
},
|
||
request: {
|
||
method: "PATCH",
|
||
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
|
||
body: { bodyChars: newBody.length },
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function boardRowUpsertPlan(
|
||
repo: string,
|
||
boardIssue: GitHubIssue,
|
||
issueNumber: number,
|
||
options: GitHubOptions,
|
||
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
|
||
): BoardRowMutationPlanResult | GitHubCommandFailure {
|
||
const commandName = "issue board-row upsert";
|
||
const pollutionFailure = boardRowUpsertPollutionFailure(repo, commandName, issueNumber, options.boardRowUpsertValues);
|
||
if (pollutionFailure !== null) return pollutionFailure;
|
||
const matches = boardRowsExactMatch(parsed.sections, issueNumber);
|
||
if (matches.length > 1) {
|
||
return validationError(commandName, repo, `board row for issue #${issueNumber} is ambiguous; refusing upsert`, {
|
||
issueNumber,
|
||
matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
if (matches.length === 1) {
|
||
return boardRowUpsertUpdatePlan(repo, boardIssue, issueNumber, matches[0], options.boardRowUpsertValues, options.boardSection, parsed);
|
||
}
|
||
if (options.boardSection === undefined) {
|
||
return validationError(commandName, repo, "issue board-row upsert add requires --section open|closed when the row does not already exist", {
|
||
issueNumber,
|
||
supportedSections: BOARD_MUTATION_SECTIONS.slice(),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return boardRowUpsertAddPlan(repo, boardIssue, issueNumber, options.boardSection, options.boardRowUpsertValues, parsed);
|
||
}
|
||
|
||
function boardRowAddPlan(
|
||
repo: string,
|
||
boardIssue: GitHubIssue,
|
||
issueNumber: number,
|
||
sectionKind: BoardMutationSection,
|
||
rowFile: { path: string; raw: string; cells: string[] },
|
||
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
|
||
): BoardRowMutationPlanResult | GitHubCommandFailure {
|
||
const commandName = "issue board-row add";
|
||
const duplicate = boardRowDuplicateFailure(repo, commandName, parsed.sections, issueNumber);
|
||
if (duplicate !== null) return duplicate;
|
||
const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName);
|
||
if (targetLookup.ok === false) return targetLookup;
|
||
const target = targetLookup.section;
|
||
const issueColumnIndex = boardIssueColumnIndex(target.headers);
|
||
if (issueColumnIndex === null) {
|
||
return validationError(commandName, repo, "board table does not contain an Issue column required for row-file validation", {
|
||
boardIssue: boardIssue.number,
|
||
section: sectionKind,
|
||
headers: target.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const columnMap = boardColumnIndexMap(target.headers);
|
||
const githubStatusColumnIndex = columnMap.get("githubStatus");
|
||
if (githubStatusColumnIndex === undefined) {
|
||
return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for add section validation", {
|
||
boardIssue: boardIssue.number,
|
||
section: sectionKind,
|
||
headers: target.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
if (rowFile.cells.length !== target.headers.length) {
|
||
return validationError(commandName, repo, "row-file column count does not match the target board table header", {
|
||
boardIssue: boardIssue.number,
|
||
section: sectionKind,
|
||
expectedColumns: target.headers.length,
|
||
actualColumns: rowFile.cells.length,
|
||
headers: target.headers,
|
||
rowFile: rowFile.path,
|
||
rowPreview: preview(rowFile.raw),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const row = boardRowFromCells(target, rowFile.raw, rowFile.cells, 0);
|
||
if (row.issueNumber !== issueNumber) {
|
||
return validationError(commandName, repo, "row-file Issue column does not match the requested issue number", {
|
||
issueNumber,
|
||
issueColumnIndex,
|
||
issueCell: rowFile.cells[issueColumnIndex] ?? "",
|
||
detectedIssueNumber: row.issueNumber,
|
||
rowPreview: preview(rowFile.raw),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const expectedStatus = expectedBoardGithubStatus(sectionKind);
|
||
const actualStatus = normalizeBoardGithubStatusCell(rowFile.cells[githubStatusColumnIndex]);
|
||
if (actualStatus !== expectedStatus) {
|
||
return validationError(commandName, repo, "row-file GitHub 状态 column does not match the target section", {
|
||
issueNumber,
|
||
section: sectionKind,
|
||
expectedStatus,
|
||
actualStatus,
|
||
statusColumnIndex: githubStatusColumnIndex,
|
||
statusCell: rowFile.cells[githubStatusColumnIndex] ?? "",
|
||
rowPreview: preview(rowFile.raw),
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const oldBody = boardIssue.body ?? "";
|
||
const insertAfterLine = boardSectionInsertAfterLine(target);
|
||
let newBody: string;
|
||
try {
|
||
newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, rowFile.raw);
|
||
} catch (error) {
|
||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
|
||
issueNumber,
|
||
section: sectionKind,
|
||
insertAfterLine,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return {
|
||
ok: true,
|
||
newBody,
|
||
plan: {
|
||
repo,
|
||
boardIssue: boardIssue.number,
|
||
issueNumber,
|
||
action: "add",
|
||
section: sectionKind,
|
||
insertAfterLine,
|
||
rowFile: rowFile.path,
|
||
newRow: rowFile.raw,
|
||
newCells: rowFile.cells,
|
||
row: {
|
||
old: null,
|
||
new: boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }),
|
||
},
|
||
linePlan: {
|
||
action: "insert",
|
||
section: sectionKind,
|
||
insertAfterLine,
|
||
newLineNumber: insertAfterLine + 1,
|
||
},
|
||
sectionPlan: {
|
||
kind: sectionKind,
|
||
heading: target.heading,
|
||
headerLine: target.headerLine,
|
||
rowCount: target.rows.length,
|
||
},
|
||
validation: {
|
||
issueColumnIndex,
|
||
githubStatusColumnIndex,
|
||
expectedStatus,
|
||
actualStatus,
|
||
columnCount: rowFile.cells.length,
|
||
},
|
||
body: {
|
||
oldBodyChars: oldBody.length,
|
||
oldBodySha: bodySha(oldBody),
|
||
newBodyChars: newBody.length,
|
||
newBodySha: bodySha(newBody),
|
||
changed: oldBody !== newBody,
|
||
},
|
||
tableParser: {
|
||
reused: "parseBoardTables",
|
||
rowValidationWarnings: parsed.warnings.length,
|
||
},
|
||
request: {
|
||
method: "PATCH",
|
||
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
|
||
body: { bodyChars: newBody.length },
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function moveBoardBodyRow(body: string, sourceLineNumber: number, insertAfterLine: number, newLine: string): string {
|
||
const normalized = normalizeNewlines(body);
|
||
const lines = normalized.split("\n");
|
||
if (sourceLineNumber <= 0 || sourceLineNumber > lines.length) throw new Error(`line ${sourceLineNumber} is outside the board body`);
|
||
if (insertAfterLine < 0 || insertAfterLine > lines.length) throw new Error(`insert line ${insertAfterLine} is outside the board body`);
|
||
lines.splice(sourceLineNumber - 1, 1);
|
||
const adjustedInsertAfterLine = sourceLineNumber <= insertAfterLine ? insertAfterLine - 1 : insertAfterLine;
|
||
lines.splice(adjustedInsertAfterLine, 0, newLine);
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function boardRowMovePlan(
|
||
repo: string,
|
||
boardIssue: GitHubIssue,
|
||
issueNumber: number,
|
||
targetKind: BoardMutationSection,
|
||
status: BoardGithubStatus | undefined,
|
||
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
|
||
): BoardRowMutationPlanResult | GitHubCommandFailure {
|
||
const commandName = "issue board-row move";
|
||
const statusFailure = boardStatusOptionFailure(repo, commandName, targetKind, status);
|
||
if (statusFailure !== null) return statusFailure;
|
||
const found = findBoardRow(repo, parsed.sections, issueNumber);
|
||
if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure;
|
||
const sourceConsistency = boardRowStatusConsistency(repo, commandName, found.section, found.row, "source");
|
||
if (sourceConsistency.ok === false) return sourceConsistency;
|
||
const targetLookup = targetBoardSection(repo, parsed.sections, targetKind, commandName);
|
||
if (targetLookup.ok === false) return targetLookup;
|
||
const target = targetLookup.section;
|
||
const { section: source, row } = found;
|
||
if (!sameBoardHeaderShape(source.headers, target.headers)) {
|
||
return validationError(commandName, repo, "source and target board tables have different headers; refusing to move a raw row across mismatched columns", {
|
||
issueNumber,
|
||
source: { section: source.kind, headers: source.headers, headerLine: source.headerLine },
|
||
target: { section: target.kind, headers: target.headers, headerLine: target.headerLine },
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
if (row.cells.length !== target.headers.length) {
|
||
return validationError(commandName, repo, "source row column count does not match the target board table header", {
|
||
issueNumber,
|
||
source: { section: source.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw), cells: row.cells.length },
|
||
target: { section: target.kind, headers: target.headers, columns: target.headers.length },
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const columnMap = boardColumnIndexMap(target.headers);
|
||
const githubStatusColumnIndex = columnMap.get("githubStatus");
|
||
if (githubStatusColumnIndex === undefined) {
|
||
return validationError(commandName, repo, "target board table does not contain a GitHub 状态 column required for section migration", {
|
||
issueNumber,
|
||
targetSection: targetKind,
|
||
headers: target.headers,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
const oldCells = row.cells.slice();
|
||
const newCells = oldCells.slice();
|
||
const expectedStatus = expectedBoardGithubStatus(targetKind);
|
||
if (githubStatusColumnIndex !== undefined) newCells[githubStatusColumnIndex] = status ?? expectedStatus;
|
||
const newRow = renderMarkdownTableRow(newCells);
|
||
const oldBody = boardIssue.body ?? "";
|
||
const insertAfterLine = boardSectionInsertAfterLine(target);
|
||
let newBody: string;
|
||
try {
|
||
newBody = source.kind === target.kind
|
||
? replaceBoardBodyLine(oldBody, row.lineNumber, newRow)
|
||
: moveBoardBodyRow(oldBody, row.lineNumber, insertAfterLine, newRow);
|
||
} catch (error) {
|
||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
|
||
issueNumber,
|
||
sourceLineNumber: row.lineNumber,
|
||
insertAfterLine,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return {
|
||
ok: true,
|
||
newBody,
|
||
plan: {
|
||
repo,
|
||
boardIssue: boardIssue.number,
|
||
issueNumber,
|
||
action: source.kind === target.kind ? "move-within-section" : "move",
|
||
from: source.kind,
|
||
to: target.kind,
|
||
sourceLineNumber: row.lineNumber,
|
||
insertAfterLine,
|
||
oldRow: row.raw,
|
||
newRow,
|
||
oldCells,
|
||
newCells,
|
||
row: {
|
||
old: boardRowSummary(source, row),
|
||
new: boardRowSummary(target, { ...row, section: target.kind, raw: newRow, cells: newCells, lineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1 }),
|
||
},
|
||
linePlan: {
|
||
action: source.kind === target.kind ? "replace" : "move",
|
||
sourceLineNumber: row.lineNumber,
|
||
insertAfterLine,
|
||
newLineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1,
|
||
},
|
||
sectionPlan: {
|
||
from: source.kind,
|
||
to: target.kind,
|
||
sourceHeading: source.heading,
|
||
targetHeading: target.heading,
|
||
},
|
||
status: {
|
||
requested: status ?? null,
|
||
targetDefault: expectedStatus,
|
||
githubStatusColumnIndex: githubStatusColumnIndex ?? null,
|
||
old: oldCells[githubStatusColumnIndex] ?? "",
|
||
new: newCells[githubStatusColumnIndex] ?? "",
|
||
},
|
||
body: {
|
||
oldBodyChars: oldBody.length,
|
||
oldBodySha: bodySha(oldBody),
|
||
newBodyChars: newBody.length,
|
||
newBodySha: bodySha(newBody),
|
||
changed: oldBody !== newBody,
|
||
},
|
||
tableParser: {
|
||
reused: "parseBoardTables",
|
||
rowValidationWarnings: parsed.warnings.length,
|
||
},
|
||
request: {
|
||
method: "PATCH",
|
||
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
|
||
body: { bodyChars: newBody.length },
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function boardRowDeletePlan(
|
||
repo: string,
|
||
boardIssue: GitHubIssue,
|
||
issueNumber: number,
|
||
parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] },
|
||
): BoardRowMutationPlanResult | GitHubCommandFailure {
|
||
const commandName = "issue board-row delete";
|
||
const found = findBoardRow(repo, parsed.sections, issueNumber);
|
||
if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure;
|
||
const { section, row } = found;
|
||
const sourceConsistency = boardRowStatusConsistency(repo, commandName, section, row, "source");
|
||
if (sourceConsistency.ok === false) return sourceConsistency;
|
||
const oldBody = boardIssue.body ?? "";
|
||
let newBody: string;
|
||
try {
|
||
newBody = removeBoardBodyLine(oldBody, row.lineNumber);
|
||
} catch (error) {
|
||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
|
||
issueNumber,
|
||
lineNumber: row.lineNumber,
|
||
}) as GitHubCommandFailure;
|
||
}
|
||
return {
|
||
ok: true,
|
||
newBody,
|
||
plan: {
|
||
repo,
|
||
boardIssue: boardIssue.number,
|
||
issueNumber,
|
||
action: "delete",
|
||
section: section.kind,
|
||
lineNumber: row.lineNumber,
|
||
oldRow: row.raw,
|
||
oldCells: row.cells,
|
||
row: {
|
||
old: boardRowSummary(section, row),
|
||
new: null,
|
||
},
|
||
linePlan: {
|
||
action: "remove",
|
||
section: section.kind,
|
||
lineNumber: row.lineNumber,
|
||
},
|
||
sectionPlan: {
|
||
kind: section.kind,
|
||
heading: section.heading,
|
||
headerLine: section.headerLine,
|
||
rowCount: section.rows.length,
|
||
},
|
||
body: {
|
||
oldBodyChars: oldBody.length,
|
||
oldBodySha: bodySha(oldBody),
|
||
newBodyChars: newBody.length,
|
||
newBodySha: bodySha(newBody),
|
||
changed: oldBody !== newBody,
|
||
},
|
||
tableParser: {
|
||
reused: "parseBoardTables",
|
||
rowValidationWarnings: parsed.warnings.length,
|
||
},
|
||
request: {
|
||
method: "PATCH",
|
||
path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`,
|
||
body: { bodyChars: newBody.length },
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
async function issueBoardRowMutation(
|
||
repo: string,
|
||
token: string,
|
||
issueNumber: number,
|
||
options: GitHubOptions,
|
||
commandName: "issue board-row add" | "issue board-row move" | "issue board-row delete" | "issue board-row upsert",
|
||
mutationKey: "add" | "move" | "delete" | "upsert",
|
||
buildPlan: (boardIssue: GitHubIssue, parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }) => BoardRowMutationPlanResult | GitHubCommandFailure,
|
||
): Promise<GitHubCommandResult> {
|
||
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
|
||
if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo };
|
||
const boardIssue = await getIssue(token, repo, options.boardIssue);
|
||
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber });
|
||
const parsed = parseBoardTables(boardIssue.body ?? "");
|
||
const planned = buildPlan(boardIssue, parsed);
|
||
if (planned.ok === false) return planned;
|
||
const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options);
|
||
const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options);
|
||
const hasConcurrencyGuard = options.expectBodySha !== undefined || options.expectUpdatedAt !== undefined;
|
||
const effectiveDryRun = options.dryRun || !hasConcurrencyGuard;
|
||
const base = {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
boardIssue: {
|
||
number: boardIssue.number,
|
||
title: boardIssue.title,
|
||
state: boardIssue.state,
|
||
url: boardIssue.html_url,
|
||
bodyChars: (boardIssue.body ?? "").length,
|
||
bodySha: bodySha(boardIssue.body ?? ""),
|
||
updatedAt: boardIssue.updated_at ?? null,
|
||
},
|
||
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody),
|
||
issueNumber,
|
||
operation: typeof planned.plan.operation === "string" ? planned.plan.operation : mutationKey,
|
||
dryRun: effectiveDryRun,
|
||
planned: true,
|
||
[mutationKey]: planned.plan,
|
||
guard,
|
||
bodyOnlySafety: {
|
||
oldBody: {
|
||
fetched: true,
|
||
bodyChars: (boardIssue.body ?? "").length,
|
||
bodySha: bodySha(boardIssue.body ?? ""),
|
||
updatedAt: boardIssue.updated_at ?? null,
|
||
},
|
||
newBody: {
|
||
bodyChars: planned.newBody.length,
|
||
bodySha: bodySha(planned.newBody),
|
||
...bodySafetySignals(planned.newBody),
|
||
},
|
||
},
|
||
concurrency: {
|
||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||
expectBodySha: options.expectBodySha ?? null,
|
||
note: `non-dry-run board-row ${mutationKey} requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH`,
|
||
},
|
||
};
|
||
if (guardError !== null) return { ...guardError, command: commandName, [mutationKey]: planned.plan, guard };
|
||
if (effectiveDryRun) {
|
||
return {
|
||
...base,
|
||
dryRun: true,
|
||
wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length },
|
||
note: !hasConcurrencyGuard
|
||
? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified."
|
||
: "Dry-run only; no GitHub issue body was modified.",
|
||
};
|
||
}
|
||
const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options);
|
||
if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName };
|
||
const { owner, name } = repoParts(repo);
|
||
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody });
|
||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, [mutationKey]: planned.plan });
|
||
return {
|
||
...base,
|
||
dryRun: false,
|
||
planned: false,
|
||
issue: issueSummary(issue),
|
||
concurrency: {
|
||
checked: true,
|
||
oldIssueUpdatedAt: boardIssue.updated_at ?? null,
|
||
oldBodySha: bodySha(boardIssue.body ?? ""),
|
||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||
expectBodySha: options.expectBodySha ?? null,
|
||
},
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function issueBoardRowList(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const commandName = "issue board-row list";
|
||
const boardIssue = await getIssue(token, repo, options.boardIssue);
|
||
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue });
|
||
const parsed = parseBoardTables(boardIssue.body ?? "");
|
||
const rows = boardRowsForState(parsed.sections, options.listState);
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
dryRun: true,
|
||
readOnly: true,
|
||
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""),
|
||
boardIssue: {
|
||
number: boardIssue.number,
|
||
title: boardIssue.title,
|
||
state: boardIssue.state,
|
||
url: boardIssue.html_url,
|
||
bodyChars: (boardIssue.body ?? "").length,
|
||
bodySha: bodySha(boardIssue.body ?? ""),
|
||
updatedAt: boardIssue.updated_at ?? null,
|
||
},
|
||
state: options.listState,
|
||
count: rows.length,
|
||
rows: rows.map(({ section, row }) => boardRowSummary(section, row)),
|
||
sections: parsed.sections.map((section) => ({ kind: section.kind, heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine, headers: section.headers, rows: section.rows.length })),
|
||
rowValidationWarnings: parsed.warnings,
|
||
note: "Read-only board row list; no issue body was edited.",
|
||
};
|
||
}
|
||
|
||
async function issueBoardRowGet(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const commandName = "issue board-row get";
|
||
const boardIssue = await getIssue(token, repo, options.boardIssue);
|
||
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber });
|
||
const parsed = parseBoardTables(boardIssue.body ?? "");
|
||
const found = findBoardRow(repo, parsed.sections, issueNumber);
|
||
if (found.ok === false) return { ...found, command: commandName, repo, boardIssue: options.boardIssue };
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
dryRun: true,
|
||
readOnly: true,
|
||
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""),
|
||
boardIssue: {
|
||
number: boardIssue.number,
|
||
title: boardIssue.title,
|
||
state: boardIssue.state,
|
||
url: boardIssue.html_url,
|
||
bodyChars: (boardIssue.body ?? "").length,
|
||
bodySha: bodySha(boardIssue.body ?? ""),
|
||
updatedAt: boardIssue.updated_at ?? null,
|
||
},
|
||
issueNumber,
|
||
row: boardRowSummary(found.section, found.row),
|
||
rowValidationWarnings: parsed.warnings.filter((warning) => warning.issueNumber === issueNumber),
|
||
note: "Read-only board row get; no issue body was edited.",
|
||
};
|
||
}
|
||
|
||
async function issueBoardRowUpdate(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const commandName = "issue board-row update";
|
||
if (options.boardRowField === undefined) return validationError(commandName, repo, "issue board-row update requires --field progress|status|validation|branch|tasks|focus", { supportedFields: BOARD_ROW_FIELDS.slice() });
|
||
if (options.boardRowValue === undefined) return validationError(commandName, repo, "issue board-row update requires --value <text>", { issueNumber, field: options.boardRowField });
|
||
const valuePollutionEvidence = boardRowValuePollutionEvidence(options.boardRowValue);
|
||
if (valuePollutionEvidence.length > 0) {
|
||
return validationError(commandName, repo, "issue board-row update --value contains literal shell escape text that would pollute the board cell; pass real newlines or plain text instead", {
|
||
issueNumber,
|
||
field: options.boardRowField,
|
||
valuePreview: preview(options.boardRowValue),
|
||
shellPollution: { suspected: true, evidence: valuePollutionEvidence },
|
||
});
|
||
}
|
||
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
|
||
if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo };
|
||
|
||
const boardIssue = await getIssue(token, repo, options.boardIssue);
|
||
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber });
|
||
const parsed = parseBoardTables(boardIssue.body ?? "");
|
||
const planned = boardRowUpdatePlan(repo, boardIssue, issueNumber, options.boardRowField, options.boardRowValue, parsed);
|
||
if (planned.ok === false) return planned;
|
||
const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options);
|
||
const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options);
|
||
const effectiveDryRun = options.dryRun || (options.expectBodySha === undefined && options.expectUpdatedAt === undefined);
|
||
const base = {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
boardIssue: {
|
||
number: boardIssue.number,
|
||
title: boardIssue.title,
|
||
state: boardIssue.state,
|
||
url: boardIssue.html_url,
|
||
bodyChars: (boardIssue.body ?? "").length,
|
||
bodySha: bodySha(boardIssue.body ?? ""),
|
||
updatedAt: boardIssue.updated_at ?? null,
|
||
},
|
||
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody),
|
||
issueNumber,
|
||
dryRun: effectiveDryRun,
|
||
planned: true,
|
||
update: planned.plan,
|
||
guard,
|
||
bodyOnlySafety: {
|
||
oldBody: {
|
||
fetched: true,
|
||
bodyChars: (boardIssue.body ?? "").length,
|
||
bodySha: bodySha(boardIssue.body ?? ""),
|
||
updatedAt: boardIssue.updated_at ?? null,
|
||
},
|
||
newBody: {
|
||
bodyChars: planned.newBody.length,
|
||
bodySha: bodySha(planned.newBody),
|
||
...bodySafetySignals(planned.newBody),
|
||
},
|
||
},
|
||
concurrency: {
|
||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||
expectBodySha: options.expectBodySha ?? null,
|
||
note: "non-dry-run board-row update requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH",
|
||
},
|
||
};
|
||
if (guardError !== null) return { ...guardError, command: commandName, update: planned.plan, guard };
|
||
if (effectiveDryRun) {
|
||
return {
|
||
...base,
|
||
dryRun: true,
|
||
wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length },
|
||
note: options.expectBodySha === undefined && options.expectUpdatedAt === undefined
|
||
? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified."
|
||
: "Dry-run only; no GitHub issue body was modified.",
|
||
};
|
||
}
|
||
const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options);
|
||
if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName };
|
||
const { owner, name } = repoParts(repo);
|
||
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody });
|
||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, update: planned.plan });
|
||
return {
|
||
...base,
|
||
dryRun: false,
|
||
planned: false,
|
||
issue: issueSummary(issue),
|
||
concurrency: {
|
||
checked: true,
|
||
oldIssueUpdatedAt: boardIssue.updated_at ?? null,
|
||
oldBodySha: bodySha(boardIssue.body ?? ""),
|
||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||
expectBodySha: options.expectBodySha ?? null,
|
||
},
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function issueBoardRowAdd(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const commandName = "issue board-row add";
|
||
if (options.boardSection === undefined) return validationError(commandName, repo, "issue board-row add requires --section open|closed", { issueNumber, supportedSections: BOARD_MUTATION_SECTIONS.slice() });
|
||
let rowFile: { path: string; raw: string; cells: string[] };
|
||
try {
|
||
rowFile = readBoardRowFile(options.boardRowFile, commandName);
|
||
} catch (error) {
|
||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), {
|
||
issueNumber,
|
||
rowFile: options.boardRowFile ?? null,
|
||
});
|
||
}
|
||
return issueBoardRowMutation(
|
||
repo,
|
||
token,
|
||
issueNumber,
|
||
options,
|
||
commandName,
|
||
"add",
|
||
(boardIssue, parsed) => boardRowAddPlan(repo, boardIssue, issueNumber, options.boardSection as BoardMutationSection, rowFile, parsed),
|
||
);
|
||
}
|
||
|
||
async function issueBoardRowUpsert(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const commandName = "issue board-row upsert";
|
||
return issueBoardRowMutation(
|
||
repo,
|
||
token,
|
||
issueNumber,
|
||
options,
|
||
commandName,
|
||
"upsert",
|
||
(boardIssue, parsed) => boardRowUpsertPlan(repo, boardIssue, issueNumber, options, parsed),
|
||
);
|
||
}
|
||
|
||
async function issueBoardRowMove(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const commandName = "issue board-row move";
|
||
if (options.boardMoveTo === undefined) return validationError(commandName, repo, "issue board-row move requires --to open|closed", { issueNumber, supportedTargets: BOARD_MUTATION_SECTIONS.slice() });
|
||
return issueBoardRowMutation(
|
||
repo,
|
||
token,
|
||
issueNumber,
|
||
options,
|
||
commandName,
|
||
"move",
|
||
(boardIssue, parsed) => boardRowMovePlan(repo, boardIssue, issueNumber, options.boardMoveTo as BoardMutationSection, options.boardGithubStatus, parsed),
|
||
);
|
||
}
|
||
|
||
async function issueBoardRowDelete(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const commandName = "issue board-row delete";
|
||
return issueBoardRowMutation(
|
||
repo,
|
||
token,
|
||
issueNumber,
|
||
options,
|
||
commandName,
|
||
"delete",
|
||
(boardIssue, parsed) => boardRowDeletePlan(repo, boardIssue, issueNumber, parsed),
|
||
);
|
||
}
|
||
|
||
function ignoredIssueList(issues: BoardIssueEntry[], ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] {
|
||
return sortedIssueEntries(issues.filter((issue) => ignoreMap.has(issue.number))).map((issue) => ({
|
||
number: issue.number,
|
||
reason: ignoreMap.get(issue.number) ?? "ignored",
|
||
title: issue.title,
|
||
state: issue.state,
|
||
url: issue.url,
|
||
}));
|
||
}
|
||
|
||
function ignoredBoardOnlyIssues(issueNumbers: number[], issueMap: Map<number, BoardIssueEntry>, ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] {
|
||
return issueNumbers
|
||
.filter((issueNumber) => ignoreMap.has(issueNumber) && !issueMap.has(issueNumber))
|
||
.sort((a, b) => a - b)
|
||
.map((issueNumber) => ({ number: issueNumber, reason: ignoreMap.get(issueNumber) ?? "ignored" }));
|
||
}
|
||
|
||
function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null {
|
||
if (requested !== "auto") return requested;
|
||
if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board";
|
||
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return "commander-brief";
|
||
return null;
|
||
}
|
||
|
||
function firstMarkdownLine(body: string | null | undefined): string {
|
||
return normalizeNewlines(body ?? "").split("\n").find((line) => line.trim().length > 0)?.trim() ?? "";
|
||
}
|
||
|
||
function isDailyCommanderBriefTitle(title: string | null | undefined): boolean {
|
||
return DAILY_COMMANDER_BRIEF_TITLE_PATTERN.test((title ?? "").trim());
|
||
}
|
||
|
||
function isDailyCommanderBriefBodyHeading(line: string): boolean {
|
||
return DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim());
|
||
}
|
||
|
||
function isLegacyCommanderBriefBodyHeading(line: string): boolean {
|
||
return LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim());
|
||
}
|
||
|
||
function currentBodyLooksLikeCommanderBrief(body: string | null | undefined): boolean {
|
||
const normalized = normalizeNewlines(body ?? "");
|
||
const firstLine = firstMarkdownLine(normalized);
|
||
if (isDailyCommanderBriefBodyHeading(firstLine)) return true;
|
||
return isLegacyCommanderBriefBodyHeading(firstLine)
|
||
&& normalized.includes("## 常驻观察与长期建议")
|
||
&& extractTimelineSections(normalized).length > 0;
|
||
}
|
||
|
||
function commanderBriefIssueProfileMatch(issueNumber: number, context: IssueProfileValidationContext): { ok: boolean; reason: string } {
|
||
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return { ok: true, reason: "legacy-issue-number" };
|
||
if (isDailyCommanderBriefTitle(context.issueTitle)) return { ok: true, reason: "daily-title" };
|
||
if (currentBodyLooksLikeCommanderBrief(context.issueBody)) return { ok: true, reason: "current-body-heading" };
|
||
return {
|
||
ok: false,
|
||
reason: context.issueMetadataFetched === true ? "not-commander-brief" : "issue-metadata-unavailable",
|
||
};
|
||
}
|
||
|
||
function issueProfileNeedsMetadata(issueNumber: number, requested: IssueBodyProfileOption): boolean {
|
||
return requested === "commander-brief" && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE;
|
||
}
|
||
|
||
function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption, context: IssueProfileValidationContext = {}): Record<string, unknown> {
|
||
const profileName = issueProfileFor(issueNumber, requested);
|
||
if (profileName === null) return { profile: null, applied: false, requiredHeadings: [], missingHeadings: [], ok: true };
|
||
const profile = ISSUE_BODY_PROFILES[profileName];
|
||
const match = profileName === "commander-brief"
|
||
? commanderBriefIssueProfileMatch(issueNumber, context)
|
||
: { ok: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber, reason: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber ? "issue-number" : "wrong-issue-number" };
|
||
const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading));
|
||
return {
|
||
profile: profileName,
|
||
label: profile.label,
|
||
applied: true,
|
||
...(profileName === "code-queue-board"
|
||
? { expectedIssueNumber: ISSUE_BODY_PROFILES["code-queue-board"].issueNumber }
|
||
: { legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE }),
|
||
issueMatchesProfile: match.ok,
|
||
issueMatchReason: match.reason,
|
||
issueMetadataFetched: context.issueMetadataFetched === true,
|
||
issueTitle: context.issueTitle ?? null,
|
||
requiredHeadings: profile.requiredHeadings,
|
||
missingHeadings,
|
||
ok: match.ok && missingHeadings.length === 0,
|
||
};
|
||
}
|
||
|
||
function validationBoolean(record: Record<string, unknown>, key: string): boolean {
|
||
return record[key] === true;
|
||
}
|
||
|
||
function validationStringArray(record: Record<string, unknown>, key: string): string[] {
|
||
const value = record[key];
|
||
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
||
}
|
||
|
||
function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): GitHubCommandResult | null {
|
||
const trimmed = body.trim();
|
||
const isLiteralNull = trimmed.toLowerCase() === "null";
|
||
const isBlank = trimmed.length === 0;
|
||
const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS;
|
||
const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
|
||
const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body);
|
||
const boardContainsCommanderBriefUpdates = codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint);
|
||
const boardContainsHwlabProductWork = codeQueueBoardHintHasHwlabProductRouting(boardBriefHint);
|
||
const profileOk = validationBoolean(profileValidation, "ok");
|
||
const failures: string[] = [];
|
||
if (isLiteralNull) failures.push("literal-null-body");
|
||
if (isBlank) failures.push("blank-body");
|
||
if (isShort && !options.allowShortBody) failures.push("short-body");
|
||
if (boardContainsCommanderBriefUpdates) failures.push("code-queue-board-contains-commander-brief-updates");
|
||
if (boardContainsHwlabProductWork) failures.push("code-queue-board-contains-hwlab-product-work");
|
||
if (!profileOk) {
|
||
if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch");
|
||
const missingHeadings = validationStringArray(profileValidation, "missingHeadings");
|
||
if (missingHeadings.length > 0) failures.push("profile-heading-missing");
|
||
}
|
||
if (failures.length === 0) return null;
|
||
const overrideAvailable = failures.every((failure) => failure === "short-body");
|
||
const message = overrideAvailable
|
||
? "issue edit body is shorter than the safe default; pass --allow-short-body only for intentional short writes"
|
||
: "issue edit body failed safety guard; refusing to write possible body-only issue corruption";
|
||
return validationError("issue edit", repo, message, {
|
||
issueNumber,
|
||
guard: {
|
||
ok: false,
|
||
failures,
|
||
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
|
||
allowShortBody: options.allowShortBody,
|
||
shortBodyOverrideAvailable: overrideAvailable,
|
||
bodySource: { kind: "body-file", path: options.bodyFile ?? null },
|
||
bodyPreview: preview(body),
|
||
bodyPreviewLines: previewLines(body),
|
||
codeQueueBoardHint: boardBriefHint,
|
||
...bodySafetySignals(body),
|
||
isLiteralNull,
|
||
isBlank,
|
||
isShort,
|
||
profile: profileValidation,
|
||
},
|
||
});
|
||
}
|
||
|
||
function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): Record<string, unknown> {
|
||
const trimmed = body.trim();
|
||
const profile = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
|
||
const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body);
|
||
const warnings: string[] = [];
|
||
if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) {
|
||
warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail");
|
||
}
|
||
if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk");
|
||
if (profile.ok === false) warnings.push("profile guard would fail");
|
||
if (codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint)) warnings.push("code-queue-board-contains-commander-brief-updates");
|
||
if (codeQueueBoardHintHasHwlabProductRouting(boardBriefHint)) warnings.push("code-queue-board-contains-hwlab-product-work");
|
||
const signals = bodySafetySignals(body);
|
||
const shellPollution = signals.shellPollution as Record<string, unknown>;
|
||
if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected");
|
||
return {
|
||
ok: trimmed.length > 0
|
||
&& trimmed.toLowerCase() !== "null"
|
||
&& (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody)
|
||
&& profile.ok === true
|
||
&& !codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint)
|
||
&& !codeQueueBoardHintHasHwlabProductRouting(boardBriefHint),
|
||
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
|
||
allowShortBody: options.allowShortBody,
|
||
warnings,
|
||
profile,
|
||
codeQueueBoardHint: boardBriefHint,
|
||
...signals,
|
||
};
|
||
}
|
||
|
||
function assertConcurrencyOptions(options: GitHubOptions, commandName: string): GitHubCommandResult | null {
|
||
if (options.expectBodySha === undefined) return null;
|
||
try {
|
||
normalizeExpectedSha(options.expectBodySha);
|
||
return null;
|
||
} catch (error) {
|
||
return validationError(commandName, options.repo, error instanceof Error ? error.message : String(error), {
|
||
expectBodySha: options.expectBodySha,
|
||
});
|
||
}
|
||
}
|
||
|
||
function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions): GitHubCommandResult | null {
|
||
const checks: Record<string, unknown> = {
|
||
issueNumber,
|
||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||
actualUpdatedAt: oldIssue.updated_at ?? null,
|
||
expectBodySha: options.expectBodySha ?? null,
|
||
actualBodySha: bodySha(oldIssue.body ?? ""),
|
||
};
|
||
if (options.expectUpdatedAt !== undefined && oldIssue.updated_at !== options.expectUpdatedAt) {
|
||
return validationError("issue edit", repo, "--expect-updated-at did not match current GitHub issue updated_at; refusing stale body overwrite", checks);
|
||
}
|
||
if (options.expectBodySha !== undefined) {
|
||
const expected = normalizeExpectedSha(options.expectBodySha);
|
||
if (bodySha(oldIssue.body ?? "") !== expected) {
|
||
return validationError("issue edit", repo, "--expect-body-sha did not match current GitHub issue body; refusing stale body overwrite", checks);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function commentSummary(comment: GitHubComment): Record<string, unknown> {
|
||
return {
|
||
id: comment.id,
|
||
body: comment.body ?? "",
|
||
url: comment.html_url,
|
||
author: comment.user?.login ?? null,
|
||
createdAt: comment.created_at ?? null,
|
||
updatedAt: comment.updated_at ?? null,
|
||
};
|
||
}
|
||
|
||
function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" {
|
||
if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged";
|
||
return pr.state === "closed" ? "closed" : "open";
|
||
}
|
||
|
||
function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
|
||
const stateDetail = prStateDetail(pr);
|
||
const closedAt = pr.closed_at ?? pr.merged_at ?? null;
|
||
const mergedAt = pr.merged_at ?? null;
|
||
const mergeCommitSha = pr.merge_commit_sha ?? null;
|
||
return {
|
||
id: pr.id,
|
||
number: pr.number,
|
||
title: pr.title,
|
||
body: pr.body ?? "",
|
||
state: pr.state,
|
||
stateDetail,
|
||
draft: pr.draft ?? false,
|
||
url: pr.html_url,
|
||
author: pr.user?.login ?? null,
|
||
head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null },
|
||
base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null },
|
||
headRefName: pr.head?.ref ?? null,
|
||
baseRefName: pr.base?.ref ?? null,
|
||
createdAt: pr.created_at ?? null,
|
||
updatedAt: pr.updated_at ?? null,
|
||
closed: stateDetail !== "open",
|
||
closedAt,
|
||
merged: stateDetail === "merged",
|
||
mergedAt,
|
||
mergeCommit: stateDetail === "merged" && mergeCommitSha !== null ? { oid: mergeCommitSha } : null,
|
||
};
|
||
}
|
||
|
||
function numberOrNull(value: number | undefined): number | null {
|
||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||
}
|
||
|
||
function prCompactSummary(pr: GitHubPullRequest): Record<string, unknown> {
|
||
return {
|
||
id: pr.id,
|
||
number: pr.number,
|
||
title: pr.title,
|
||
state: pr.state,
|
||
draft: pr.draft ?? false,
|
||
url: pr.html_url,
|
||
author: pr.user?.login ?? null,
|
||
head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null },
|
||
base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null },
|
||
createdAt: pr.created_at ?? null,
|
||
updatedAt: pr.updated_at ?? null,
|
||
};
|
||
}
|
||
|
||
function prFileSummary(file: GitHubPullRequestFile): Record<string, unknown> {
|
||
return {
|
||
filename: file.filename,
|
||
status: file.status ?? null,
|
||
additions: numberOrNull(file.additions),
|
||
deletions: numberOrNull(file.deletions),
|
||
changes: numberOrNull(file.changes),
|
||
previousFilename: file.previous_filename ?? null,
|
||
sha: file.sha ?? null,
|
||
blobUrl: file.blob_url ?? null,
|
||
contentsUrl: file.contents_url ?? null,
|
||
};
|
||
}
|
||
|
||
function sumPrFileStats(files: GitHubPullRequestFile[]): { additions: number; deletions: number; changes: number } {
|
||
return files.reduce((accumulator, file) => {
|
||
accumulator.additions += file.additions ?? 0;
|
||
accumulator.deletions += file.deletions ?? 0;
|
||
accumulator.changes += file.changes ?? ((file.additions ?? 0) + (file.deletions ?? 0));
|
||
return accumulator;
|
||
}, { additions: 0, deletions: 0, changes: 0 });
|
||
}
|
||
|
||
function selectedPrJson(summary: Record<string, unknown>, fields: readonly string[]): Record<string, unknown> {
|
||
const selected: Record<string, unknown> = {};
|
||
for (const field of fields) selected[field] = summary[field];
|
||
return selected;
|
||
}
|
||
|
||
function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> {
|
||
return {
|
||
mergeable: metadata.mergeable ?? null,
|
||
mergeStateStatus: metadata.mergeStateStatus ?? null,
|
||
statusCheckRollup: metadata.statusCheckRollup ?? null,
|
||
headRefName: metadata.headRefName ?? null,
|
||
baseRefName: metadata.baseRefName ?? null,
|
||
};
|
||
}
|
||
|
||
function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean {
|
||
if (fields === undefined) return false;
|
||
return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup");
|
||
}
|
||
|
||
async function prGraphqlMetadata(repo: string, token: string, number: number): Promise<GitHubPullRequestGraphqlMetadata | GitHubErrorPayload> {
|
||
const { owner, name } = repoParts(repo);
|
||
const query = `
|
||
query PullRequestCloseoutMetadata($owner: String!, $name: String!, $number: Int!) {
|
||
repository(owner: $owner, name: $name) {
|
||
pullRequest(number: $number) {
|
||
mergeable
|
||
mergeStateStatus
|
||
headRefName
|
||
baseRefName
|
||
statusCheckRollup {
|
||
state
|
||
contexts(first: 100) {
|
||
nodes {
|
||
__typename
|
||
... on CheckRun {
|
||
name
|
||
status
|
||
conclusion
|
||
}
|
||
... on StatusContext {
|
||
context
|
||
state
|
||
targetUrl
|
||
description
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
`;
|
||
const response = await githubGraphqlRequest<{ repository?: { pullRequest?: GitHubPullRequestGraphqlMetadata | null } }>(token, query, { owner, name, number });
|
||
if (isGitHubError(response)) return response;
|
||
const pullRequest = response.repository?.pullRequest;
|
||
if (pullRequest === null || pullRequest === undefined) {
|
||
return errorPayload("invalid-response", "GitHub GraphQL PR metadata response was missing repository.pullRequest", { details: response });
|
||
}
|
||
return pullRequest;
|
||
}
|
||
|
||
function repoSummary(repo: GitHubRepository): Record<string, unknown> {
|
||
return {
|
||
id: repo.id ?? null,
|
||
fullName: repo.full_name ?? null,
|
||
private: repo.private ?? null,
|
||
defaultBranch: repo.default_branch ?? null,
|
||
permissions: repo.permissions ?? null,
|
||
};
|
||
}
|
||
|
||
function branchSummary(branch: GitHubBranch): Record<string, unknown> {
|
||
return {
|
||
name: branch.name ?? null,
|
||
sha: branch.commit?.sha ?? null,
|
||
};
|
||
}
|
||
|
||
function compareSummary(compare: GitHubCompare): Record<string, unknown> {
|
||
return {
|
||
status: compare.status ?? null,
|
||
aheadBy: compare.ahead_by ?? null,
|
||
behindBy: compare.behind_by ?? null,
|
||
totalCommits: compare.total_commits ?? null,
|
||
htmlUrl: compare.html_url ?? null,
|
||
baseSha: compare.base_commit?.sha ?? null,
|
||
mergeBaseSha: compare.merge_base_commit?.sha ?? null,
|
||
};
|
||
}
|
||
|
||
function prUrl(repo: string, number: number): string {
|
||
return `https://github.com/${repo}/pull/${number}`;
|
||
}
|
||
|
||
function encodePathPart(value: string): string {
|
||
return encodeURIComponent(value);
|
||
}
|
||
|
||
async function repoInfo(token: string, repo: string): Promise<GitHubRepository | GitHubErrorPayload> {
|
||
const { owner, name } = repoParts(repo);
|
||
return githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`);
|
||
}
|
||
|
||
async function branchInfo(token: string, repo: string, branch: string): Promise<GitHubBranch | GitHubErrorPayload> {
|
||
const { owner, name } = repoParts(repo);
|
||
return githubRequest<GitHubBranch>(token, "GET", `/repos/${owner}/${name}/branches/${encodePathPart(branch)}`);
|
||
}
|
||
|
||
async function compareBranches(token: string, repo: string, base: string, head: string): Promise<GitHubCompare | GitHubErrorPayload> {
|
||
const { owner, name } = repoParts(repo);
|
||
return githubRequest<GitHubCompare>(token, "GET", `/repos/${owner}/${name}/compare/${encodePathPart(base)}...${encodePathPart(head)}`);
|
||
}
|
||
|
||
function prCreatePlannedOperation(repo: string, options: GitHubOptions, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
|
||
return {
|
||
repo,
|
||
title: options.title,
|
||
base: options.base,
|
||
head: options.head,
|
||
draft: options.draft,
|
||
bodyChars: body.length,
|
||
bodyPreview: preview(body),
|
||
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
|
||
bodySource,
|
||
preservesRawNewlines: body.includes("\n"),
|
||
containsLiteralBackslashN: body.includes("\\n"),
|
||
containsBackticks: body.includes("`"),
|
||
request: {
|
||
method: "POST",
|
||
path: "/repos/{owner}/{repo}/pulls",
|
||
body: {
|
||
title: options.title,
|
||
base: options.base,
|
||
head: options.head,
|
||
draft: options.draft,
|
||
bodyChars: body.length,
|
||
},
|
||
},
|
||
validation: {
|
||
repo: "required",
|
||
base: "required",
|
||
head: "required",
|
||
title: "required",
|
||
body: body.length > 0 ? "optional" : "empty",
|
||
},
|
||
};
|
||
}
|
||
|
||
function prCommentPlannedOperation(repo: string, issueNumber: number, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
|
||
return {
|
||
repo,
|
||
issueNumber,
|
||
bodyChars: body.length,
|
||
bodyPreview: preview(body),
|
||
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
|
||
bodySource,
|
||
preservesRawNewlines: body.includes("\n"),
|
||
containsLiteralBackslashN: body.includes("\\n"),
|
||
containsBackticks: body.includes("`"),
|
||
request: {
|
||
method: "POST",
|
||
path: `/repos/{owner}/{repo}/issues/${issueNumber}/comments`,
|
||
body: { bodyChars: body.length },
|
||
},
|
||
validation: {
|
||
repo: "required",
|
||
issueNumber: "required",
|
||
body: "required",
|
||
},
|
||
};
|
||
}
|
||
|
||
function bodyUpdatePlan(command: string, repo: string, number: number, mode: BodyUpdateMode, body: string, bodySource: Record<string, unknown>, existingBody: string | null): Record<string, unknown> {
|
||
const finalBody = mode === "append" ? `${existingBody ?? ""}${body}` : body;
|
||
return {
|
||
command,
|
||
repo,
|
||
number,
|
||
mode,
|
||
bodySource,
|
||
input: bodySafetySignals(body),
|
||
existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) },
|
||
finalBody: {
|
||
bodyChars: finalBody.length,
|
||
bodyPreview: preview(finalBody),
|
||
bodyPreviewLines: previewLines(finalBody),
|
||
...bodySafetySignals(finalBody),
|
||
},
|
||
};
|
||
}
|
||
|
||
function prEditBodyPlan(mode: BodyUpdateMode, input: { body: string; bodySource: Record<string, unknown> } | null, finalBody: string | undefined, existingBody: string | null): Record<string, unknown> | null {
|
||
if (input === null || finalBody === undefined) return null;
|
||
return {
|
||
mode,
|
||
bodySource: input.bodySource,
|
||
input: bodySafetySignals(input.body),
|
||
existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) },
|
||
finalBody: bodySafetySignals(finalBody),
|
||
};
|
||
}
|
||
|
||
async function prCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title <title>");
|
||
if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>");
|
||
if (options.head === undefined) return validationError("pr create", repo, "pr create requires --head <branch>");
|
||
let bodySource: { body: string; bodySource: Record<string, unknown> };
|
||
try {
|
||
bodySource = readMarkdownBody(options, "pr create");
|
||
} catch (error) {
|
||
return validationError("pr create", repo, error instanceof Error ? error.message : String(error));
|
||
}
|
||
const body = bodySource.body;
|
||
const planned = prCreatePlannedOperation(repo, options, body, bodySource.bodySource);
|
||
if (options.dryRun) {
|
||
return {
|
||
ok: true,
|
||
command: "pr create",
|
||
repo,
|
||
dryRun: true,
|
||
planned: true,
|
||
draft: options.draft,
|
||
...planned,
|
||
};
|
||
}
|
||
|
||
const repoResult = await repoInfo(token, repo);
|
||
if (isGitHubError(repoResult)) return commandError("pr create", repo, repoResult, { planned });
|
||
const baseBranch = await branchInfo(token, repo, options.base);
|
||
if (isGitHubError(baseBranch)) return commandError("pr create", repo, baseBranch, { base: options.base, planned });
|
||
const headBranch = await branchInfo(token, repo, options.head);
|
||
if (isGitHubError(headBranch)) return commandError("pr create", repo, headBranch, { head: options.head, planned });
|
||
const compare = await compareBranches(token, repo, options.base, options.head);
|
||
if (isGitHubError(compare)) return commandError("pr create", repo, compare, { base: options.base, head: options.head, planned });
|
||
const aheadBy = typeof compare.ahead_by === "number" ? compare.ahead_by : null;
|
||
if (aheadBy === null || aheadBy <= 0) {
|
||
return validationError("pr create", repo, "head branch must be ahead of base branch before creating a PR", {
|
||
base: options.base,
|
||
head: options.head,
|
||
compare: compareSummary(compare),
|
||
});
|
||
}
|
||
|
||
const { owner, name } = repoParts(repo);
|
||
const payload: Record<string, unknown> = {
|
||
title: options.title,
|
||
base: options.base,
|
||
head: options.head,
|
||
draft: options.draft,
|
||
body,
|
||
};
|
||
const pr = await githubRequest<GitHubPullRequest>(token, "POST", `/repos/${owner}/${name}/pulls`, payload);
|
||
if (isGitHubError(pr)) return commandError("pr create", repo, pr, { base: options.base, head: options.head, planned });
|
||
return {
|
||
ok: true,
|
||
command: "pr create",
|
||
repo,
|
||
pr: prSummary(pr),
|
||
validation: {
|
||
repo: repoSummary(repoResult),
|
||
base: branchSummary(baseBranch),
|
||
head: branchSummary(headBranch),
|
||
compare: compareSummary(compare),
|
||
bodySource: bodySource.bodySource,
|
||
},
|
||
request: {
|
||
method: "POST",
|
||
path: `/repos/${owner}/${name}/pulls`,
|
||
title: options.title,
|
||
base: options.base,
|
||
head: options.head,
|
||
draft: options.draft,
|
||
bodyChars: body.length,
|
||
},
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function prComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
let bodySource: { body: string; bodySource: Record<string, unknown> };
|
||
try {
|
||
bodySource = readMarkdownBody(options, "pr comment");
|
||
} catch (error) {
|
||
return validationError("pr comment", repo, error instanceof Error ? error.message : String(error), { issueNumber });
|
||
}
|
||
const body = bodySource.body;
|
||
const planned = prCommentPlannedOperation(repo, issueNumber, body, bodySource.bodySource);
|
||
if (options.dryRun) {
|
||
return {
|
||
ok: true,
|
||
command: "pr comment",
|
||
repo,
|
||
dryRun: true,
|
||
planned: true,
|
||
issueNumber,
|
||
...planned,
|
||
};
|
||
}
|
||
|
||
const repoResult = await repoInfo(token, repo);
|
||
if (isGitHubError(repoResult)) return commandError("pr comment", repo, repoResult, { issueNumber, planned });
|
||
const { owner, name } = repoParts(repo);
|
||
const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`);
|
||
if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned });
|
||
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
||
if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned });
|
||
return {
|
||
ok: true,
|
||
command: "pr comment create",
|
||
repo,
|
||
issueNumber,
|
||
pr: prSummary(prResult),
|
||
comment: commentSummary(comment),
|
||
request: {
|
||
method: "POST",
|
||
path: `/repos/${owner}/${name}/issues/${issueNumber}/comments`,
|
||
bodyChars: body.length,
|
||
},
|
||
validation: {
|
||
repo: repoSummary(repoResult),
|
||
pr: prSummary(prResult),
|
||
bodySource: bodySource.bodySource,
|
||
},
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function prUpdate(repo: string, token: string, number: number, options: GitHubOptions, commandName = "pr update"): Promise<GitHubCommandResult> {
|
||
let bodySource: { body: string; bodySource: Record<string, unknown> } | null;
|
||
try {
|
||
bodySource = readOptionalMarkdownBody(options, commandName);
|
||
} catch (error) {
|
||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { number });
|
||
}
|
||
if (bodySource === null && options.title === undefined) {
|
||
return validationError(commandName, repo, `${commandName} requires --title <title>, --body-file <file|->, or --body <text>`, { number });
|
||
}
|
||
if (options.mode === "append" && bodySource === null) {
|
||
return validationError(commandName, repo, `${commandName} --mode append requires a body source`, { number });
|
||
}
|
||
const { owner, name } = repoParts(repo);
|
||
let oldPr: GitHubPullRequest | null = null;
|
||
if (token.length > 0 && options.mode === "append") {
|
||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, phase: "read-before-update" });
|
||
oldPr = pr;
|
||
}
|
||
const finalBody = bodySource === null ? undefined : (options.mode === "append" ? `${oldPr?.body ?? ""}${bodySource.body}` : bodySource.body);
|
||
const changedFields = [
|
||
...(options.title === undefined ? [] : ["title"]),
|
||
...(bodySource === null ? [] : ["body"]),
|
||
];
|
||
const bodyPlan = prEditBodyPlan(options.mode, bodySource, finalBody, oldPr?.body ?? null);
|
||
const request = {
|
||
method: "PATCH",
|
||
path: `/repos/{owner}/{repo}/pulls/${number}`,
|
||
changedFields,
|
||
body: {
|
||
...(options.title === undefined ? {} : { title: "provided" }),
|
||
...(finalBody === undefined ? {} : { bodyChars: finalBody.length, bodySha: bodySha(finalBody) }),
|
||
},
|
||
};
|
||
if (options.dryRun) {
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
dryRun: true,
|
||
planned: true,
|
||
number,
|
||
url: prUrl(repo, number),
|
||
changedFields,
|
||
...(bodyPlan === null ? {} : { body: bodyPlan }),
|
||
request,
|
||
graphQl: false,
|
||
projectsClassic: false,
|
||
};
|
||
}
|
||
const payload: Record<string, unknown> = {};
|
||
if (finalBody !== undefined) payload.body = finalBody;
|
||
if (options.title !== undefined) payload.title = options.title;
|
||
const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, payload);
|
||
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, planned: { changedFields, body: bodyPlan, request } });
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
pr: pr.number ?? number,
|
||
number: pr.number ?? number,
|
||
changedFields,
|
||
url: pr.html_url ?? prUrl(repo, number),
|
||
...(bodyPlan === null ? {} : { body: bodyPlan }),
|
||
request: { ...request, path: `/repos/${owner}/${name}/pulls/${number}` },
|
||
rest: true,
|
||
graphQl: false,
|
||
projectsClassic: false,
|
||
};
|
||
}
|
||
|
||
async function prState(repo: string, token: string, number: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> {
|
||
const command = state === "closed" ? "pr close" : "pr reopen";
|
||
if (dryRun) return { ok: true, command, repo, dryRun: true, number, wouldPatch: { state } };
|
||
const { owner, name } = repoParts(repo);
|
||
const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, { state });
|
||
if (isGitHubError(pr)) return commandError(command, repo, pr, { number });
|
||
return { ok: true, command, repo, number, pullRequest: prSummary(pr), rest: true };
|
||
}
|
||
|
||
async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> {
|
||
const { owner, name } = repoParts(repo);
|
||
return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`);
|
||
}
|
||
|
||
async function listIssues(token: string, repo: string, state: IssueListState, limit: number): Promise<GitHubIssue[] | GitHubErrorPayload> {
|
||
const { owner, name } = repoParts(repo);
|
||
return githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=${state}&per_page=${limit}`);
|
||
}
|
||
|
||
async function getIssue(token: string, repo: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
|
||
const { owner, name } = repoParts(repo);
|
||
return githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`);
|
||
}
|
||
|
||
function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null, fields: IssueViewJsonField[] | undefined): Record<string, unknown> | null {
|
||
if (fields === undefined) return null;
|
||
const summary = issueSummary(issue);
|
||
const selected: Record<string, unknown> = {};
|
||
for (const field of fields) {
|
||
if (field === "comments") {
|
||
selected.comments = (comments ?? []).map(commentSummary);
|
||
} else {
|
||
selected[field] = summary[field];
|
||
}
|
||
}
|
||
return selected;
|
||
}
|
||
|
||
async function issueRead(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, commandName = "issue read", disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
|
||
const issue = await getIssue(token, repo, issueNumber);
|
||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
|
||
const needsComments = jsonFields === undefined || jsonFields.includes("comments");
|
||
const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null;
|
||
if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue) });
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
...(disclosure === null ? {} : { disclosure }),
|
||
issue: issueSummary(issue),
|
||
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(issueNumber, issue.body ?? ""),
|
||
...(comments === null ? {} : { comments: comments.map(commentSummary) }),
|
||
...(jsonFields === undefined ? {} : {
|
||
jsonFields,
|
||
json: selectedIssueJson(issue, comments, jsonFields),
|
||
compatibility: {
|
||
legacyJsonBodyPath: ".data.issue.body",
|
||
},
|
||
}),
|
||
};
|
||
}
|
||
|
||
async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
|
||
return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure);
|
||
}
|
||
|
||
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined): Promise<GitHubCommandResult> {
|
||
const rawIssues = await listIssues(token, repo, state, limit);
|
||
if (isGitHubError(rawIssues)) return commandError("issue list", repo, rawIssues, { state, limit });
|
||
const issues = rawIssues.filter((issue) => issue.pull_request === undefined).slice(0, limit);
|
||
const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice();
|
||
return {
|
||
ok: true,
|
||
command: "issue list",
|
||
repo,
|
||
state,
|
||
limit,
|
||
count: issues.length,
|
||
rawCount: rawIssues.length,
|
||
jsonFields: fields,
|
||
issues: issues.map((issue) => issueListSummary(issue, fields)),
|
||
request: {
|
||
method: "GET",
|
||
path: "/repos/{owner}/{repo}/issues",
|
||
query: { state, per_page: limit },
|
||
},
|
||
note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.",
|
||
};
|
||
}
|
||
|
||
async function issueBoardAudit(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const commandName = "issue board-audit";
|
||
const boardIssue = await getIssue(token, repo, options.boardIssue);
|
||
if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue });
|
||
const parsed = parseBoardTables(boardIssue.body ?? "");
|
||
const openRows = parsed.sections.filter((section) => section.kind === "open").flatMap((section) => section.rows);
|
||
const closedRows = parsed.sections.filter((section) => section.kind === "closed").flatMap((section) => section.rows);
|
||
const body = boardIssue.body ?? "";
|
||
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
dryRun: true,
|
||
planned: true,
|
||
readOnly: true,
|
||
boardIssue: {
|
||
number: boardIssue.number,
|
||
title: boardIssue.title,
|
||
state: boardIssue.state,
|
||
url: boardIssue.html_url,
|
||
bodyChars: body.length,
|
||
bodyLines: body.length === 0 ? 0 : normalizeNewlines(body).split("\n").length,
|
||
bodySha: bodySha(body),
|
||
updatedAt: boardIssue.updated_at ?? null,
|
||
},
|
||
config: {
|
||
limit: options.limit,
|
||
knownMetaIssues: mergedKnownMetaIssues(options),
|
||
ignoredIssues: options.ignoredIssues,
|
||
briefIndexManagedIssuePattern: options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE ? "YYYY-MM-DD 指挥简报(北京时间)" : null,
|
||
openClosedCoverageValidation: false,
|
||
requiredColumns: [],
|
||
},
|
||
summary: {
|
||
openIssues: null,
|
||
closedIssues: null,
|
||
openRows: openRows.length,
|
||
closedRows: closedRows.length,
|
||
parsedSections: parsed.sections.length,
|
||
parsedRows: parsed.sections.reduce((sum, section) => sum + section.rows.length, 0),
|
||
parserWarnings: parsed.warnings.length,
|
||
missingOpenIssues: 0,
|
||
closedInOpenRows: 0,
|
||
missingClosedRows: 0,
|
||
openInClosedRows: 0,
|
||
rowValidationWarnings: 0,
|
||
ignoredIssues: 0,
|
||
boardOnlyRows: 0,
|
||
staleOpenRows: 0,
|
||
recommendedActions: 0,
|
||
},
|
||
validation: {
|
||
openClosedCoverage: {
|
||
enabled: false,
|
||
reason: "OPEN/CLOSED table coverage validation has been disabled; this command no longer compares GitHub issue state against board rows.",
|
||
},
|
||
rowRequiredColumns: {
|
||
enabled: false,
|
||
reason: "board-audit no longer reports missing required row columns; use board-row commands when intentionally maintaining a legacy OPEN/CLOSED table.",
|
||
},
|
||
},
|
||
sections: parsed.sections.map((section) => ({
|
||
kind: section.kind,
|
||
heading: section.heading,
|
||
headingLine: section.headingLine,
|
||
headerLine: section.headerLine,
|
||
headers: section.headers,
|
||
rows: section.rows.length,
|
||
issueNumbers: section.rows.flatMap((row) => row.issueNumbers),
|
||
})),
|
||
parserWarnings: parsed.warnings,
|
||
missingOpenIssues: [],
|
||
closedInOpenRows: [],
|
||
missingClosedRows: [],
|
||
openInClosedRows: [],
|
||
staleOpenRows: [],
|
||
boardOnlyRows: [],
|
||
rowValidationWarnings: [],
|
||
ignoredIssues: [],
|
||
recommendedActions: [],
|
||
request: {
|
||
method: "GET",
|
||
paths: [
|
||
`/repos/{owner}/{repo}/issues/${options.boardIssue}`,
|
||
],
|
||
query: {},
|
||
},
|
||
note: "Read-only board structure audit; OPEN/CLOSED coverage validation is disabled, no issue body was edited, no issue was closed, and no comments were written.",
|
||
};
|
||
}
|
||
|
||
async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
if (options.title === undefined) throw new Error("issue create requires --title <title>");
|
||
const body = readBodyFile(options.bodyFile, "issue create");
|
||
const bodySource = { kind: "body-file", path: options.bodyFile ?? null };
|
||
const labels = options.labels;
|
||
if (options.dryRun) {
|
||
return {
|
||
ok: true,
|
||
command: "issue create",
|
||
repo,
|
||
dryRun: true,
|
||
planned: true,
|
||
title: options.title,
|
||
labels,
|
||
...writeBodyPlan("issue create", repo, body, bodySource, { title: options.title, labels }),
|
||
};
|
||
}
|
||
const { owner, name } = repoParts(repo);
|
||
const payload: Record<string, unknown> = { title: options.title, body };
|
||
if (labels.length > 0) payload.labels = labels;
|
||
const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, payload);
|
||
if (isGitHubError(issue)) return commandError("issue create", repo, issue, { title: options.title, labels });
|
||
const appliedLabels = issueLabelNames(issue);
|
||
const missingLabels = labels.filter((label) => !appliedLabels.includes(label));
|
||
if (missingLabels.length > 0) {
|
||
return validationError("issue create", repo, "GitHub created the issue but did not return all requested labels; refusing to report silent label success", {
|
||
issue: issueSummary(issue),
|
||
requestedLabels: labels,
|
||
appliedLabels,
|
||
missingLabels,
|
||
});
|
||
}
|
||
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true };
|
||
}
|
||
|
||
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> {
|
||
const body = readBodyFile(options.bodyFile, commandName);
|
||
const needsProfileMetadata = issueProfileNeedsMetadata(issueNumber, options.bodyProfile);
|
||
if (options.mode === "replace" && !needsProfileMetadata) {
|
||
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
|
||
if (bodyGuard !== null) return bodyGuard;
|
||
}
|
||
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
|
||
if (concurrencyOptionError !== null) return concurrencyOptionError;
|
||
let oldIssue: GitHubIssue | null = null;
|
||
let briefDiff: CommanderBriefDiff | null = null;
|
||
let profileContext: IssueProfileValidationContext = {};
|
||
const claudeQqConfig = commanderBriefClaudeQqConfig();
|
||
if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) {
|
||
return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber });
|
||
}
|
||
const needsReadBeforeEdit = options.mode === "append"
|
||
|| needsProfileMetadata && token.length > 0
|
||
|| !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined);
|
||
if (needsReadBeforeEdit) {
|
||
const issue = await getIssue(token, repo, issueNumber);
|
||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" });
|
||
oldIssue = issue;
|
||
profileContext = { issueTitle: issue.title, issueBody: issue.body, issueMetadataFetched: true };
|
||
const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options);
|
||
if (concurrencyError !== null) return concurrencyError;
|
||
if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body);
|
||
}
|
||
const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body;
|
||
if (options.mode === "append" || needsProfileMetadata) {
|
||
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options, profileContext);
|
||
if (bodyGuard !== null) return bodyGuard;
|
||
}
|
||
if (options.dryRun) {
|
||
const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null;
|
||
const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext);
|
||
let dryRunOldBody: Record<string, unknown> = {
|
||
fetched: false,
|
||
bodyChars: null,
|
||
bodySha: null,
|
||
updatedAt: null,
|
||
reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run",
|
||
};
|
||
if (oldIssue !== null) {
|
||
const oldBody = oldIssue.body ?? "";
|
||
dryRunOldBody = {
|
||
fetched: true,
|
||
bodyChars: oldBody.length,
|
||
bodySha: bodySha(oldBody),
|
||
updatedAt: oldIssue.updated_at ?? null,
|
||
};
|
||
} else if (token.length > 0) {
|
||
const issue = await getIssue(token, repo, issueNumber);
|
||
if (isGitHubError(issue)) {
|
||
dryRunOldBody = {
|
||
fetched: false,
|
||
bodyChars: null,
|
||
bodySha: null,
|
||
updatedAt: null,
|
||
readError: {
|
||
degradedReason: issue.degradedReason,
|
||
runnerDisposition: issue.runnerDisposition,
|
||
message: issue.message,
|
||
status: issue.status ?? null,
|
||
},
|
||
};
|
||
} else {
|
||
const oldBody = issue.body ?? "";
|
||
dryRunOldBody = {
|
||
fetched: true,
|
||
bodyChars: oldBody.length,
|
||
bodySha: bodySha(oldBody),
|
||
updatedAt: issue.updated_at ?? null,
|
||
};
|
||
}
|
||
}
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
dryRun: true,
|
||
issueNumber,
|
||
mode: options.mode,
|
||
...dryRunBody(repo, options.title, finalBody),
|
||
guard,
|
||
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, { kind: "body-file", path: options.bodyFile ?? null }, oldIssue?.body ?? null),
|
||
bodyOnlySafety: {
|
||
oldBody: dryRunOldBody,
|
||
newBody: {
|
||
bodyChars: finalBody.length,
|
||
bodySha: bodySha(finalBody),
|
||
},
|
||
},
|
||
concurrency: {
|
||
dryRunNoWrite: true,
|
||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||
expectBodySha: options.expectBodySha ?? null,
|
||
note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH",
|
||
},
|
||
wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile },
|
||
...(options.notifyClaudeQqBriefDiff
|
||
? {
|
||
commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, finalBody, dryRunDiff ?? commanderBriefDiff("", finalBody), claudeQqConfig),
|
||
dryRunOldBodySource: "not-fetched",
|
||
dryRunDiffReliability: "new-body-only preview; non-dry-run reads the GitHub issue body before PATCH and sends only sections absent from the old body",
|
||
dryRunNoWrite: true,
|
||
dryRunNoClaudeQqSend: true,
|
||
}
|
||
: {}),
|
||
};
|
||
}
|
||
const { owner, name } = repoParts(repo);
|
||
const payload: Record<string, unknown> = { body: finalBody };
|
||
if (options.title !== undefined) payload.title = options.title;
|
||
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload);
|
||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
|
||
const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext);
|
||
const concurrency = oldIssue === null
|
||
? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }
|
||
: { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null };
|
||
if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: commandName, repo, issue: issueSummary(issue), mode: options.mode, guard, concurrency, rest: true };
|
||
|
||
const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody);
|
||
const claudeqq = diff.ok
|
||
? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message)
|
||
: {
|
||
ok: true,
|
||
attempted: false,
|
||
skipped: true,
|
||
skippedReason: diff.skippedReason ?? "no commander brief diff to send",
|
||
target: maskedTarget(claudeQqConfig),
|
||
} satisfies ClaudeQqSendResult;
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
issue: issueSummary(issue),
|
||
guard,
|
||
concurrency,
|
||
rest: true,
|
||
commanderBriefNotification: {
|
||
issueNumber,
|
||
oldIssueUpdatedAt: oldIssue?.updated_at ?? null,
|
||
diff: {
|
||
ok: diff.ok,
|
||
mode: diff.mode,
|
||
chars: diff.chars,
|
||
sectionCount: diff.sectionCount,
|
||
skippedReason: diff.skippedReason ?? null,
|
||
preview: diff.ok ? preview(diff.message) : "",
|
||
previewLines: diff.ok ? previewLines(diff.message) : [],
|
||
containsLiteralBackslashN: diff.message.includes("\\n"),
|
||
containsBackticks: diff.message.includes("`"),
|
||
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message),
|
||
},
|
||
},
|
||
claudeqq,
|
||
};
|
||
}
|
||
|
||
async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const body = readBodyFile(options.bodyFile, "issue comment");
|
||
const bodySource = { kind: "body-file", path: options.bodyFile ?? null };
|
||
if (options.dryRun) {
|
||
return {
|
||
ok: true,
|
||
command: "issue comment create",
|
||
repo,
|
||
dryRun: true,
|
||
planned: true,
|
||
issueNumber,
|
||
...writeBodyPlan("issue comment create", repo, body, bodySource, { issueNumber }),
|
||
};
|
||
}
|
||
const { owner, name } = repoParts(repo);
|
||
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
||
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
|
||
return { ok: true, command: "issue comment create", repo, issueNumber, comment: commentSummary(comment), bodySource, rest: true };
|
||
}
|
||
|
||
async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> {
|
||
const command = `${ownerKind} comment delete`;
|
||
const { owner, name } = repoParts(repo);
|
||
if (dryRun) {
|
||
return {
|
||
ok: true,
|
||
command,
|
||
repo,
|
||
dryRun: true,
|
||
planned: true,
|
||
commentId,
|
||
request: { method: "DELETE", path: `/repos/{owner}/{repo}/issues/comments/${commentId}` },
|
||
};
|
||
}
|
||
const result = await githubRequest<null>(token, "DELETE", `/repos/${owner}/${name}/issues/comments/${commentId}`);
|
||
if (isGitHubError(result)) return commandError(command, repo, result, { commentId });
|
||
return {
|
||
ok: true,
|
||
command,
|
||
repo,
|
||
commentId,
|
||
deleted: true,
|
||
request: { method: "DELETE", path: `/repos/${owner}/${name}/issues/comments/${commentId}` },
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> {
|
||
if (dryRun) return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", dryRun: true, repo, issueNumber, wouldPatch: { state } };
|
||
const { owner, name } = repoParts(repo);
|
||
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state });
|
||
if (isGitHubError(issue)) return commandError(state === "closed" ? "issue close" : "issue reopen", repo, issue, { issueNumber });
|
||
return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", repo, issue: issueSummary(issue), rest: true };
|
||
}
|
||
|
||
function escapeSnippet(text: string, index: number, radius = 80): string {
|
||
const start = Math.max(0, index - radius);
|
||
const end = Math.min(text.length, index + radius + 40);
|
||
return text.slice(start, end).replace(/\n/g, "\\n");
|
||
}
|
||
|
||
function lineColumnAt(text: string, index: number): { lineNumber: number; column: number } {
|
||
let lineNumber = 1;
|
||
let column = 1;
|
||
for (let cursor = 0; cursor < index && cursor < text.length; cursor += 1) {
|
||
if (text[cursor] === "\n") {
|
||
lineNumber += 1;
|
||
column = 1;
|
||
} else {
|
||
column += 1;
|
||
}
|
||
}
|
||
return { lineNumber, column };
|
||
}
|
||
|
||
function lineTextAt(text: string, lineNumber: number): string {
|
||
const lines = text.split(/\r?\n/);
|
||
return lines[lineNumber - 1] ?? "";
|
||
}
|
||
|
||
function isInsideFencedCodeBlock(text: string, index: number): boolean {
|
||
const lines = text.slice(0, index).split(/\r?\n/);
|
||
let openFence = false;
|
||
for (const line of lines) {
|
||
const trimmed = line.trimStart();
|
||
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) openFence = !openFence;
|
||
}
|
||
return openFence;
|
||
}
|
||
|
||
function isInsideInlineCode(text: string, index: number): boolean {
|
||
const { lineNumber, column } = lineColumnAt(text, index);
|
||
const line = lineTextAt(text, lineNumber);
|
||
const before = line.slice(0, Math.max(0, column - 1));
|
||
const after = line.slice(Math.max(0, column - 1));
|
||
return (before.match(/`/g)?.length ?? 0) % 2 === 1 && (after.match(/`/g)?.length ?? 0) > 0;
|
||
}
|
||
|
||
function isExplanatoryLiteralBackslashN(text: string, index: number): boolean {
|
||
if (isInsideFencedCodeBlock(text, index) || isInsideInlineCode(text, index)) return true;
|
||
const window = text.slice(Math.max(0, index - 90), Math.min(text.length, index + 90)).toLowerCase();
|
||
return [
|
||
"字面量",
|
||
"说明",
|
||
"示例",
|
||
"举例",
|
||
"提到",
|
||
"引用",
|
||
"文本",
|
||
"字符串",
|
||
"escape",
|
||
"literal",
|
||
"example",
|
||
"mention",
|
||
"quoted",
|
||
"反斜杠",
|
||
].some((keyword) => window.includes(keyword));
|
||
}
|
||
|
||
function cleanupEscapedText(text: string): string {
|
||
return text
|
||
.replace(/\\r\\n/g, "\n")
|
||
.replace(/\\n/g, "\n")
|
||
.replace(/\\t/g, "\t")
|
||
.replace(/\\x0a/gi, "\n")
|
||
.replace(/\\x1b/gi, "")
|
||
.replace(/\\u001b/gi, "")
|
||
.replace(/\\033/g, "")
|
||
.replace(/\\`/g, "`");
|
||
}
|
||
|
||
function escapeBodyId(bodyKind: EscapeBodyKind, issueNumber: number, issueId: number, commentId?: number): string {
|
||
return bodyKind === "issue-body"
|
||
? `issue:${issueNumber}:body:${issueId}`
|
||
: `issue:${issueNumber}:comment:${commentId ?? "unknown"}`;
|
||
}
|
||
|
||
function textFindingPatterns(): EscapePatternDefinition[] {
|
||
return [
|
||
{
|
||
kind: "literal-backslash-n",
|
||
pattern: /\\n/g,
|
||
description: "literal backslash-n",
|
||
},
|
||
{
|
||
kind: "literal-backslash-t",
|
||
pattern: /\\t/g,
|
||
description: "literal backslash-t",
|
||
},
|
||
{
|
||
kind: "escaped-backtick",
|
||
pattern: /\\`/g,
|
||
description: "escaped backtick",
|
||
},
|
||
{
|
||
kind: "shell-escaped-newline",
|
||
pattern: /\\r\\n|\\012|\\x0a/gi,
|
||
description: "shell escaped newline",
|
||
},
|
||
{
|
||
kind: "ansi-escape-literal",
|
||
pattern: /\\x1b|\\u001b|\\033/gi,
|
||
description: "ANSI escape literal",
|
||
},
|
||
];
|
||
}
|
||
|
||
function bodyRiskFindings(kind: EscapeBodyKind, issueNumber: number, issueId: number, body: string | null, url: string, commentId?: number): EscapeMatchFinding[] {
|
||
const bodyChars = body === null ? null : body.length;
|
||
const bodyTrimmedChars = body === null ? null : body.trim().length;
|
||
const location = {
|
||
type: kind === "issue-body" ? "issue" as const : "comment" as const,
|
||
bodyKind: kind,
|
||
bodyId: escapeBodyId(kind, issueNumber, issueId, commentId),
|
||
issueNumber,
|
||
issueId,
|
||
...(commentId === undefined ? {} : { commentId }),
|
||
url,
|
||
bodyChars,
|
||
bodyTrimmedChars,
|
||
};
|
||
if (body === null) {
|
||
return [{
|
||
...location,
|
||
kind: "null-body",
|
||
classification: "risk",
|
||
severity: "high",
|
||
reason: "GitHub returned a null body",
|
||
snippet: "",
|
||
lineNumber: 0,
|
||
column: 0,
|
||
match: "",
|
||
}];
|
||
}
|
||
const trimmed = body.trim();
|
||
if (trimmed.toLowerCase() === "null") {
|
||
return [{
|
||
...location,
|
||
kind: "literal-null-body",
|
||
classification: "risk",
|
||
severity: "high",
|
||
reason: "body text is the literal string null",
|
||
snippet: escapeSnippet(body, 0),
|
||
lineNumber: 1,
|
||
column: 1,
|
||
match: "null",
|
||
}];
|
||
}
|
||
if (trimmed.length === 0) {
|
||
return [{
|
||
...location,
|
||
kind: "blank-body",
|
||
classification: "risk",
|
||
severity: "medium",
|
||
reason: "body is blank after trimming",
|
||
snippet: "",
|
||
lineNumber: 0,
|
||
column: 0,
|
||
match: "",
|
||
}];
|
||
}
|
||
if (trimmed.length < MIN_SAFE_BODY_SCAN_CHARS) {
|
||
return [{
|
||
...location,
|
||
kind: "short-body",
|
||
classification: "risk",
|
||
severity: "medium",
|
||
reason: `body is shorter than the safe guard threshold (${MIN_SAFE_BODY_SCAN_CHARS} chars)`,
|
||
snippet: escapeSnippet(body, 0),
|
||
lineNumber: 1,
|
||
column: 1,
|
||
match: "",
|
||
}];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function scanText(text: string, patterns: EscapePatternDefinition[], issueNumber: number, issueId: number, bodyKind: EscapeBodyKind, url: string, commentId?: number): EscapeMatchFinding[] {
|
||
const findings: EscapeMatchFinding[] = [];
|
||
const counts = new Map<string, number>();
|
||
for (const item of patterns) {
|
||
item.pattern.lastIndex = 0;
|
||
const matches = text.match(item.pattern);
|
||
counts.set(item.kind, matches === null ? 0 : matches.length);
|
||
}
|
||
for (const item of patterns) {
|
||
item.pattern.lastIndex = 0;
|
||
let match = item.pattern.exec(text);
|
||
while (match !== null) {
|
||
const { lineNumber, column } = lineColumnAt(text, match.index);
|
||
const explanatory = item.kind === "literal-backslash-n" && counts.get(item.kind) === 1 && isExplanatoryLiteralBackslashN(text, match.index);
|
||
const classification: EscapeFindingClassification = explanatory ? "explanatory-mention" : "suspected-pollution";
|
||
const severity: EscapeFindingSeverity = explanatory ? "low" : item.kind === "ansi-escape-literal" ? "high" : "medium";
|
||
findings.push({
|
||
type: bodyKind === "issue-body" ? "issue" : "comment",
|
||
bodyKind,
|
||
bodyId: escapeBodyId(bodyKind, issueNumber, issueId, commentId),
|
||
issueNumber,
|
||
issueId,
|
||
...(commentId === undefined ? {} : { commentId }),
|
||
url,
|
||
kind: item.kind,
|
||
classification,
|
||
severity,
|
||
reason: explanatory
|
||
? "literal backslash-n appears in explanatory context"
|
||
: `matched ${item.description}`,
|
||
snippet: escapeSnippet(text, match.index),
|
||
lineNumber,
|
||
column,
|
||
match: match[0],
|
||
bodyChars: text.length,
|
||
bodyTrimmedChars: text.trim().length,
|
||
...(classification === "suspected-pollution"
|
||
? { cleanupPreview: { before: escapeSnippet(text, match.index), after: escapeSnippet(cleanupEscapedText(text), match.index) } }
|
||
: {}),
|
||
});
|
||
if (findings.length >= ISSUE_SCAN_MAX_FINDINGS) return findings;
|
||
match = item.pattern.exec(text);
|
||
}
|
||
}
|
||
return findings;
|
||
}
|
||
|
||
function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupSuggestion[] {
|
||
const groups = new Map<string, EscapeMatchFinding[]>();
|
||
for (const finding of findings) {
|
||
if (finding.type === "comment-scan-error") continue;
|
||
if (finding.classification !== "suspected-pollution" && finding.kind !== "short-body" && finding.kind !== "null-body" && finding.kind !== "blank-body" && finding.kind !== "literal-null-body") continue;
|
||
const locatorKey = `${finding.bodyKind}:${finding.issueNumber}:${finding.issueId}:${finding.commentId ?? ""}`;
|
||
const list = groups.get(locatorKey);
|
||
if (list === undefined) groups.set(locatorKey, [finding]);
|
||
else list.push(finding);
|
||
}
|
||
|
||
const suggestions: EscapeCleanupSuggestion[] = [];
|
||
for (const group of groups.values()) {
|
||
const first = group[0];
|
||
const suspected = group.some((finding) => finding.classification === "suspected-pollution");
|
||
const bodyRisk = group.some((finding) => finding.classification === "risk");
|
||
suggestions.push({
|
||
type: first.bodyKind,
|
||
bodyId: first.bodyId,
|
||
issueNumber: first.issueNumber,
|
||
issueId: first.issueId,
|
||
...(first.commentId === undefined ? {} : { commentId: first.commentId }),
|
||
url: first.url,
|
||
classification: suspected ? "suspected-pollution" : "risk",
|
||
action: first.bodyKind === "issue-body"
|
||
? (suspected ? "rewrite-issue-body-with-body-file" : "review-body-length")
|
||
: "review-comment-manually",
|
||
reason: suspected
|
||
? "suspected shell escape pollution should be rewritten from a body file"
|
||
: bodyRisk
|
||
? "body length/null risk should be reviewed before any write"
|
||
: "no cleanup needed",
|
||
findings: group.map((finding) => ({
|
||
kind: finding.kind,
|
||
classification: finding.classification,
|
||
severity: finding.severity,
|
||
lineNumber: finding.lineNumber,
|
||
column: finding.column,
|
||
snippet: finding.snippet,
|
||
match: finding.match,
|
||
})),
|
||
...(first.bodyKind === "issue-body"
|
||
? {
|
||
plannedCommand: `bun scripts/cli.ts gh issue update ${first.issueNumber} --mode replace --body-file <file> --dry-run`,
|
||
bodyFileHint: "write a cleaned Markdown body to a file, then pass it with --body-file; use a quoted heredoc to stage the file instead of inline shell text",
|
||
preview: {
|
||
before: first.cleanupPreview?.before ?? first.snippet,
|
||
after: first.cleanupPreview?.after ?? first.snippet,
|
||
},
|
||
}
|
||
: {
|
||
preview: {
|
||
before: first.cleanupPreview?.before ?? first.snippet,
|
||
after: first.cleanupPreview?.after ?? first.snippet,
|
||
},
|
||
}),
|
||
});
|
||
}
|
||
return suggestions;
|
||
}
|
||
|
||
async function issueScanEscape(repo: string, token: string, limit: number, dryRun: boolean, commandName = "issue scan-escape"): Promise<GitHubCommandResult> {
|
||
const { owner, name } = repoParts(repo);
|
||
const issues = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=all&per_page=${limit}`);
|
||
if (isGitHubError(issues)) return commandError(commandName, repo, issues);
|
||
const issueOnly = issues.filter((issue) => issue.pull_request === undefined).slice(0, limit);
|
||
|
||
const patterns = textFindingPatterns();
|
||
const findings: EscapeScanEntry[] = [];
|
||
let scannedComments = 0;
|
||
for (const issue of issueOnly) {
|
||
findings.push(...bodyRiskFindings("issue-body", issue.number, issue.id, issue.body, issue.html_url));
|
||
findings.push(...scanText(issue.body ?? "", patterns, issue.number, issue.id, "issue-body", issue.html_url));
|
||
const comments = await listIssueComments(token, repo, issue.number);
|
||
if (isGitHubError(comments)) {
|
||
findings.push({
|
||
type: "comment-scan-error",
|
||
bodyKind: "comment-body",
|
||
bodyId: escapeBodyId("comment-body", issue.number, issue.id),
|
||
issueNumber: issue.number,
|
||
issueId: issue.id,
|
||
url: issue.html_url,
|
||
degradedReason: comments.degradedReason,
|
||
runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason),
|
||
details: comments,
|
||
});
|
||
continue;
|
||
}
|
||
for (const comment of comments) {
|
||
scannedComments += 1;
|
||
findings.push(...bodyRiskFindings("comment-body", issue.number, issue.id, comment.body, comment.html_url, comment.id));
|
||
findings.push(...scanText(comment.body ?? "", patterns, issue.number, issue.id, "comment-body", comment.html_url, comment.id));
|
||
}
|
||
}
|
||
const cleanupSuggestions = summarizeCleanupSuggestion(findings);
|
||
const summary = findings.reduce((acc, finding) => {
|
||
if (finding.type === "comment-scan-error") {
|
||
acc.commentScanErrors += 1;
|
||
return acc;
|
||
}
|
||
if (finding.classification === "suspected-pollution") acc.suspectedPollution += 1;
|
||
if (finding.classification === "explanatory-mention") acc.explanatoryMention += 1;
|
||
if (finding.classification === "risk") acc.risk += 1;
|
||
if (finding.kind === "null-body" || finding.kind === "literal-null-body" || finding.kind === "blank-body" || finding.kind === "short-body") acc.bodyRisks += 1;
|
||
return acc;
|
||
}, { suspectedPollution: 0, explanatoryMention: 0, risk: 0, bodyRisks: 0, commentScanErrors: 0 });
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
dryRun,
|
||
planned: true,
|
||
scannedIssues: issueOnly.length,
|
||
rawIssues: issues.length,
|
||
scannedComments,
|
||
findings,
|
||
cleanupSuggestions,
|
||
summary: {
|
||
findings: findings.length,
|
||
suggestions: cleanupSuggestions.length,
|
||
...summary,
|
||
},
|
||
note: dryRun
|
||
? "Dry-run cleanup planning only; no issue or comment content was modified."
|
||
: "Read-only scan; no issue or comment content was modified.",
|
||
};
|
||
}
|
||
|
||
async function authStatus(repo: string): Promise<GitHubCommandResult> {
|
||
const ghPath = ghBinaryPath();
|
||
const { token, probe } = resolveToken(ghPath !== null);
|
||
const degraded: GitHubDegradedReason[] = [];
|
||
if (ghPath === null) degraded.push("missing-binary");
|
||
if (!probe.present || token === null) {
|
||
if (ghPath === null) {
|
||
return {
|
||
ok: false,
|
||
command: "auth status",
|
||
repo,
|
||
degradedReason: "missing-binary",
|
||
degraded,
|
||
runnerDisposition: runnerDisposition("missing-binary"),
|
||
gh: { binaryFound: false, path: null },
|
||
token: probe,
|
||
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
|
||
};
|
||
}
|
||
degraded.push("missing-token");
|
||
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), {
|
||
degraded,
|
||
gh: { binaryFound: ghPath !== null, path: ghPath },
|
||
token: probe,
|
||
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
|
||
});
|
||
}
|
||
|
||
const { owner, name } = repoParts(repo);
|
||
const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit");
|
||
if (isGitHubError(api)) {
|
||
return commandError("auth status", repo, api, {
|
||
degraded: [...degraded, api.degradedReason],
|
||
gh: { binaryFound: ghPath !== null, path: ghPath },
|
||
token: probe,
|
||
probes: { restApi: api, repo: "skipped", issueRead: "skipped" },
|
||
});
|
||
}
|
||
|
||
const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`);
|
||
if (isGitHubError(repoProbe)) {
|
||
return commandError("auth status", repo, repoProbe, {
|
||
degraded: [...degraded, repoProbe.degradedReason],
|
||
gh: { binaryFound: ghPath !== null, path: ghPath },
|
||
token: probe,
|
||
probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" },
|
||
});
|
||
}
|
||
|
||
const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`);
|
||
if (isGitHubError(issueProbe)) {
|
||
return commandError("auth status", repo, issueProbe, {
|
||
degraded: [...degraded, issueProbe.degradedReason],
|
||
gh: { binaryFound: ghPath !== null, path: ghPath },
|
||
token: probe,
|
||
probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe },
|
||
});
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
command: "auth status",
|
||
repo,
|
||
degraded,
|
||
gh: { binaryFound: ghPath !== null, path: ghPath },
|
||
token: probe,
|
||
probes: {
|
||
restApi: "ok",
|
||
repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null },
|
||
issueRead: { ok: true, readable: true, sampleCount: issueProbe.length },
|
||
},
|
||
restFallback: true,
|
||
};
|
||
}
|
||
|
||
async function prList(repo: string, token: string, state: PrListState, limit: number, jsonFields: PrListJsonField[] | undefined): Promise<GitHubCommandResult> {
|
||
const { owner, name } = repoParts(repo);
|
||
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=${state}&per_page=${limit}`);
|
||
if (isGitHubError(prs)) return commandError("pr list", repo, prs, { state, limit });
|
||
const fields = jsonFields ?? PR_LIST_JSON_FIELDS.slice();
|
||
return {
|
||
ok: true,
|
||
command: "pr list",
|
||
repo,
|
||
state,
|
||
limit,
|
||
count: prs.length,
|
||
jsonFields: fields,
|
||
pullRequests: prs.map((pr) => selectedPrJson(prSummary(pr), fields)),
|
||
};
|
||
}
|
||
|
||
async function prFiles(repo: string, token: string, number: number, limit: number, commandName = "pr files"): Promise<GitHubCommandResult> {
|
||
const { owner, name } = repoParts(repo);
|
||
const boundedLimit = Math.min(limit, MAX_PR_FILES_LIMIT);
|
||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
|
||
const perPage = Math.max(1, Math.min(100, boundedLimit));
|
||
const files: GitHubPullRequestFile[] = [];
|
||
let page = 1;
|
||
while (files.length < boundedLimit) {
|
||
const remaining = boundedLimit - files.length;
|
||
const pageSize = Math.min(perPage, remaining);
|
||
const path = `/repos/${owner}/${name}/pulls/${number}/files?per_page=${pageSize}&page=${page}`;
|
||
const pageFiles = await githubRequest<GitHubPullRequestFile[]>(token, "GET", path);
|
||
if (isGitHubError(pageFiles)) return commandError(commandName, repo, pageFiles, { number, phase: "fetch-pr-files", filesReturned: files.length });
|
||
files.push(...pageFiles);
|
||
if (pageFiles.length < pageSize || pageFiles.length === 0) break;
|
||
page += 1;
|
||
}
|
||
const totalFiles = numberOrNull(pr.changed_files);
|
||
const listedStats = sumPrFileStats(files);
|
||
const totalAdditions = numberOrNull(pr.additions);
|
||
const totalDeletions = numberOrNull(pr.deletions);
|
||
const fullStatsAvailable = totalAdditions !== null && totalDeletions !== null;
|
||
const totalChanges = fullStatsAvailable ? totalAdditions + totalDeletions : listedStats.changes;
|
||
const truncated = totalFiles !== null ? files.length < totalFiles : files.length >= boundedLimit;
|
||
const nextLimit = totalFiles === null ? MAX_PR_FILES_LIMIT : Math.min(totalFiles, MAX_PR_FILES_LIMIT);
|
||
const nextCommand = truncated
|
||
? `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${nextLimit}`
|
||
: `bun scripts/cli.ts gh pr read ${number} --repo ${repo} --json body,title,state,head,base`;
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
readOnly: true,
|
||
rawDiffIncluded: false,
|
||
pullRequest: prCompactSummary(pr),
|
||
summary: {
|
||
files: totalFiles ?? files.length,
|
||
additions: totalAdditions ?? listedStats.additions,
|
||
deletions: totalDeletions ?? listedStats.deletions,
|
||
changes: totalChanges,
|
||
commits: numberOrNull(pr.commits),
|
||
source: fullStatsAvailable && totalFiles !== null ? "pull-request-rest" : "listed-files-rest",
|
||
},
|
||
files: files.map(prFileSummary),
|
||
filesReturned: files.length,
|
||
limit: boundedLimit,
|
||
truncation: {
|
||
truncated,
|
||
requestedLimit: limit,
|
||
appliedLimit: boundedLimit,
|
||
returned: files.length,
|
||
totalFiles,
|
||
maxLimit: MAX_PR_FILES_LIMIT,
|
||
},
|
||
next: {
|
||
command: nextCommand,
|
||
purpose: truncated ? "Fetch a larger bounded file summary page." : "Fetch full PR metadata/body; raw diffs remain intentionally excluded.",
|
||
},
|
||
request: {
|
||
method: "GET",
|
||
paths: [
|
||
`/repos/${owner}/${name}/pulls/${number}`,
|
||
`/repos/${owner}/${name}/pulls/${number}/files`,
|
||
],
|
||
},
|
||
};
|
||
}
|
||
|
||
async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read", disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
|
||
const { owner, name } = repoParts(repo);
|
||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
|
||
const summary = prSummary(pr);
|
||
const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null;
|
||
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [] });
|
||
const selectionSummary = metadata === null ? summary : { ...summary, ...prMetadataSummary(metadata) };
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
...(disclosure === null ? {} : { disclosure }),
|
||
pullRequest: summary,
|
||
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(selectionSummary, jsonFields) }),
|
||
};
|
||
}
|
||
|
||
async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
|
||
return prRead(repo, token, number, jsonFields, "pr view", disclosure);
|
||
}
|
||
|
||
export function ghHelp(): unknown {
|
||
return {
|
||
command: "gh",
|
||
output: "json",
|
||
usage: [
|
||
"bun scripts/cli.ts gh auth status [--repo owner/name]",
|
||
"bun scripts/cli.ts gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
|
||
"bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]",
|
||
"bun scripts/cli.ts gh issue view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for issue read]",
|
||
"bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--repo owner/name] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]",
|
||
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [compat alias for issue update --mode replace]",
|
||
"bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]",
|
||
"bun scripts/cli.ts gh issue comment create <number> --body-file <file> [--repo owner/name] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
|
||
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue cleanup-plan [--repo owner/name] [--limit N] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue board-row list [--repo owner/name] --board-issue 20 [--state open|closed|all] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue board-row get <issueNumber> [--repo owner/name] --board-issue 20",
|
||
"bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
|
||
"bun scripts/cli.ts gh issue board-row add <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed --row-file <markdown-row-file> [--dry-run] [--expect-body-sha sha256]",
|
||
"bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
|
||
"bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]",
|
||
"bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
|
||
"bun scripts/cli.ts gh pr list [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
|
||
"bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--limit N]",
|
||
"bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--limit N] [compatibility alias for pr files; no raw diff]",
|
||
"bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]",
|
||
"bun scripts/cli.ts gh pr view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for pr read]",
|
||
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
|
||
],
|
||
defaults: { repo: DEFAULT_REPO },
|
||
notes: [
|
||
"Issue and PR create/read/update/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
|
||
"Token values are never printed; auth status reports only token source and presence.",
|
||
"issue list defaults to --state open and bounded --limit 30; supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
|
||
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
|
||
"issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
|
||
"--raw and --full are explicit full-disclosure aliases for gh issue read/view and gh pr read/view. They request the full supported read/view JSON field set while keeping default command output structured JSON.",
|
||
"issue create accepts repeatable --label values and comma-separated labels; dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
|
||
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
|
||
"update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.",
|
||
"issue edit is a compatibility alias for issue update --mode replace.",
|
||
"issue update --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading and rejects HWLAB product/user issue rows in favor of pikasTech/HWLAB, while commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).",
|
||
"issue update dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.",
|
||
"Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.",
|
||
"When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.",
|
||
"For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue Markdown writes intentionally use --body-file only; PR edit/update also accepts --body-file - for stdin when a runner already has reviewed Markdown on stdin.",
|
||
"issue scan-escape classifies literal \\n findings as suspected-pollution, explanatory-mention, or risk, and emits cleanupSuggestions with body/comment ids plus diff-like previews. cleanup-plan is an alias that remains dry-run/read-only.",
|
||
"issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It reads only the board issue body, returns body size/SHA and parsed Markdown board sections, and no longer validates GitHub open/closed issue coverage against OPEN/CLOSED tables. The legacy coverage fields remain present as empty arrays/zero counts for compatibility.",
|
||
"issue board-row list/get reuse the board-audit table parser and are read-only. board-row update changes one table cell by issue number, returns old/new row, body SHA, body guard and request plan, and defaults to dry-run unless --expect-updated-at or --expect-body-sha is supplied for the guarded PATCH. Field aliases map status and validation to the 验收状态 column, tasks to 相关 Code Queue 任务, and focus to 当前关注点.",
|
||
"issue board-row upsert updates an existing row when the issue is already present, or generates a complete row in --section open|closed when missing. It returns operation=update or operation=add, defaults to dry-run, requires --expect-body-sha or --expect-updated-at before PATCH, and refuses section migration; use board-row move for OPEN/CLOSED migration.",
|
||
"issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation, and the shared #20 guard rejects HWLAB product/user issue rows in favor of pikasTech/HWLAB.",
|
||
"issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
|
||
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
|
||
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
|
||
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
|
||
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
|
||
"PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure.",
|
||
"PR create/edit/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
|
||
],
|
||
};
|
||
}
|
||
|
||
export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> {
|
||
const [top, sub, third] = args;
|
||
if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp();
|
||
let options: GitHubOptions;
|
||
try {
|
||
options = parseOptions(args);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
const repo = optionValue(args, "--repo") ?? DEFAULT_REPO;
|
||
const unknownOption = /^unknown gh option:\s+(.+)$/u.exec(message)?.[1];
|
||
return unknownOption !== undefined
|
||
? unsupportedCommand(command, repo, message, unknownGhOptionDetails(args, unknownOption))
|
||
: validationError(command, repo, message);
|
||
}
|
||
if (options.notifyClaudeQqBriefDiff && !(top === "issue" && sub === "edit")) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--notify-claudeqq-brief-diff is only supported by gh issue edit 24");
|
||
}
|
||
if (optionWasProvided(args, "--state") && !(top === "issue" && (sub === "list" || sub === "board-row" && third === "list") || top === "pr" && sub === "list")) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--state is only supported by gh issue list, gh issue board-row list, and gh pr list");
|
||
}
|
||
if (optionWasProvided(args, "--json") && !(top === "issue" && (isIssueReadCommand(sub) || sub === "list"))) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
if (!(top === "pr" && (isPrReadCommand(sub) || sub === "list"))) {
|
||
return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list");
|
||
}
|
||
}
|
||
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && isIssueReadCommand(sub)) || (top === "pr" && isPrReadCommand(sub)))) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view and gh pr read/view.", {
|
||
supportedCommands: [
|
||
"bun scripts/cli.ts gh issue read owner/name#<number> --raw",
|
||
"bun scripts/cli.ts gh issue read <number> --repo owner/name --json body,title,state,comments",
|
||
"bun scripts/cli.ts gh pr read owner/name#<number> --raw",
|
||
`bun scripts/cli.ts gh pr read <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
|
||
],
|
||
});
|
||
}
|
||
if (optionWasProvided(args, "--stat") && !(top === "pr" && sub === "diff")) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--stat is only supported by gh pr diff <number> --stat.", {
|
||
supportedCommands: [
|
||
"bun scripts/cli.ts gh pr files <number> --repo owner/name --limit 30",
|
||
"bun scripts/cli.ts gh pr diff <number> --stat --repo owner/name --limit 30",
|
||
],
|
||
});
|
||
}
|
||
if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && (sub === "update" || sub === "edit")))) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--mode is only supported by gh issue update/edit and gh pr update/edit");
|
||
}
|
||
if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && (sub === "edit" || sub === "update" || sub === "board-row" && ["update", "add", "move", "delete", "upsert"].includes(third ?? "")))) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue update/edit and gh issue board-row update/add/move/delete/upsert");
|
||
}
|
||
if ((optionWasProvided(args, "--board-issue") || optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && (sub === "board-audit" || sub === "board-row"))) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--board-issue, --known-meta-issue, and --ignore-issue are only supported by gh issue board-audit and --board-issue by gh issue board-row");
|
||
}
|
||
if ((optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && sub === "board-audit")) {
|
||
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--known-meta-issue and --ignore-issue are only supported by gh issue board-audit");
|
||
}
|
||
if ((optionWasProvided(args, "--field") || optionWasProvided(args, "--value")) && !(top === "issue" && sub === "board-row" && third === "update")) {
|
||
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--field and --value are only supported by gh issue board-row update");
|
||
}
|
||
if (optionWasProvided(args, "--row-file") && !(top === "issue" && sub === "board-row" && third === "add")) {
|
||
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--row-file is only supported by gh issue board-row add");
|
||
}
|
||
if (optionWasProvided(args, "--section") && !(top === "issue" && sub === "board-row" && (third === "add" || third === "upsert"))) {
|
||
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--section is only supported by gh issue board-row add/upsert");
|
||
}
|
||
if (optionWasProvided(args, "--to") && !(top === "issue" && sub === "board-row" && third === "move")) {
|
||
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--to is only supported by gh issue board-row move");
|
||
}
|
||
if (optionWasProvided(args, "--status") && !(top === "issue" && sub === "board-row" && (third === "move" || third === "upsert"))) {
|
||
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--status is only supported by gh issue board-row move/upsert");
|
||
}
|
||
if ((optionWasProvided(args, "--category") || optionWasProvided(args, "--branch") || optionWasProvided(args, "--tasks") || optionWasProvided(args, "--summary") || optionWasProvided(args, "--focus") || optionWasProvided(args, "--validation") || optionWasProvided(args, "--progress")) && !(top === "issue" && sub === "board-row" && third === "upsert")) {
|
||
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--category, --branch, --tasks, --summary, --focus, --validation, and --progress are only supported by gh issue board-row upsert");
|
||
}
|
||
if (optionWasProvided(args, "--label") && !(top === "issue" && sub === "create")) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--label is only supported by gh issue create");
|
||
}
|
||
|
||
if (top === "auth" && sub === "status") return authStatus(options.repo);
|
||
|
||
if (top === "issue") {
|
||
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
|
||
if (sub === "comment" && third === "delete") {
|
||
const commentId = parseNumberForCommand(options.repo, args[3], "issue comment delete");
|
||
if (typeof commentId !== "number") return commentId;
|
||
if (options.dryRun) return commentDelete(options.repo, "", "issue", commentId, true);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "issue comment delete", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true });
|
||
return commentDelete(options.repo, token, "issue", commentId, false);
|
||
}
|
||
if (sub === "comment" && third === "create") {
|
||
const issueNumber = parseNumberForCommand(options.repo, args[3], "issue comment create");
|
||
if (typeof issueNumber !== "number") return issueNumber;
|
||
if (options.dryRun) return issueComment(options.repo, "", issueNumber, options);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "issue comment create", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true });
|
||
return issueComment(options.repo, token, issueNumber, options);
|
||
}
|
||
if (sub === "scan-escape" || sub === "cleanup-plan") {
|
||
const { token, probe } = resolveToken(true);
|
||
const commandName = sub === "cleanup-plan" ? "issue cleanup-plan" : "issue scan-escape";
|
||
const missing = authRequired(options.repo, commandName, probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
|
||
return issueScanEscape(options.repo, token, options.limit, options.dryRun || sub === "cleanup-plan", commandName);
|
||
}
|
||
if (sub === "board-audit") {
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "issue board-audit", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue board-audit", { present: false, source: null, ghFallbackAttempted: true });
|
||
return issueBoardAudit(options.repo, token, options);
|
||
}
|
||
if (sub === "board-row") {
|
||
const action = third;
|
||
const commandName = `issue board-row ${action ?? ""}`.trim();
|
||
if (action !== "list" && action !== "get" && action !== "update" && action !== "add" && action !== "move" && action !== "delete" && action !== "upsert") {
|
||
return unsupportedCommand(commandName, options.repo, "board-row supported commands are list, get, update, add, move, delete, and upsert.");
|
||
}
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, commandName, probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
|
||
if (action === "list") return issueBoardRowList(options.repo, token, options);
|
||
const issueNumber = parseNumberForCommand(options.repo, args[3], commandName);
|
||
if (typeof issueNumber !== "number") return issueNumber;
|
||
if (action === "get") return issueBoardRowGet(options.repo, token, issueNumber, options);
|
||
if (action === "update") return issueBoardRowUpdate(options.repo, token, issueNumber, options);
|
||
if (action === "add") return issueBoardRowAdd(options.repo, token, issueNumber, options);
|
||
if (action === "upsert") return issueBoardRowUpsert(options.repo, token, issueNumber, options);
|
||
if (action === "move") return issueBoardRowMove(options.repo, token, issueNumber, options);
|
||
return issueBoardRowDelete(options.repo, token, issueNumber, options);
|
||
}
|
||
if (options.dryRun) {
|
||
if (sub === "create") return issueCreate(options.repo, "", options);
|
||
if (sub === "edit") {
|
||
const issueNumber = parseNumber(third, "issue edit");
|
||
const { token } = resolveToken(false);
|
||
return issueEdit(options.repo, token ?? "", issueNumber, options);
|
||
}
|
||
if (sub === "update") {
|
||
const issueNumber = parseNumber(third, "issue update");
|
||
const { token } = resolveToken(false);
|
||
return issueEdit(options.repo, token ?? "", issueNumber, options, "issue update");
|
||
}
|
||
if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options);
|
||
if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true);
|
||
if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true);
|
||
}
|
||
if (sub === "read" || sub === "view") {
|
||
const resolved = resolveReadViewNumberReference("issue", sub, third, options, args);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.repo, `issue ${sub}`, probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `issue ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||
const disclosure = readDisclosureOptions(options, resolved.shorthand);
|
||
if (sub === "read") return issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure);
|
||
return issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure);
|
||
}
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `issue ${sub ?? ""}`.trim(), { present: false, source: null, ghFallbackAttempted: true });
|
||
|
||
if (sub === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields);
|
||
if (sub === "create") return issueCreate(options.repo, token, options);
|
||
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);
|
||
if (sub === "update") return issueEdit(options.repo, token, parseNumber(third, "issue update"), options, "issue update");
|
||
if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options);
|
||
if (sub === "close") return issueState(options.repo, token, parseNumber(third, "issue close"), "closed", options.dryRun);
|
||
if (sub === "reopen") return issueState(options.repo, token, parseNumber(third, "issue reopen"), "open", options.dryRun);
|
||
}
|
||
|
||
if (top === "pr") {
|
||
if (sub === "diff") {
|
||
const number = parseNumberForCommand(options.repo, third, "pr diff");
|
||
if (typeof number !== "number") return number;
|
||
if (!optionWasProvided(args, "--stat")) {
|
||
return unsupportedCommand("pr diff", options.repo, "Raw PR diff output is intentionally unsupported by UniDesk CLI; use gh pr diff <number> --stat or gh pr files for a bounded REST file/stat summary.", {
|
||
rawDiffIncluded: false,
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh pr files ${number} --repo ${options.repo} --limit 30`,
|
||
`bun scripts/cli.ts gh pr diff ${number} --stat --repo ${options.repo} --limit 30`,
|
||
],
|
||
});
|
||
}
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "pr diff --stat", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true });
|
||
return prFiles(options.repo, token, number, options.limit, "pr diff --stat");
|
||
}
|
||
if (sub === "files") {
|
||
const number = parseNumberForCommand(options.repo, third, "pr files");
|
||
if (typeof number !== "number") return number;
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "pr files", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr files", { present: false, source: null, ghFallbackAttempted: true });
|
||
return prFiles(options.repo, token, number, options.limit, "pr files");
|
||
}
|
||
if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics.");
|
||
if (sub === "comment" && third === "delete") {
|
||
const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete");
|
||
if (typeof commentId !== "number") return commentId;
|
||
if (options.dryRun) return commentDelete(options.repo, "", "pr", commentId, true);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "pr comment delete", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true });
|
||
return commentDelete(options.repo, token, "pr", commentId, false);
|
||
}
|
||
if (sub === "comment" && third === "create") {
|
||
const number = parseNumberForCommand(options.repo, args[3], "pr comment create");
|
||
if (typeof number !== "number") return number;
|
||
if (options.dryRun) return prComment(options.repo, "", number, options);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "pr comment create", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment create", { present: false, source: null, ghFallbackAttempted: true });
|
||
return prComment(options.repo, token, number, options);
|
||
}
|
||
if (sub === "create") {
|
||
if (options.dryRun) return prCreate(options.repo, "", options);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "pr create", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr create", { present: false, source: null, ghFallbackAttempted: true });
|
||
return prCreate(options.repo, token, options);
|
||
}
|
||
if (sub === "comment") {
|
||
if (options.dryRun) {
|
||
const number = parseNumberForCommand(options.repo, third, "pr comment");
|
||
if (typeof number !== "number") return number;
|
||
return prComment(options.repo, "", number, options);
|
||
}
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "pr comment", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true });
|
||
const number = parseNumberForCommand(options.repo, third, "pr comment");
|
||
if (typeof number !== "number") return number;
|
||
return prComment(options.repo, token, number, options);
|
||
}
|
||
if (sub === "update" || sub === "edit") {
|
||
const commandName = `pr ${sub}`;
|
||
const number = parseNumberForCommand(options.repo, third, commandName);
|
||
if (typeof number !== "number") return number;
|
||
if (options.dryRun && options.mode === "replace") return prUpdate(options.repo, "", number, options, commandName);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, commandName, probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
|
||
return prUpdate(options.repo, token, number, options, commandName);
|
||
}
|
||
if (sub === "close" || sub === "reopen") {
|
||
const number = parseNumberForCommand(options.repo, third, `pr ${sub}`);
|
||
if (typeof number !== "number") return number;
|
||
if (options.dryRun) return prState(options.repo, "", number, sub === "close" ? "closed" : "open", true);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, `pr ${sub}`, probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||
return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false);
|
||
}
|
||
if (sub === "merge") {
|
||
return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only.");
|
||
}
|
||
if (sub !== "list" && !isPrReadCommand(sub)) {
|
||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete.");
|
||
}
|
||
if (sub === "read" || sub === "view") {
|
||
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.repo, `pr ${sub}`, probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||
const disclosure = readDisclosureOptions(options, resolved.shorthand);
|
||
if (sub === "read") return prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure);
|
||
return prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure);
|
||
}
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, `pr ${sub}`, probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||
if (sub === "list") return prList(options.repo, token, options.prListState, options.limit, options.prListJsonFields);
|
||
}
|
||
|
||
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });
|
||
}
|