6999 lines
307 KiB
TypeScript
6999 lines
307 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 MAX_INLINE_ISSUE_COMMENT_BODY_CHARS = 1000;
|
||
const GITHUB_REST_PAGE_SIZE = 100;
|
||
const MAX_ISSUE_LIST_LIMIT = 1000;
|
||
const DEFAULT_STALE_CLOSE_INACTIVE_HOURS = 48;
|
||
const MAX_STALE_CLOSE_INACTIVE_HOURS = 24 * 365 * 10;
|
||
const ISSUE_LIFECYCLE_PREVIEW_LIMIT = 80;
|
||
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", "closed", "closedAt", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
|
||
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "closed", "closedAt", "url", "updatedAt", "createdAt", "author", "labels"] as const;
|
||
const PR_LIST_JSON_FIELDS = [
|
||
"body",
|
||
"title",
|
||
"state",
|
||
"number",
|
||
"url",
|
||
"author",
|
||
"head",
|
||
"base",
|
||
"draft",
|
||
"createdAt",
|
||
"updatedAt",
|
||
"closedAt",
|
||
"merged",
|
||
"mergedAt",
|
||
"mergeCommit",
|
||
"headRefName",
|
||
"baseRefName"
|
||
] as const;
|
||
const PR_READ_JSON_FIELDS = ["body", "title", "state", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
|
||
const PR_CLOSEOUT_JSON_FIELDS = ["mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
|
||
const PR_CLOSEOUT_VIEW_JSON = "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
|
||
const ISSUE_LIST_STATES = ["open", "closed", "all"] as const;
|
||
const BODY_UPDATE_MODES = ["replace", "append"] as const;
|
||
const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const;
|
||
const BOARD_GITHUB_STATUSES = ["OPEN", "CLOSED"] as const;
|
||
const GH_VALUE_OPTIONS = new Set([
|
||
"--repo", "--limit", "--board-issue", "--known-meta-issue", "--ignore-issue", "--title",
|
||
"--body-file", "--body", "--base", "--head", "--json", "--state", "--mode",
|
||
"--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field",
|
||
"--value", "--section", "--to", "--status", "--row-file", "--category", "--branch",
|
||
"--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr",
|
||
"--search", "--inactive-hours", "--comment", "--comment-file",
|
||
]);
|
||
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch"]);
|
||
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 PullRequestMergeMethod = "merge" | "squash" | "rebase";
|
||
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;
|
||
inactiveHours: number;
|
||
boardIssue: number;
|
||
knownMetaIssues: number[];
|
||
ignoredIssues: number[];
|
||
draft: boolean;
|
||
notifyClaudeQqBriefDiff: boolean;
|
||
allowShortBody: boolean;
|
||
labels: string[];
|
||
search?: string;
|
||
title?: string;
|
||
body?: string;
|
||
bodyFile?: string;
|
||
comment?: string;
|
||
commentFile?: 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;
|
||
mergeMethod: PullRequestMergeMethod;
|
||
deleteBranch: boolean;
|
||
}
|
||
|
||
interface GitHubShorthandReference {
|
||
input: string;
|
||
repo: string;
|
||
number: number;
|
||
source?: "owner-repo-number" | "github-url" | "number-option";
|
||
urlKind?: "issue" | "pr";
|
||
standardCommand?: string;
|
||
}
|
||
|
||
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;
|
||
closed_at?: string | null;
|
||
}
|
||
|
||
interface GitHubComment {
|
||
id: number;
|
||
body: string | null;
|
||
html_url: string;
|
||
user?: { login?: string };
|
||
created_at?: string;
|
||
updated_at?: string;
|
||
}
|
||
|
||
interface GitHubIssueSearchResponse {
|
||
total_count?: number;
|
||
incomplete_results?: boolean;
|
||
items?: GitHubIssue[];
|
||
}
|
||
|
||
interface GitHubIssueListPage {
|
||
path: string;
|
||
rawCount: number;
|
||
issueCount: number;
|
||
}
|
||
|
||
interface GitHubIssueListResult {
|
||
items: GitHubIssue[];
|
||
rawCount: number;
|
||
fetchedPages: number;
|
||
pageSize: number;
|
||
exhausted: boolean;
|
||
hasMore: boolean;
|
||
pages: GitHubIssueListPage[];
|
||
searchTotalCount?: number;
|
||
searchIncomplete?: boolean;
|
||
}
|
||
|
||
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; repo?: { full_name?: string | null } | null };
|
||
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 positiveNumberOption(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.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
|
||
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 {
|
||
if (requested !== undefined) {
|
||
const listFields = new Set<string>(PR_LIST_JSON_FIELDS);
|
||
const closeoutFields = requested.filter((field) => (PR_CLOSEOUT_JSON_FIELDS as readonly string[]).includes(field));
|
||
const unsupported = requested.filter((field) => !listFields.has(field));
|
||
if (closeoutFields.length > 0) {
|
||
throw new Error(`unsupported gh pr list --json closeout field(s): ${closeoutFields.join(", ")}; use gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON} for mergeability/statusCheckRollup metadata; pr list supported fields: ${PR_LIST_JSON_FIELDS.join(",")}`);
|
||
}
|
||
if (unsupported.length > 0) {
|
||
throw new Error(`unsupported gh pr list --json field(s): ${unsupported.join(", ")}; supported fields: ${PR_LIST_JSON_FIELDS.join(",")}; closeout metadata requires gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON}`);
|
||
}
|
||
}
|
||
return validateJsonFields("gh pr list", requested, PR_LIST_JSON_FIELDS);
|
||
}
|
||
|
||
function parsePrReadJsonFields(requested: string[] | undefined): PrReadJsonField[] | undefined {
|
||
return validateJsonFields("gh pr read/view", requested, PR_READ_JSON_FIELDS);
|
||
}
|
||
|
||
function isIssueReadCommand(sub: string | undefined): boolean {
|
||
return sub === "read" || sub === "view";
|
||
}
|
||
|
||
function isPrReadCommand(sub: string | undefined): boolean {
|
||
return sub === "read" || sub === "view";
|
||
}
|
||
|
||
function allowsNumberTargetAlias(top: string | undefined, sub: string | undefined, third: string | undefined): boolean {
|
||
if (top === "preflight") return true;
|
||
if (top === "issue") {
|
||
if (sub === "read" || sub === "view" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "delete") return true;
|
||
if (sub === "comment") return true;
|
||
if (sub === "board-row" && ["get", "update", "add", "move", "delete", "upsert"].includes(third ?? "")) return true;
|
||
return false;
|
||
}
|
||
if (top === "pr") {
|
||
if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true;
|
||
if (sub === "comment") return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
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 parsePullRequestMergeMethod(args: string[]): PullRequestMergeMethod {
|
||
const selected = ["merge", "squash", "rebase"].filter((method) => hasFlag(args, `--${method}`));
|
||
if (selected.length > 1) throw new Error("choose only one PR merge method flag: --merge, --squash, or --rebase");
|
||
return (selected[0] ?? "merge") as PullRequestMergeMethod;
|
||
}
|
||
|
||
function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
|
||
const raw = optionValue(args, "--body-profile") ?? "auto";
|
||
if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw;
|
||
throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`);
|
||
}
|
||
|
||
function parseBoardMutationSection(args: string[], name: "--section" | "--to"): BoardMutationSection | undefined {
|
||
const raw = optionValue(args, name);
|
||
if (raw === undefined) return undefined;
|
||
return validateEnumValue(name, raw, BOARD_MUTATION_SECTIONS);
|
||
}
|
||
|
||
function parseBoardGithubStatus(args: string[]): BoardGithubStatus | undefined {
|
||
const raw = optionValue(args, "--status");
|
||
if (raw === undefined) return undefined;
|
||
return validateEnumValue("--status", raw, BOARD_GITHUB_STATUSES);
|
||
}
|
||
|
||
function parseBoardRowField(args: string[]): BoardRowField | undefined {
|
||
const raw = optionValue(args, "--field");
|
||
if (raw === undefined) return undefined;
|
||
return validateEnumValue("--field", raw, BOARD_ROW_FIELDS);
|
||
}
|
||
|
||
function parseBoardRowUpsertValues(args: string[]): BoardRowUpsertValues {
|
||
return {
|
||
category: optionValue(args, "--category"),
|
||
branch: optionValue(args, "--branch"),
|
||
tasks: optionValue(args, "--tasks"),
|
||
summary: optionValue(args, "--summary"),
|
||
focus: optionValue(args, "--focus"),
|
||
validation: optionValue(args, "--validation"),
|
||
progress: optionValue(args, "--progress"),
|
||
status: parseBoardGithubStatus(args),
|
||
};
|
||
}
|
||
|
||
function validateKnownOptions(args: string[]): void {
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index];
|
||
if (!arg.startsWith("--")) continue;
|
||
if (GH_FLAG_OPTIONS.has(arg)) continue;
|
||
if (GH_VALUE_OPTIONS.has(arg)) {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
throw new Error(`unknown gh option: ${arg}`);
|
||
}
|
||
}
|
||
|
||
function positionalArgs(args: string[]): string[] {
|
||
const positionals: string[] = [];
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index] ?? "";
|
||
if (arg.startsWith("--")) {
|
||
if (GH_VALUE_OPTIONS.has(arg)) index += 1;
|
||
continue;
|
||
}
|
||
positionals.push(arg);
|
||
}
|
||
return positionals;
|
||
}
|
||
|
||
function isOwnerRepo(value: string): boolean {
|
||
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value);
|
||
}
|
||
|
||
function resolveRepoOption(args: string[]): string {
|
||
const [top, sub] = args;
|
||
const explicitRepo = optionValue(args, "--repo");
|
||
if ((top === "issue" || top === "pr") && sub === "list") {
|
||
const positionals = positionalArgs(args.slice(2));
|
||
if (positionals.length > 1) {
|
||
throw new Error(`gh ${top} list accepts at most one positional owner/repo; use --repo owner/name`);
|
||
}
|
||
const positionalRepo = positionals[0];
|
||
if (positionalRepo !== undefined) {
|
||
if (!isOwnerRepo(positionalRepo)) {
|
||
throw new Error(`gh ${top} list positional argument must be owner/repo; use --repo owner/name`);
|
||
}
|
||
if (explicitRepo !== undefined && explicitRepo !== positionalRepo) {
|
||
throw new Error(`gh ${top} list received positional repo ${positionalRepo} and --repo ${explicitRepo}; use one repo target, for example --repo ${positionalRepo}`);
|
||
}
|
||
return positionalRepo;
|
||
}
|
||
}
|
||
return explicitRepo ?? DEFAULT_REPO;
|
||
}
|
||
|
||
function parseOptions(args: string[]): GitHubOptions {
|
||
validateKnownOptions(args);
|
||
const [top, sub] = args;
|
||
const requestedJsonFields = commaListOption(args, "--json");
|
||
const limitMax = top === "pr" && (sub === "files" || sub === "diff")
|
||
? MAX_PR_FILES_LIMIT
|
||
: top === "issue" && (sub === "list" || sub === "stale-close" || sub === "scan-escape" || sub === "cleanup-plan")
|
||
? MAX_ISSUE_LIST_LIMIT
|
||
: 100;
|
||
return {
|
||
repo: resolveRepoOption(args),
|
||
dryRun: hasFlag(args, "--dry-run"),
|
||
raw: hasFlag(args, "--raw"),
|
||
full: hasFlag(args, "--full"),
|
||
limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : top === "issue" && sub === "stale-close" ? MAX_ISSUE_LIST_LIMIT : 30, limitMax),
|
||
inactiveHours: positiveNumberOption(args, "--inactive-hours", DEFAULT_STALE_CLOSE_INACTIVE_HOURS, MAX_STALE_CLOSE_INACTIVE_HOURS),
|
||
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),
|
||
search: optionValue(args, "--search"),
|
||
title: optionValue(args, "--title"),
|
||
body: optionValue(args, "--body"),
|
||
bodyFile: optionValue(args, "--body-file"),
|
||
comment: optionValue(args, "--comment"),
|
||
commentFile: optionValue(args, "--comment-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),
|
||
mergeMethod: parsePullRequestMergeMethod(args),
|
||
deleteBranch: hasFlag(args, "--delete-branch"),
|
||
};
|
||
}
|
||
|
||
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 parsePositionalNumberForCommand(repo: string, args: string[], startIndex: number, label: string): number | GitHubCommandResult {
|
||
const targets = positionalArgs(args.slice(startIndex));
|
||
if (targets.length !== 1) {
|
||
return validationError(label, repo, `${label} requires exactly one positive integer positional argument`, {
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${label} <number> --repo ${repo}`,
|
||
`bun scripts/cli.ts gh ${label} --repo ${repo} <number>`,
|
||
],
|
||
});
|
||
}
|
||
return parseNumberForCommand(repo, targets[0], label);
|
||
}
|
||
|
||
function resolvedNumberOptionReference(kind: "issue" | "pr", command: string, repo: string, raw: string): GitHubResolvedNumberReference | GitHubCommandResult {
|
||
const number = parseNumberForCommand(repo, raw, command);
|
||
if (typeof number !== "number") return number;
|
||
return {
|
||
repo,
|
||
number,
|
||
shorthand: {
|
||
input: `--number ${number}`,
|
||
repo,
|
||
number,
|
||
source: "number-option",
|
||
standardCommand: `bun scripts/cli.ts gh ${command} ${number} --repo ${repo}`,
|
||
urlKind: kind,
|
||
},
|
||
};
|
||
}
|
||
|
||
function resolvePositionalNumberReference(kind: "issue" | "pr", args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult {
|
||
const targets = positionalArgs(args.slice(startIndex));
|
||
const numberOption = optionValue(args, "--number");
|
||
if (targets.length > 0 && numberOption !== undefined) {
|
||
return validationError(label, options.repo, `${label} accepts either a positional numeric target or --number, not both`, {
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
||
],
|
||
});
|
||
}
|
||
if (numberOption !== undefined) return resolvedNumberOptionReference(kind, label, options.repo, numberOption);
|
||
if (targets.length !== 1) {
|
||
return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, {
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
||
],
|
||
});
|
||
}
|
||
const shorthand = parseOwnerRepoNumberShorthand(targets[0]);
|
||
if (shorthand !== null) {
|
||
const explicitRepo = optionValue(args, "--repo");
|
||
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
|
||
return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, {
|
||
shorthand,
|
||
explicitRepo,
|
||
});
|
||
}
|
||
return { repo: shorthand.repo, number: shorthand.number, shorthand };
|
||
}
|
||
const number = parseNumberForCommand(options.repo, targets[0], label);
|
||
if (typeof number !== "number") return number;
|
||
return { repo: options.repo, number };
|
||
}
|
||
|
||
function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult {
|
||
const targets = positionalArgs(args.slice(startIndex));
|
||
const prOption = optionValue(args, "--pr");
|
||
const numberOption = optionValue(args, "--number");
|
||
if (prOption !== undefined && numberOption !== undefined) {
|
||
return validationError(label, options.repo, `${label} accepts either --pr or --number as a compatibility alias, not both`, {
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
||
],
|
||
});
|
||
}
|
||
if (targets.length > 0 && (prOption !== undefined || numberOption !== undefined)) {
|
||
return validationError(label, options.repo, `${label} accepts either a positional PR target or a compatibility number option, not both`, {
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
||
],
|
||
});
|
||
}
|
||
if (numberOption !== undefined) return resolvedNumberOptionReference("pr", label, options.repo, numberOption);
|
||
const effectiveTargets = prOption !== undefined ? [prOption] : targets;
|
||
if (effectiveTargets.length !== 1) {
|
||
return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, {
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
||
],
|
||
});
|
||
}
|
||
const shorthand = parseOwnerRepoNumberShorthand(effectiveTargets[0]);
|
||
if (shorthand !== null) {
|
||
const explicitRepo = optionValue(args, "--repo");
|
||
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
|
||
return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, {
|
||
shorthand,
|
||
explicitRepo,
|
||
});
|
||
}
|
||
return { repo: shorthand.repo, number: shorthand.number, shorthand };
|
||
}
|
||
const number = parseNumberForCommand(options.repo, effectiveTargets[0], label);
|
||
if (typeof number !== "number") return number;
|
||
return { repo: options.repo, number };
|
||
}
|
||
|
||
function resolvePositionalIssueReference(args: string[], startIndex: number, label: string, options: GitHubOptions, allowNumberOption = true): GitHubResolvedNumberReference | GitHubCommandResult {
|
||
const targets = positionalArgs(args.slice(startIndex));
|
||
const numberOption = allowNumberOption ? optionValue(args, "--number") : undefined;
|
||
if (targets.length > 0 && numberOption !== undefined) {
|
||
return validationError(label, options.repo, `${label} accepts either a positional issue target or --number, not both`, {
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
||
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
||
],
|
||
});
|
||
}
|
||
if (numberOption !== undefined) return resolvedNumberOptionReference("issue", label, options.repo, numberOption);
|
||
if (targets.length !== 1) {
|
||
return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, {
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
|
||
...(allowNumberOption ? [`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`] : []),
|
||
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
||
],
|
||
});
|
||
}
|
||
const shorthand = parseOwnerRepoNumberShorthand(targets[0]);
|
||
if (shorthand !== null) {
|
||
const explicitRepo = optionValue(args, "--repo");
|
||
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
|
||
return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, {
|
||
shorthand,
|
||
explicitRepo,
|
||
});
|
||
}
|
||
return { repo: shorthand.repo, number: shorthand.number, shorthand };
|
||
}
|
||
const number = parseNumberForCommand(options.repo, targets[0], label);
|
||
if (typeof number !== "number") return number;
|
||
return { repo: options.repo, number };
|
||
}
|
||
|
||
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]),
|
||
source: "owner-repo-number",
|
||
};
|
||
}
|
||
|
||
function parseGitHubIssueOrPrUrl(raw: string | undefined): GitHubShorthandReference | null {
|
||
if (raw === undefined) return null;
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
|
||
if (parsed.hostname.toLowerCase() !== "github.com" && parsed.hostname.toLowerCase() !== "www.github.com") return null;
|
||
const parts = parsed.pathname.split("/").filter((part) => part.length > 0);
|
||
if (parts.length < 4) return null;
|
||
const [owner, repoName, kind, numberRaw] = parts;
|
||
if (!owner || !repoName || (kind !== "issues" && kind !== "pull")) return null;
|
||
if (!/^[1-9]\d*$/u.test(numberRaw ?? "")) return null;
|
||
return {
|
||
input: raw,
|
||
repo: `${owner}/${repoName}`,
|
||
number: Number(numberRaw),
|
||
source: "github-url",
|
||
urlKind: kind === "issues" ? "issue" : "pr",
|
||
};
|
||
}
|
||
|
||
function parseReadViewTarget(raw: string | undefined): GitHubShorthandReference | null {
|
||
return parseOwnerRepoNumberShorthand(raw) ?? parseGitHubIssueOrPrUrl(raw);
|
||
}
|
||
|
||
function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] {
|
||
return [
|
||
`bun scripts/cli.ts gh ${kind} view ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`,
|
||
`bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`,
|
||
`bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)} [compatibility alias]`,
|
||
];
|
||
}
|
||
|
||
function readViewSupportedJsonFields(kind: "issue" | "pr"): string {
|
||
return kind === "issue"
|
||
? ISSUE_VIEW_JSON_FIELDS.join(",")
|
||
: "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
|
||
}
|
||
|
||
function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "view", _raw: string | undefined, options: GitHubOptions, args: string[]): GitHubResolvedNumberReference | GitHubCommandResult {
|
||
const command = `${kind} ${sub}`;
|
||
const targets = positionalArgs(args.slice(2));
|
||
if (targets.length > 1) {
|
||
return validationError(command, options.repo, `${command} accepts one positional target: number, GitHub ${kind} URL, or owner/repo#number`, {
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
|
||
`bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`,
|
||
`bun scripts/cli.ts gh ${kind} view owner/name#<number> --raw`,
|
||
],
|
||
});
|
||
}
|
||
const raw = targets[0];
|
||
if (optionWasProvided(args, "--number")) {
|
||
const numberAliasRaw = optionValue(args, "--number");
|
||
const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command);
|
||
if (typeof aliasNumber !== "number") return aliasNumber;
|
||
if (raw !== undefined) {
|
||
const parsedRaw = parseNumberForCommand(options.repo, raw, command);
|
||
if (typeof parsedRaw !== "number") return parsedRaw;
|
||
if (parsedRaw !== aliasNumber) {
|
||
return validationError(command, options.repo, `${command} positional number ${parsedRaw} conflicts with --number ${aliasNumber}; use one target number.`, {
|
||
positionalNumber: parsedRaw,
|
||
numberAlias: aliasNumber,
|
||
supportedCommands: readViewSupportedCommands(kind, options.repo, aliasNumber),
|
||
});
|
||
}
|
||
}
|
||
return {
|
||
repo: options.repo,
|
||
number: aliasNumber,
|
||
shorthand: {
|
||
input: `--number ${aliasNumber}`,
|
||
repo: options.repo,
|
||
number: aliasNumber,
|
||
source: "number-option",
|
||
standardCommand: `bun scripts/cli.ts gh ${kind} view ${aliasNumber} --repo ${options.repo}`,
|
||
},
|
||
};
|
||
}
|
||
const shorthand = parseReadViewTarget(raw);
|
||
if (shorthand !== null) {
|
||
if (shorthand.urlKind !== undefined && shorthand.urlKind !== kind) {
|
||
return validationError(command, shorthand.repo, `${command} target ${shorthand.input} is a GitHub ${shorthand.urlKind} URL, not a ${kind} URL.`, {
|
||
shorthand,
|
||
supportedCommands: readViewSupportedCommands(shorthand.urlKind, shorthand.repo, shorthand.number),
|
||
});
|
||
}
|
||
const explicitRepo = optionValue(args, "--repo");
|
||
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
|
||
const message = `${command} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided. Use either the shorthand or a matching --repo, not both.`;
|
||
return validationError(command, explicitRepo, message, {
|
||
message,
|
||
shorthand,
|
||
explicitRepo,
|
||
supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number),
|
||
});
|
||
}
|
||
return { repo: shorthand.repo, number: shorthand.number, shorthand };
|
||
}
|
||
const parsed = parseNumberForCommand(options.repo, raw, command);
|
||
if (typeof parsed !== "number") {
|
||
return {
|
||
...parsed,
|
||
supportedCommands: [
|
||
`bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
|
||
`bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`,
|
||
`bun scripts/cli.ts gh ${kind} view 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,
|
||
...(shorthand?.source === "number-option" ? {
|
||
compatibilityHint: "--number is accepted for low-friction compatibility; standard gh syntax uses a positional number or URL.",
|
||
standardCommand: shorthand.standardCommand,
|
||
} : {}),
|
||
};
|
||
}
|
||
|
||
function numberOptionCompatibilityHint(resolved: GitHubResolvedNumberReference): Record<string, unknown> | null {
|
||
if (resolved.shorthand?.source !== "number-option") return null;
|
||
return {
|
||
acceptedOption: "--number",
|
||
compatibility: true,
|
||
message: "--number is accepted for low-friction compatibility; prefer the standard positional number target shown in standardCommand.",
|
||
standardCommand: resolved.shorthand.standardCommand ?? null,
|
||
};
|
||
}
|
||
|
||
async function withNumberOptionHint(result: GitHubCommandResult | Promise<GitHubCommandResult>, resolved: GitHubResolvedNumberReference): Promise<GitHubCommandResult> {
|
||
const output = await result;
|
||
const hint = numberOptionCompatibilityHint(resolved);
|
||
if (hint === null) return output;
|
||
return {
|
||
...output,
|
||
standardSyntaxHint: hint,
|
||
};
|
||
}
|
||
|
||
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 = parseReadViewTarget(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} view <number> --repo owner/name --json ${readViewSupportedJsonFields(top)}`,
|
||
`bun scripts/cli.ts gh ${top} view owner/name#<number> --raw`,
|
||
];
|
||
}
|
||
return details;
|
||
}
|
||
|
||
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 secretLikeInlineFindings(body: string): string[] {
|
||
const findings: string[] = [];
|
||
if (/\bgh[pousr]_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-token-like-value");
|
||
if (/\bgithub_pat_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-pat-like-value");
|
||
if (/\b(?:token|password|passwd|secret|api[_-]?key)\s*[:=]\s*["']?[^"'\s,;]{6,}/iu.test(body)) findings.push("credential-assignment-like-value");
|
||
if (/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/iu.test(body)) findings.push("bearer-token-like-value");
|
||
return findings;
|
||
}
|
||
|
||
function readIssueCommentBody(options: GitHubOptions): { body: string; bodySource: Record<string, unknown> } {
|
||
if (options.bodyFile !== undefined && options.body !== undefined) {
|
||
throw new Error("issue comment create accepts only one body source: --body-file or --body");
|
||
}
|
||
if (options.bodyFile !== undefined) {
|
||
return readMarkdownBodyFileOrStdin(options.bodyFile);
|
||
}
|
||
if (options.body === undefined) throw new Error("issue comment create requires --body-file <file> or --body <text>");
|
||
const body = options.body;
|
||
const trimmed = body.trim();
|
||
const shellPollution = shellPollutionEvidence(body);
|
||
const secretLike = secretLikeInlineFindings(body);
|
||
if (trimmed.length === 0) {
|
||
throw new Error("issue comment create --body must not be blank; use --body-file for reviewed Markdown");
|
||
}
|
||
if (body.length > MAX_INLINE_ISSUE_COMMENT_BODY_CHARS) {
|
||
throw new Error(`issue comment create --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-file for long Markdown`);
|
||
}
|
||
if (body.includes("\n") || body.includes("\r")) {
|
||
throw new Error("issue comment create --body supports short single-line text only; use --body-file for multiline Markdown");
|
||
}
|
||
if (shellPollution.length > 0) {
|
||
throw new Error(`issue comment create --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-file with reviewed Markdown bytes`);
|
||
}
|
||
if (secretLike.length > 0) {
|
||
throw new Error(`issue comment create --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`);
|
||
}
|
||
return {
|
||
body,
|
||
bodySource: {
|
||
kind: "inline",
|
||
maxInlineBodyChars: MAX_INLINE_ISSUE_COMMENT_BODY_CHARS,
|
||
warning: "inline issue comments are intended only for short single-line text; use --body-file for Markdown or generated content",
|
||
},
|
||
};
|
||
}
|
||
|
||
function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
|
||
if (options.comment === undefined && options.commentFile === undefined) return null;
|
||
if (options.comment !== undefined && options.commentFile !== undefined) {
|
||
throw new Error(`${command} --comment and --comment-file are mutually exclusive`);
|
||
}
|
||
if (options.body !== undefined || options.bodyFile !== undefined) {
|
||
throw new Error(`${command} --comment or --comment-file cannot be combined with --body or --body-file`);
|
||
}
|
||
if (options.commentFile !== undefined) {
|
||
return readIssueCommentBody({ ...options, body: undefined, bodyFile: options.commentFile });
|
||
}
|
||
return readIssueCommentBody({ ...options, body: options.comment, bodyFile: undefined });
|
||
}
|
||
|
||
function tokenFromEnvironment(): GitHubTokenProbe {
|
||
if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) {
|
||
return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false };
|
||
}
|
||
if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.length > 0) {
|
||
return { present: true, source: "GITHUB_TOKEN", ghFallbackAttempted: false };
|
||
}
|
||
return { present: false, source: null, ghFallbackAttempted: false };
|
||
}
|
||
|
||
function ghBinaryPath(): string | null {
|
||
try {
|
||
const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||
return output.length > 0 ? output : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function ghAuthToken(): string | null {
|
||
try {
|
||
const output = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
||
return output.length > 0 ? output : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } {
|
||
const envProbe = tokenFromEnvironment();
|
||
if (envProbe.present) {
|
||
const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null;
|
||
return { token, probe: envProbe };
|
||
}
|
||
if (!allowGhFallback) return { token: null, probe: envProbe };
|
||
const ghPath = ghBinaryPath();
|
||
if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
|
||
const token = ghAuthToken();
|
||
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } };
|
||
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
|
||
}
|
||
|
||
function repoParts(repo: string): { owner: string; name: string } {
|
||
const [owner, name, extra] = repo.split("/");
|
||
if (!owner || !name || extra !== undefined) throw new Error("--repo must be in owner/name format");
|
||
return { owner, name };
|
||
}
|
||
|
||
function preview(text: string): string {
|
||
return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}...` : text;
|
||
}
|
||
|
||
function previewLines(text: string, maxLines = 12): string[] {
|
||
return text.split(/\r?\n/).slice(0, maxLines).map((line) => preview(line));
|
||
}
|
||
|
||
function bodySha(text: string): string {
|
||
return createHash("sha256").update(text, "utf8").digest("hex");
|
||
}
|
||
|
||
function normalizeExpectedSha(raw: string): string {
|
||
const value = raw.replace(/^sha256:/iu, "").toLowerCase();
|
||
if (!/^[a-f0-9]{64}$/u.test(value)) throw new Error("--expect-body-sha must be a 64-character SHA-256 hex value, optionally prefixed with sha256:");
|
||
return value;
|
||
}
|
||
|
||
function shellPollutionEvidence(body: string): string[] {
|
||
const evidence: string[] = [];
|
||
if (body.includes("\\n")) evidence.push("literal-backslash-n");
|
||
if (body.includes("\\t")) evidence.push("literal-backslash-t");
|
||
if (body.includes("\\`")) evidence.push("escaped-backtick");
|
||
if (/\\x1b|\\u001b|\u001b\[/u.test(body)) evidence.push("ansi-escape");
|
||
if (/\r(?!\n)/u.test(body)) evidence.push("bare-carriage-return");
|
||
return evidence;
|
||
}
|
||
|
||
function bodySafetySignals(body: string): Record<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,
|
||
timeoutMs: config.timeoutMs,
|
||
});
|
||
if (claudeQqResponseOk(response)) return { ok: true, endpoint, status: isRecord(response) && typeof response.status === "number" ? response.status : 200, response };
|
||
return summarizeClaudeQqProxyFailure(response, endpoint);
|
||
}
|
||
|
||
return {
|
||
ok: false,
|
||
endpoint,
|
||
degradedReason: "notification-path-unavailable",
|
||
message: "ClaudeQQ notifications must use backend-core /api/microservices/claudeqq/proxy; local skill, powershell, direct host, and ad-hoc ClaudeQQ URLs are not supported.",
|
||
response: {
|
||
baseUrl: sanitizeUrlForOutput(config.baseUrl),
|
||
requiredBaseUrl: DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL,
|
||
recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw",
|
||
},
|
||
};
|
||
}
|
||
|
||
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> {
|
||
const proxyBasePath = proxiedClaudeQqBasePath(config.baseUrl);
|
||
return {
|
||
enabled: config.enabled,
|
||
issueNumber,
|
||
bodyChars: body.length,
|
||
diff: {
|
||
ok: diff.ok,
|
||
mode: diff.mode,
|
||
chars: diff.chars,
|
||
sectionCount: diff.sectionCount,
|
||
skippedReason: diff.skippedReason ?? null,
|
||
preview: diff.ok ? preview(diff.message) : "",
|
||
previewLines: diff.ok ? previewLines(diff.message) : [],
|
||
containsLiteralBackslashN: diff.message.includes("\\n"),
|
||
containsBackticks: diff.message.includes("`"),
|
||
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message),
|
||
},
|
||
claudeqq: {
|
||
dryRun: true,
|
||
wouldSend: config.enabled && diff.ok && proxyBasePath !== null,
|
||
baseUrl: sanitizeUrlForOutput(config.baseUrl),
|
||
proxyBasePath,
|
||
servicePath: proxyBasePath === null ? null : normalizeClaudeQqEndpoint(proxyBasePath, "/api/push/text"),
|
||
target: maskedTarget(config),
|
||
timeoutMs: config.timeoutMs,
|
||
...(proxyBasePath === null
|
||
? {
|
||
blockedReason: "notification-path-unavailable",
|
||
recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw",
|
||
}
|
||
: {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
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 source = String(bodySource.kind ?? "unknown");
|
||
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,
|
||
source,
|
||
bodyPreview: preview(body),
|
||
bodyPreviewLines: previewLines(body),
|
||
...bodySafetySignals(body),
|
||
request: {
|
||
method: "POST",
|
||
path: isIssueWrite
|
||
? (command === "issue create" ? "/repos/{owner}/{repo}/issues" : "/repos/{owner}/{repo}/issues/{issue_number}/comments")
|
||
: (command === "pr create" ? "/repos/{owner}/{repo}/pulls" : "/repos/{owner}/{repo}/issues/{issue_number}/comments"),
|
||
body: requestBody,
|
||
},
|
||
validation: {
|
||
source,
|
||
rawText: source === "body-file"
|
||
? "read from file bytes without shell interpolation"
|
||
: source === "stdin"
|
||
? "read from stdin bytes"
|
||
: "short inline argument accepted only by command-specific safety rules",
|
||
},
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition {
|
||
if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed";
|
||
return "infra-blocked";
|
||
}
|
||
|
||
function sanitizedErrorDetails(parsed: unknown): unknown {
|
||
if (typeof parsed !== "object" || parsed === null) return parsed;
|
||
const source = parsed as Record<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 issueBodyReadCommands(repo: string, issueNumber: number): Record<string, string> {
|
||
return {
|
||
body: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json body`,
|
||
full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
|
||
raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
|
||
};
|
||
}
|
||
|
||
function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> {
|
||
return {
|
||
comments: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json comments`,
|
||
full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
|
||
raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
|
||
};
|
||
}
|
||
|
||
function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> {
|
||
const explicitFullDisclosure = options.raw || options.full;
|
||
return {
|
||
defaultCompact: dryRun || !explicitFullDisclosure,
|
||
explicitFullDisclosure,
|
||
fullBodyIncluded: explicitFullDisclosure && !dryRun,
|
||
bodyOmitted: dryRun || !explicitFullDisclosure,
|
||
dryRunBoundedPreview: dryRun,
|
||
note: explicitFullDisclosure && !dryRun
|
||
? "The returned issue object includes the full body because --full or --raw was explicitly requested."
|
||
: "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.",
|
||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||
};
|
||
}
|
||
|
||
function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; previewLineCount?: number } = {}): Record<string, unknown> {
|
||
const body = issue.body ?? "";
|
||
const includeBody = options.includeBody ?? true;
|
||
const summary: Record<string, unknown> = {
|
||
id: issue.id,
|
||
number: issue.number,
|
||
title: issue.title,
|
||
state: issue.state,
|
||
closed: issue.state === "closed",
|
||
closedAt: issue.closed_at ?? null,
|
||
url: issue.html_url,
|
||
author: issue.user?.login ?? null,
|
||
createdAt: issue.created_at ?? null,
|
||
updatedAt: issue.updated_at ?? null,
|
||
commentCount: issue.comments ?? null,
|
||
bodyChars: body.length,
|
||
bodySha: bodySha(body),
|
||
};
|
||
if (includeBody) {
|
||
summary.body = body;
|
||
} else {
|
||
summary.bodyOmitted = true;
|
||
summary.fullBodyIncluded = false;
|
||
summary.bodyPreview = preview(body);
|
||
summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8);
|
||
}
|
||
return summary;
|
||
}
|
||
|
||
function issueLifecycleSummary(issue: GitHubIssue): Record<string, unknown> {
|
||
return {
|
||
id: issue.id,
|
||
number: issue.number,
|
||
title: issue.title,
|
||
state: issue.state,
|
||
closed: issue.state === "closed",
|
||
closedAt: issue.closed_at ?? null,
|
||
url: issue.html_url,
|
||
author: issue.user?.login ?? null,
|
||
createdAt: issue.created_at ?? null,
|
||
updatedAt: issue.updated_at ?? null,
|
||
commentCount: issue.comments ?? null,
|
||
labels: issueLabelNames(issue),
|
||
bodyChars: (issue.body ?? "").length,
|
||
bodySha: bodySha(issue.body ?? ""),
|
||
bodyOmitted: true,
|
||
fullBodyIncluded: false,
|
||
};
|
||
}
|
||
|
||
function issueLifecycleDisclosure(repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> {
|
||
return {
|
||
defaultCompact: true,
|
||
explicitFullDisclosure: false,
|
||
fullBodyIncluded: false,
|
||
bodyOmitted: true,
|
||
dryRunBoundedPreview: dryRun,
|
||
note: "Issue lifecycle write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.",
|
||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||
};
|
||
}
|
||
|
||
function issueLifecycleBatchSummary(issues: GitHubIssue[]): Record<string, unknown> {
|
||
const visible = issues.slice(0, ISSUE_LIFECYCLE_PREVIEW_LIMIT);
|
||
return {
|
||
count: issues.length,
|
||
returned: visible.length,
|
||
omitted: Math.max(0, issues.length - visible.length),
|
||
numbers: visible.map((issue) => issue.number),
|
||
issues: visible.map(issueLifecycleSummary),
|
||
};
|
||
}
|
||
|
||
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,
|
||
closed: issue.state === "closed",
|
||
closedAt: issue.closed_at ?? null,
|
||
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,
|
||
disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true),
|
||
readCommands: issueBodyReadCommands(repo, options.boardIssue),
|
||
wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length },
|
||
note: !hasConcurrencyGuard
|
||
? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified."
|
||
: "Dry-run only; no GitHub issue body was modified.",
|
||
};
|
||
}
|
||
const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options);
|
||
if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName };
|
||
const { owner, name } = repoParts(repo);
|
||
const issue = await githubRequest<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, { includeBody: options.raw || options.full }),
|
||
disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false),
|
||
readCommands: issueBodyReadCommands(repo, options.boardIssue),
|
||
concurrency: {
|
||
checked: true,
|
||
oldIssueUpdatedAt: boardIssue.updated_at ?? null,
|
||
oldBodySha: bodySha(boardIssue.body ?? ""),
|
||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||
expectBodySha: options.expectBodySha ?? null,
|
||
},
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function issueBoardRowList(repo: string, token: string, options: GitHubOptions): Promise<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,
|
||
disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true),
|
||
readCommands: issueBodyReadCommands(repo, options.boardIssue),
|
||
wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length },
|
||
note: options.expectBodySha === undefined && options.expectUpdatedAt === undefined
|
||
? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified."
|
||
: "Dry-run only; no GitHub issue body was modified.",
|
||
};
|
||
}
|
||
const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options);
|
||
if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName };
|
||
const { owner, name } = repoParts(repo);
|
||
const issue = await githubRequest<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, { includeBody: options.raw || options.full }),
|
||
disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false),
|
||
readCommands: issueBodyReadCommands(repo, options.boardIssue),
|
||
concurrency: {
|
||
checked: true,
|
||
oldIssueUpdatedAt: boardIssue.updated_at ?? null,
|
||
oldBodySha: bodySha(boardIssue.body ?? ""),
|
||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||
expectBodySha: options.expectBodySha ?? null,
|
||
},
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function issueBoardRowAdd(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<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 profileOk = validationBoolean(profileValidation, "ok");
|
||
const failures: string[] = [];
|
||
if (isLiteralNull) failures.push("literal-null-body");
|
||
if (isBlank) failures.push("blank-body");
|
||
if (isShort && !options.allowShortBody) failures.push("short-body");
|
||
if (boardContainsCommanderBriefUpdates) failures.push("code-queue-board-contains-commander-brief-updates");
|
||
if (!profileOk) {
|
||
if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch");
|
||
const missingHeadings = validationStringArray(profileValidation, "missingHeadings");
|
||
if (missingHeadings.length > 0) failures.push("profile-heading-missing");
|
||
}
|
||
if (failures.length === 0) return null;
|
||
const overrideAvailable = failures.every((failure) => failure === "short-body");
|
||
const message = overrideAvailable
|
||
? "issue edit body is shorter than the safe default; pass --allow-short-body only for intentional short writes"
|
||
: "issue edit body failed safety guard; refusing to write possible body-only issue corruption";
|
||
return validationError("issue edit", repo, message, {
|
||
issueNumber,
|
||
guard: {
|
||
ok: false,
|
||
failures,
|
||
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
|
||
allowShortBody: options.allowShortBody,
|
||
shortBodyOverrideAvailable: overrideAvailable,
|
||
bodySource: { kind: "body-file", path: options.bodyFile ?? null },
|
||
bodyPreview: preview(body),
|
||
bodyPreviewLines: previewLines(body),
|
||
codeQueueBoardHint: boardBriefHint,
|
||
...bodySafetySignals(body),
|
||
isLiteralNull,
|
||
isBlank,
|
||
isShort,
|
||
profile: profileValidation,
|
||
},
|
||
});
|
||
}
|
||
|
||
function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): Record<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),
|
||
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 compactCommentSummary(comment: GitHubComment): Record<string, unknown> {
|
||
const body = comment.body ?? "";
|
||
return {
|
||
id: comment.id,
|
||
url: comment.html_url,
|
||
author: comment.user?.login ?? null,
|
||
createdAt: comment.created_at ?? null,
|
||
updatedAt: comment.updated_at ?? null,
|
||
bodyChars: body.length,
|
||
bodySha: bodySha(body),
|
||
bodyPreview: preview(body),
|
||
bodyPreviewLines: previewLines(body, 4),
|
||
bodyOmitted: true,
|
||
fullBodyIncluded: false,
|
||
};
|
||
}
|
||
|
||
function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" {
|
||
if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged";
|
||
return pr.state === "closed" ? "closed" : "open";
|
||
}
|
||
|
||
function prSummary(pr: GitHubPullRequest): Record<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 prMergeBoundary(): Record<string, unknown> {
|
||
return {
|
||
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close", "pr merge after explicit command authorization and preflight success"],
|
||
ordinaryRunnerFinalActionAllowed: true,
|
||
commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"],
|
||
hostAllowedToolsAfterReview: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"],
|
||
unideskCliMergeSupported: true,
|
||
};
|
||
}
|
||
|
||
function prCloseoutMetadata(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> {
|
||
const summary = prMetadataSummary(metadata);
|
||
const missingOrUnknownFields = PR_CLOSEOUT_JSON_FIELDS.filter((field) => {
|
||
const value = summary[field];
|
||
if (value === null || value === undefined) return true;
|
||
return typeof value === "string" && value.toUpperCase() === "UNKNOWN";
|
||
});
|
||
return {
|
||
ok: missingOrUnknownFields.length === 0,
|
||
source: "github-graphql",
|
||
missingOrUnknownFields,
|
||
advice: missingOrUnknownFields.length === 0
|
||
? "Closeout GraphQL metadata is present; still review checks, branch scope, and task boundary before final action."
|
||
: "Some closeout GraphQL metadata is missing or unknown; retry or cross-check with system gh/GitHub UI before treating the PR as merge-ready.",
|
||
mergeBoundary: prMergeBoundary(),
|
||
};
|
||
}
|
||
|
||
function prCloseoutMetadataError(error: GitHubErrorPayload): Record<string, unknown> {
|
||
return {
|
||
ok: false,
|
||
source: "github-graphql",
|
||
degradedReason: error.degradedReason,
|
||
runnerDisposition: error.runnerDisposition,
|
||
message: error.message,
|
||
advice: "Closeout GraphQL metadata was not available; retry or use a read-only system gh/GitHub UI cross-check before deciding merge readiness.",
|
||
mergeBoundary: prMergeBoundary(),
|
||
};
|
||
}
|
||
|
||
function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean {
|
||
if (fields === undefined) return false;
|
||
return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup");
|
||
}
|
||
|
||
async function prGraphqlMetadata(repo: string, token: string, number: number): Promise<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 statusContextName(context: GitHubPullRequestGraphqlStatusContext): string {
|
||
return context.name ?? context.context ?? "(unnamed status check)";
|
||
}
|
||
|
||
function statusContextBucket(context: GitHubPullRequestGraphqlStatusContext): string {
|
||
const type = context.__typename ?? "StatusContext";
|
||
const status = (context.status ?? "").toUpperCase();
|
||
const conclusion = (context.conclusion ?? "").toUpperCase();
|
||
const state = (context.state ?? "").toUpperCase();
|
||
if (type === "CheckRun") {
|
||
if (status.length > 0 && status !== "COMPLETED") return "pending";
|
||
if (conclusion === "SUCCESS") return "success";
|
||
if (conclusion === "NEUTRAL") return "neutral";
|
||
if (conclusion === "SKIPPED") return "skipped";
|
||
if (conclusion === "CANCELLED") return "cancelled";
|
||
if (conclusion === "TIMED_OUT") return "timed-out";
|
||
if (["FAILURE", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(conclusion)) return "failure";
|
||
return "unknown";
|
||
}
|
||
if (state === "SUCCESS") return "success";
|
||
if (state === "PENDING" || state === "EXPECTED") return "pending";
|
||
if (state === "FAILURE" || state === "ERROR") return "failure";
|
||
return "unknown";
|
||
}
|
||
|
||
function compactStatusContext(context: GitHubPullRequestGraphqlStatusContext): Record<string, unknown> {
|
||
const bucket = statusContextBucket(context);
|
||
return {
|
||
name: statusContextName(context),
|
||
type: context.__typename ?? "StatusContext",
|
||
bucket,
|
||
status: context.status ?? null,
|
||
conclusion: context.conclusion ?? null,
|
||
state: context.state ?? null,
|
||
targetUrl: context.targetUrl ?? null,
|
||
description: context.description === undefined || context.description === null ? null : preview(context.description),
|
||
};
|
||
}
|
||
|
||
function statusRollupSummary(
|
||
repo: string,
|
||
number: number,
|
||
rollup: GitHubPullRequestGraphqlStatusCheckRollup | null | undefined,
|
||
includeRaw: boolean,
|
||
): Record<string, unknown> {
|
||
const contexts = rollup?.contexts?.nodes ?? [];
|
||
const compactContexts = contexts.map(compactStatusContext);
|
||
const counts: Record<string, number> = {
|
||
success: 0,
|
||
failure: 0,
|
||
pending: 0,
|
||
neutral: 0,
|
||
skipped: 0,
|
||
cancelled: 0,
|
||
"timed-out": 0,
|
||
unknown: 0,
|
||
};
|
||
for (const context of compactContexts) {
|
||
const bucket = String(context.bucket ?? "unknown");
|
||
counts[bucket] = (counts[bucket] ?? 0) + 1;
|
||
}
|
||
const nonSuccess = compactContexts.filter((context) => context.bucket !== "success");
|
||
const failing = compactContexts.filter((context) => context.bucket === "failure" || context.bucket === "cancelled" || context.bucket === "timed-out");
|
||
const pending = compactContexts.filter((context) => context.bucket === "pending" || context.bucket === "unknown");
|
||
const neutral = compactContexts.filter((context) => context.bucket === "neutral" || context.bucket === "skipped");
|
||
return {
|
||
state: rollup?.state ?? null,
|
||
totalContexts: compactContexts.length,
|
||
counts,
|
||
failingContexts: failing.slice(0, 10),
|
||
pendingContexts: pending.slice(0, 10),
|
||
neutralContexts: neutral.slice(0, 10),
|
||
nonSuccessPreview: nonSuccess.slice(0, 12),
|
||
omittedNonSuccessContexts: Math.max(0, nonSuccess.length - 12),
|
||
rawOmitted: !includeRaw,
|
||
...(includeRaw
|
||
? { contexts: compactContexts, raw: rollup ?? null }
|
||
: { fullHint: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo} --full` }),
|
||
};
|
||
}
|
||
|
||
function compactProbeOk(value: unknown): boolean | null {
|
||
if (value === "ok") return true;
|
||
if (value === "skipped") return null;
|
||
if (isRecord(value) && value.ok === true) return true;
|
||
if (isRecord(value) && value.ok === false) return false;
|
||
return null;
|
||
}
|
||
|
||
function compactAuthCapability(auth: GitHubCommandResult): Record<string, unknown> {
|
||
const token = isRecord(auth.token) ? auth.token : {};
|
||
const gh = isRecord(auth.gh) ? auth.gh : {};
|
||
const probes = isRecord(auth.probes) ? auth.probes : {};
|
||
return {
|
||
ok: auth.ok === true,
|
||
degradedReason: auth.ok === false ? auth.degradedReason ?? null : null,
|
||
runnerDisposition: auth.ok === false ? auth.runnerDisposition ?? null : null,
|
||
tokenPresent: token.present === true,
|
||
tokenSource: typeof token.source === "string" ? token.source : null,
|
||
ghFallbackAttempted: token.ghFallbackAttempted === true,
|
||
ghBinaryFound: gh.binaryFound === true,
|
||
restApi: compactProbeOk(probes.restApi),
|
||
repoVisible: compactProbeOk(probes.repo),
|
||
issueRead: compactProbeOk(probes.issueRead),
|
||
valuesPrinted: false,
|
||
};
|
||
}
|
||
|
||
function prPreflightPolicy(repo: string, number: number): Record<string, unknown> {
|
||
return {
|
||
readOnly: true,
|
||
writesRemote: false,
|
||
createsPr: false,
|
||
comments: false,
|
||
mergesPr: false,
|
||
mergeCommandSupported: true,
|
||
mergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo} --merge`,
|
||
unideskCliMergeSupported: true,
|
||
ordinaryRunnerFinalActionAllowed: true,
|
||
note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; use gh pr merge only after this command reports ready and the task boundary allows merge.",
|
||
};
|
||
}
|
||
|
||
function preflightPullRequestSummary(summary: Record<string, unknown>): Record<string, unknown> {
|
||
return {
|
||
number: summary.number ?? null,
|
||
title: summary.title ?? null,
|
||
state: summary.state ?? null,
|
||
stateDetail: summary.stateDetail ?? null,
|
||
draft: summary.draft ?? null,
|
||
url: summary.url ?? null,
|
||
author: summary.author ?? null,
|
||
head: summary.head ?? null,
|
||
base: summary.base ?? null,
|
||
headRefName: summary.headRefName ?? null,
|
||
baseRefName: summary.baseRefName ?? null,
|
||
closed: summary.closed ?? null,
|
||
merged: summary.merged ?? null,
|
||
mergedAt: summary.mergedAt ?? null,
|
||
updatedAt: summary.updatedAt ?? null,
|
||
bodyOmitted: true,
|
||
};
|
||
}
|
||
|
||
function prCloseoutSummary(
|
||
pr: Record<string, unknown>,
|
||
metadata: Record<string, unknown>,
|
||
statusChecks: Record<string, unknown>,
|
||
): Record<string, unknown> {
|
||
const blockers: string[] = [];
|
||
const pending: string[] = [];
|
||
const prState = typeof pr.state === "string" ? pr.state.toLowerCase() : "";
|
||
if (prState !== "open") blockers.push(`state:${pr.state ?? "unknown"}`);
|
||
if (pr.draft === true) blockers.push("draft:true");
|
||
const mergeable = typeof metadata.mergeable === "string" ? metadata.mergeable.toUpperCase() : null;
|
||
if (mergeable === "MERGEABLE") {
|
||
// Ready signal, combined with mergeStateStatus and checks below.
|
||
} else if (mergeable === "CONFLICTING" || mergeable === "NOT_MERGEABLE") {
|
||
blockers.push(`mergeable:${mergeable}`);
|
||
} else {
|
||
pending.push(`mergeable:${mergeable ?? "unknown"}`);
|
||
}
|
||
const mergeStateStatus = typeof metadata.mergeStateStatus === "string" ? metadata.mergeStateStatus.toUpperCase() : null;
|
||
if (mergeStateStatus === "CLEAN") {
|
||
// Ready signal.
|
||
} else if (mergeStateStatus === "UNKNOWN" || mergeStateStatus === null) {
|
||
pending.push(`mergeStateStatus:${mergeStateStatus ?? "unknown"}`);
|
||
} else {
|
||
blockers.push(`mergeStateStatus:${mergeStateStatus}`);
|
||
}
|
||
const rollupState = typeof statusChecks.state === "string" ? statusChecks.state.toUpperCase() : null;
|
||
const totalContexts = typeof statusChecks.totalContexts === "number" ? statusChecks.totalContexts : 0;
|
||
const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {};
|
||
const failureCount = Number(counts.failure ?? 0) + Number(counts.cancelled ?? 0) + Number(counts["timed-out"] ?? 0);
|
||
const pendingCount = Number(counts.pending ?? 0) + Number(counts.unknown ?? 0);
|
||
if (failureCount > 0) blockers.push(`statusChecks:failure:${failureCount}`);
|
||
if (pendingCount > 0) pending.push(`statusChecks:pending:${pendingCount}`);
|
||
if (rollupState === "SUCCESS" || rollupState === null && totalContexts === 0) {
|
||
// Ready signal.
|
||
} else if (rollupState === "PENDING" || rollupState === "EXPECTED" || rollupState === null) {
|
||
pending.push(`statusCheckRollup:${rollupState ?? "unknown"}`);
|
||
} else if (rollupState !== null) {
|
||
blockers.push(`statusCheckRollup:${rollupState}`);
|
||
}
|
||
const readyForCommanderMerge = blockers.length === 0 && pending.length === 0;
|
||
return {
|
||
readyForCommanderMerge,
|
||
conclusion: readyForCommanderMerge ? "ready" : blockers.length > 0 ? "blocked" : "pending",
|
||
blockers,
|
||
pending,
|
||
commanderAction: readyForCommanderMerge
|
||
? "review and merge through bun scripts/cli.ts gh pr merge when task boundaries allow"
|
||
: "resolve blockers or rerun after GitHub finishes computing mergeability/status checks",
|
||
};
|
||
}
|
||
|
||
async function deleteHeadBranchAfterMerge(repo: string, token: string, pr: GitHubPullRequest): Promise<Record<string, unknown>> {
|
||
const { owner, name } = repoParts(repo);
|
||
const headRepo = pr.head?.repo?.full_name ?? null;
|
||
const headRef = pr.head?.ref ?? null;
|
||
if (headRepo !== repo || headRef === null || headRef.length === 0) {
|
||
return {
|
||
attempted: false,
|
||
skippedReason: "head-repo-differs-or-ref-missing",
|
||
headRepo,
|
||
headRef,
|
||
};
|
||
}
|
||
const encodedRef = encodeURIComponent(`heads/${headRef}`);
|
||
const deleted = await githubRequest<unknown>(token, "DELETE", `/repos/${owner}/${name}/git/refs/${encodedRef}`);
|
||
if (isGitHubError(deleted)) {
|
||
return {
|
||
attempted: true,
|
||
ok: false,
|
||
headRepo,
|
||
headRef,
|
||
error: deleted,
|
||
};
|
||
}
|
||
return {
|
||
attempted: true,
|
||
ok: true,
|
||
headRepo,
|
||
headRef,
|
||
};
|
||
}
|
||
|
||
async function prMerge(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const { owner, name } = repoParts(repo);
|
||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||
if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" });
|
||
const summary = prSummary(pr);
|
||
const metadata = await prGraphqlMetadata(repo, token, number);
|
||
if (isGitHubError(metadata)) return commandError("pr merge", repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary });
|
||
const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, false);
|
||
const mergeability = prCloseoutSummary(summary, prMetadataSummary(metadata), statusChecks);
|
||
if (mergeability.readyForCommanderMerge !== true) {
|
||
return validationError("pr merge", repo, "PR is not ready for merge; preflight blockers or pending states remain", {
|
||
number,
|
||
pullRequest: preflightPullRequestSummary(summary),
|
||
mergeability,
|
||
statusChecks,
|
||
closeoutMetadata: prCloseoutMetadata(metadata),
|
||
});
|
||
}
|
||
if (options.dryRun) {
|
||
return {
|
||
ok: true,
|
||
command: "pr merge",
|
||
repo,
|
||
number,
|
||
dryRun: true,
|
||
wouldMerge: true,
|
||
method: options.mergeMethod,
|
||
deleteBranch: options.deleteBranch,
|
||
pullRequest: preflightPullRequestSummary(summary),
|
||
mergeability,
|
||
statusChecks,
|
||
};
|
||
}
|
||
const merged = await githubRequest<Record<string, unknown>>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, {
|
||
merge_method: options.mergeMethod,
|
||
});
|
||
if (isGitHubError(merged)) return commandError("pr merge", repo, merged, { number, phase: "merge", method: options.mergeMethod, pullRequest: summary });
|
||
const after = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||
if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged });
|
||
const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "delete-branch-not-requested" };
|
||
return {
|
||
ok: true,
|
||
command: "pr merge",
|
||
repo,
|
||
number,
|
||
method: options.mergeMethod,
|
||
mergeResult: merged,
|
||
pullRequest: prSummary(after),
|
||
branchDeletion,
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise<GitHubCommandResult> {
|
||
const auth = await authStatus(repo);
|
||
const authCapability = compactAuthCapability(auth);
|
||
const policy = prPreflightPolicy(repo, number);
|
||
if (auth.ok === false) {
|
||
return {
|
||
ok: false,
|
||
command: commandName,
|
||
repo,
|
||
number,
|
||
degradedReason: auth.degradedReason,
|
||
runnerDisposition: auth.runnerDisposition,
|
||
authCapability,
|
||
policy,
|
||
details: auth,
|
||
};
|
||
}
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(repo, commandName, probe);
|
||
if (missing !== null || token === null) {
|
||
const missingResult = missing ?? authRequired(repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
|
||
return {
|
||
...(missingResult ?? commandError(commandName, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required"))),
|
||
command: commandName,
|
||
number,
|
||
authCapability,
|
||
policy,
|
||
} as GitHubCommandResult;
|
||
}
|
||
const { owner, name } = repoParts(repo);
|
||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, authCapability, policy });
|
||
const metadata = await prGraphqlMetadata(repo, token, number);
|
||
const summary = prSummary(pr);
|
||
const pullRequest = preflightPullRequestSummary(summary);
|
||
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest, authCapability, policy });
|
||
const metadataSummary = prMetadataSummary(metadata);
|
||
const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, includeRaw);
|
||
const closeout = prCloseoutSummary(summary, metadataSummary, statusChecks);
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
canonicalCommand: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo}`,
|
||
aliases: ["bun scripts/cli.ts gh preflight <number>", "bun scripts/cli.ts gh pr closeout <number>"],
|
||
repo,
|
||
number,
|
||
readOnly: true,
|
||
writesRemote: false,
|
||
valuesPrinted: false,
|
||
authCapability,
|
||
pullRequest,
|
||
mergeability: {
|
||
mergeable: metadataSummary.mergeable,
|
||
mergeStateStatus: metadataSummary.mergeStateStatus,
|
||
...closeout,
|
||
},
|
||
statusChecks,
|
||
policy,
|
||
...(includeRaw ? { raw: { authStatus: auth, pullRequest, closeoutMetadata: metadataSummary } } : {}),
|
||
};
|
||
}
|
||
|
||
function repoSummary(repo: GitHubRepository): Record<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`);
|
||
}
|
||
|
||
function githubSearchLabelQualifier(label: string): string {
|
||
if (/^[A-Za-z0-9_.:-]+$/u.test(label)) return `label:${label}`;
|
||
return `label:"${label.replace(/["\\]/gu, "\\$&")}"`;
|
||
}
|
||
|
||
function issueListPaginationSummary(result: GitHubIssueListResult): Record<string, unknown> {
|
||
return {
|
||
pageSize: result.pageSize,
|
||
fetchedPages: result.fetchedPages,
|
||
exhausted: result.exhausted,
|
||
hasMore: result.hasMore,
|
||
pages: result.pages,
|
||
};
|
||
}
|
||
|
||
async function listIssues(token: string, repo: string, state: IssueListState, limit: number, search?: string, labels: string[] = []): Promise<GitHubIssueListResult | GitHubErrorPayload> {
|
||
const { owner, name } = repoParts(repo);
|
||
const normalizedSearch = String(search ?? "").trim();
|
||
const pageSize = GITHUB_REST_PAGE_SIZE;
|
||
const collected: GitHubIssue[] = [];
|
||
const pages: GitHubIssueListPage[] = [];
|
||
let rawCount = 0;
|
||
let fetchedPages = 0;
|
||
let exhausted = false;
|
||
let searchTotalCount: number | undefined;
|
||
let searchIncomplete: boolean | undefined;
|
||
const maxPages = Math.max(1, Math.ceil(limit / pageSize) + 10);
|
||
for (let page = 1; collected.length < limit && page <= maxPages; page += 1) {
|
||
const perPage = pageSize;
|
||
let path: string;
|
||
let rawIssueItems: GitHubIssue[];
|
||
if (normalizedSearch) {
|
||
const qualifiers = [`repo:${owner}/${name}`, "type:issue"];
|
||
if (state !== "all") qualifiers.push(`state:${state}`);
|
||
for (const label of labels) qualifiers.push(githubSearchLabelQualifier(label));
|
||
const params = new URLSearchParams({ q: `${normalizedSearch} ${qualifiers.join(" ")}`, per_page: String(perPage), page: String(page) });
|
||
path = `/search/issues?${params.toString()}`;
|
||
const response = await githubRequest<GitHubIssueSearchResponse>(token, "GET", path);
|
||
if (isGitHubError(response)) return response;
|
||
rawIssueItems = Array.isArray(response.items) ? response.items : [];
|
||
if (searchTotalCount === undefined) searchTotalCount = response.total_count;
|
||
searchIncomplete = searchIncomplete === true || response.incomplete_results === true;
|
||
} else {
|
||
const params = new URLSearchParams({ state, per_page: String(perPage), page: String(page) });
|
||
if (labels.length > 0) params.set("labels", labels.join(","));
|
||
path = `/repos/${owner}/${name}/issues?${params.toString()}`;
|
||
const response = await githubRequest<GitHubIssue[]>(token, "GET", path);
|
||
if (isGitHubError(response)) return response;
|
||
rawIssueItems = response;
|
||
}
|
||
const issueItems = rawIssueItems.filter((issue) => issue.pull_request === undefined);
|
||
rawCount += rawIssueItems.length;
|
||
fetchedPages += 1;
|
||
pages.push({ path, rawCount: rawIssueItems.length, issueCount: issueItems.length });
|
||
collected.push(...issueItems);
|
||
if (rawIssueItems.length < perPage) {
|
||
exhausted = true;
|
||
break;
|
||
}
|
||
}
|
||
const hasMore = collected.length > limit || !exhausted;
|
||
return {
|
||
items: collected.slice(0, limit),
|
||
rawCount,
|
||
fetchedPages,
|
||
pageSize,
|
||
exhausted,
|
||
hasMore,
|
||
pages,
|
||
searchTotalCount,
|
||
searchIncomplete,
|
||
};
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function shellWord(value: string): string {
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
function issueListNextCommand(repo: string, state: IssueListState, limit: number, search: string, labels: string[]): string {
|
||
const parts = [
|
||
"bun scripts/cli.ts gh issue list",
|
||
"--repo", shellWord(repo),
|
||
"--state", state,
|
||
"--limit", String(Math.min(limit * 2, MAX_ISSUE_LIST_LIMIT)),
|
||
];
|
||
if (search.length > 0) parts.push("--search", shellWord(search));
|
||
for (const label of labels) parts.push("--label", shellWord(label));
|
||
return parts.join(" ");
|
||
}
|
||
|
||
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string, labels: string[] = [], noDump = false): Promise<GitHubCommandResult> {
|
||
const normalizedSearch = String(search ?? "").trim();
|
||
const result = await listIssues(token, repo, state, limit, normalizedSearch, labels);
|
||
if (isGitHubError(result)) return commandError("issue list", repo, result, { state, limit, search: normalizedSearch || null, labels });
|
||
const issues = result.items;
|
||
const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice();
|
||
return {
|
||
ok: true,
|
||
command: "issue list",
|
||
repo,
|
||
...(noDump ? { noDump: true } : {}),
|
||
state,
|
||
limit,
|
||
search: normalizedSearch || null,
|
||
labels,
|
||
count: issues.length,
|
||
rawCount: result.rawCount,
|
||
searchTotalCount: result.searchTotalCount,
|
||
searchIncomplete: result.searchIncomplete,
|
||
pagination: issueListPaginationSummary(result),
|
||
hasMore: result.hasMore,
|
||
jsonFields: fields,
|
||
issues: issues.map((issue) => issueListSummary(issue, fields)),
|
||
...(result.hasMore && limit < MAX_ISSUE_LIST_LIMIT
|
||
? {
|
||
next: {
|
||
command: issueListNextCommand(repo, state, limit, normalizedSearch, labels),
|
||
note: "Increase --limit to continue scanning beyond the current bounded result set.",
|
||
},
|
||
}
|
||
: result.hasMore
|
||
? {
|
||
scanLimit: {
|
||
maxLimitReached: true,
|
||
maxLimit: MAX_ISSUE_LIST_LIMIT,
|
||
note: "The command reached the maximum bounded issue scan; narrow with --state/--label/--search before treating the repository as exhausted.",
|
||
},
|
||
}
|
||
: {}),
|
||
request: {
|
||
method: "GET",
|
||
path: normalizedSearch ? "/search/issues" : "/repos/{owner}/{repo}/issues",
|
||
query: normalizedSearch
|
||
? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}${labels.map((label) => ` ${githubSearchLabelQualifier(label)}`).join("")}`, per_page: GITHUB_REST_PAGE_SIZE }
|
||
: { state, labels, per_page: GITHUB_REST_PAGE_SIZE },
|
||
},
|
||
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>");
|
||
if (options.body !== undefined) {
|
||
return validationError("issue create", repo, "issue create does not support --body; use --body-file <file|-> for Markdown", {
|
||
title: options.title,
|
||
bodySource: "inline",
|
||
});
|
||
}
|
||
const { body, bodySource } = readMarkdownBody({
|
||
...options,
|
||
body: undefined,
|
||
}, "issue create");
|
||
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> {
|
||
if (options.bodyFile === undefined) {
|
||
return validationError(commandName, repo, `${commandName} requires --body-file <file|->`, { issueNumber });
|
||
}
|
||
let bodyInput: { body: string; bodySource: Record<string, unknown> };
|
||
try {
|
||
bodyInput = readMarkdownBodyFileOrStdin(options.bodyFile);
|
||
} catch (error) {
|
||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber });
|
||
}
|
||
const { body, bodySource } = bodyInput;
|
||
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;
|
||
if (needsReadBeforeEdit) {
|
||
const issue = await getIssue(token, repo, issueNumber);
|
||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" });
|
||
oldIssue = issue;
|
||
profileContext = { issueTitle: issue.title, issueBody: issue.body, issueMetadataFetched: true };
|
||
const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options);
|
||
if (concurrencyError !== null) return concurrencyError;
|
||
if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body);
|
||
}
|
||
const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body;
|
||
if (options.mode === "append" || needsProfileMetadata) {
|
||
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options, profileContext);
|
||
if (bodyGuard !== null) return bodyGuard;
|
||
}
|
||
if (options.dryRun) {
|
||
const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null;
|
||
const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext);
|
||
let dryRunOldBody: Record<string, unknown> = {
|
||
fetched: false,
|
||
bodyChars: null,
|
||
bodySha: null,
|
||
updatedAt: null,
|
||
reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run",
|
||
};
|
||
if (oldIssue !== null) {
|
||
const oldBody = oldIssue.body ?? "";
|
||
dryRunOldBody = {
|
||
fetched: true,
|
||
bodyChars: oldBody.length,
|
||
bodySha: bodySha(oldBody),
|
||
updatedAt: oldIssue.updated_at ?? null,
|
||
};
|
||
} else if (token.length > 0) {
|
||
const issue = await getIssue(token, repo, issueNumber);
|
||
if (isGitHubError(issue)) {
|
||
dryRunOldBody = {
|
||
fetched: false,
|
||
bodyChars: null,
|
||
bodySha: null,
|
||
updatedAt: null,
|
||
readError: {
|
||
degradedReason: issue.degradedReason,
|
||
runnerDisposition: issue.runnerDisposition,
|
||
message: issue.message,
|
||
status: issue.status ?? null,
|
||
},
|
||
};
|
||
} else {
|
||
const oldBody = issue.body ?? "";
|
||
dryRunOldBody = {
|
||
fetched: true,
|
||
bodyChars: oldBody.length,
|
||
bodySha: bodySha(oldBody),
|
||
updatedAt: issue.updated_at ?? null,
|
||
};
|
||
}
|
||
}
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
dryRun: true,
|
||
issueNumber,
|
||
mode: options.mode,
|
||
...dryRunBody(repo, options.title, finalBody),
|
||
disclosure: issueWriteDisclosure(options, repo, issueNumber, true),
|
||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||
guard,
|
||
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, bodySource, oldIssue?.body ?? null),
|
||
bodyOnlySafety: {
|
||
oldBody: dryRunOldBody,
|
||
newBody: {
|
||
bodyChars: finalBody.length,
|
||
bodySha: bodySha(finalBody),
|
||
bodyPreview: preview(finalBody),
|
||
bodyPreviewLines: previewLines(finalBody),
|
||
},
|
||
},
|
||
concurrency: {
|
||
dryRunNoWrite: true,
|
||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||
expectBodySha: options.expectBodySha ?? null,
|
||
note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH",
|
||
},
|
||
wouldPatch: {
|
||
issueNumber,
|
||
title: options.title ?? null,
|
||
bodySource,
|
||
mode: options.mode,
|
||
bodyChars: finalBody.length,
|
||
bodySha: bodySha(finalBody),
|
||
},
|
||
...(options.notifyClaudeQqBriefDiff
|
||
? {
|
||
commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, finalBody, dryRunDiff ?? commanderBriefDiff("", finalBody), claudeQqConfig),
|
||
dryRunOldBodySource: "not-fetched",
|
||
dryRunDiffReliability: "new-body-only preview; non-dry-run reads the GitHub issue body before PATCH and sends only sections absent from the old body",
|
||
dryRunNoWrite: true,
|
||
dryRunNoClaudeQqSend: true,
|
||
}
|
||
: {}),
|
||
};
|
||
}
|
||
const { owner, name } = repoParts(repo);
|
||
const payload: Record<string, unknown> = { body: finalBody };
|
||
if (options.title !== undefined) payload.title = options.title;
|
||
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload);
|
||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
|
||
const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext);
|
||
const concurrency = oldIssue === null
|
||
? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }
|
||
: { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null };
|
||
if (!options.notifyClaudeQqBriefDiff) {
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
|
||
mode: options.mode,
|
||
bodySource,
|
||
guard,
|
||
concurrency,
|
||
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
|
||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody);
|
||
const claudeqq = diff.ok
|
||
? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message)
|
||
: {
|
||
ok: true,
|
||
attempted: false,
|
||
skipped: true,
|
||
skippedReason: diff.skippedReason ?? "no commander brief diff to send",
|
||
target: maskedTarget(claudeQqConfig),
|
||
} satisfies ClaudeQqSendResult;
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
|
||
guard,
|
||
concurrency,
|
||
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
|
||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||
rest: true,
|
||
commanderBriefNotification: {
|
||
issueNumber,
|
||
oldIssueUpdatedAt: oldIssue?.updated_at ?? null,
|
||
diff: {
|
||
ok: diff.ok,
|
||
mode: diff.mode,
|
||
chars: diff.chars,
|
||
sectionCount: diff.sectionCount,
|
||
skippedReason: diff.skippedReason ?? null,
|
||
preview: diff.ok ? preview(diff.message) : "",
|
||
previewLines: diff.ok ? previewLines(diff.message) : [],
|
||
containsLiteralBackslashN: diff.message.includes("\\n"),
|
||
containsBackticks: diff.message.includes("`"),
|
||
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message),
|
||
},
|
||
},
|
||
claudeqq,
|
||
};
|
||
}
|
||
|
||
async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
let bodyInput: { body: string; bodySource: Record<string, unknown> };
|
||
try {
|
||
bodyInput = readIssueCommentBody(options);
|
||
} catch (error) {
|
||
return validationError("issue comment create", repo, error instanceof Error ? error.message : String(error), { issueNumber });
|
||
}
|
||
const { body, bodySource } = bodyInput;
|
||
if (options.dryRun) {
|
||
return {
|
||
ok: true,
|
||
command: "issue comment create",
|
||
repo,
|
||
dryRun: true,
|
||
planned: true,
|
||
issueNumber,
|
||
readCommands: issueCommentReadCommands(repo, issueNumber),
|
||
...writeBodyPlan("issue comment create", repo, body, bodySource, { issueNumber }),
|
||
};
|
||
}
|
||
const { owner, name } = repoParts(repo);
|
||
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
||
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
|
||
return {
|
||
ok: true,
|
||
command: "issue comment create",
|
||
repo,
|
||
issueNumber,
|
||
comment: compactCommentSummary(comment),
|
||
bodySource,
|
||
bodyChars: body.length,
|
||
bodySha: bodySha(body),
|
||
bodyPreview: preview(body),
|
||
source: String(bodySource.kind ?? "unknown"),
|
||
readCommands: issueCommentReadCommands(repo, issueNumber),
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> {
|
||
const command = `${ownerKind} comment delete`;
|
||
const { owner, name } = repoParts(repo);
|
||
if (dryRun) {
|
||
return {
|
||
ok: true,
|
||
command,
|
||
repo,
|
||
dryRun: true,
|
||
planned: true,
|
||
commentId,
|
||
request: { method: "DELETE", path: `/repos/{owner}/{repo}/issues/comments/${commentId}` },
|
||
};
|
||
}
|
||
const result = await githubRequest<null>(token, "DELETE", `/repos/${owner}/${name}/issues/comments/${commentId}`);
|
||
if (isGitHubError(result)) return commandError(command, repo, result, { commentId });
|
||
return {
|
||
ok: true,
|
||
command,
|
||
repo,
|
||
commentId,
|
||
deleted: true,
|
||
request: { method: "DELETE", path: `/repos/${owner}/${name}/issues/comments/${commentId}` },
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean, options?: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const command = state === "closed" ? "issue close" : "issue reopen";
|
||
let lifecycleComment: { body: string; bodySource: Record<string, unknown> } | null = null;
|
||
try {
|
||
lifecycleComment = options === undefined ? null : readIssueLifecycleCommentBody(options, command);
|
||
} catch (error) {
|
||
return validationError(command, repo, error instanceof Error ? error.message : String(error), { issueNumber });
|
||
}
|
||
if (dryRun) {
|
||
return {
|
||
ok: true,
|
||
command,
|
||
dryRun: true,
|
||
repo,
|
||
issueNumber,
|
||
disclosure: issueLifecycleDisclosure(repo, issueNumber, true),
|
||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||
comment: lifecycleComment === null
|
||
? null
|
||
: {
|
||
planned: true,
|
||
...writeBodyPlan("issue comment create", repo, lifecycleComment.body, lifecycleComment.bodySource, { issueNumber }),
|
||
},
|
||
wouldPatch: { state },
|
||
};
|
||
}
|
||
const { owner, name } = repoParts(repo);
|
||
let commentSummary: Record<string, unknown> | null = null;
|
||
if (lifecycleComment !== null) {
|
||
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body: lifecycleComment.body });
|
||
if (isGitHubError(comment)) {
|
||
return commandError(command, repo, comment, {
|
||
issueNumber,
|
||
phase: "comment",
|
||
commentBodySource: lifecycleComment.bodySource,
|
||
commentBodyChars: lifecycleComment.body.length,
|
||
commentBodySha: bodySha(lifecycleComment.body),
|
||
});
|
||
}
|
||
commentSummary = compactCommentSummary(comment);
|
||
}
|
||
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state });
|
||
if (isGitHubError(issue)) return commandError(command, repo, issue, { issueNumber, phase: "state", comment: commentSummary });
|
||
return {
|
||
ok: true,
|
||
command,
|
||
repo,
|
||
comment: commentSummary,
|
||
issue: issueLifecycleSummary(issue),
|
||
disclosure: issueLifecycleDisclosure(repo, issueNumber, false),
|
||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||
rest: true,
|
||
};
|
||
}
|
||
|
||
function parseGitHubTimestamp(value: string | undefined): number | null {
|
||
if (value === undefined) return null;
|
||
const millis = Date.parse(value);
|
||
return Number.isFinite(millis) ? millis : null;
|
||
}
|
||
|
||
function inactiveIssueCandidates(issues: GitHubIssue[], cutoffMs: number): GitHubIssue[] {
|
||
return issues.filter((issue) => {
|
||
const updatedAtMs = parseGitHubTimestamp(issue.updated_at);
|
||
return updatedAtMs !== null && updatedAtMs < cutoffMs;
|
||
});
|
||
}
|
||
|
||
async function closeIssueForBatch(repo: string, token: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
|
||
const { owner, name } = repoParts(repo);
|
||
return githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state: "closed" });
|
||
}
|
||
|
||
async function issueStaleClose(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||
const command = "issue stale-close";
|
||
const observedAt = new Date();
|
||
const cutoffMs = observedAt.getTime() - Math.round(options.inactiveHours * 60 * 60 * 1000);
|
||
const cutoffAt = new Date(cutoffMs).toISOString();
|
||
const result = await listIssues(token, repo, "open", options.limit, "", options.labels);
|
||
if (isGitHubError(result)) {
|
||
return commandError(command, repo, result, {
|
||
state: "open",
|
||
limit: options.limit,
|
||
inactiveHours: options.inactiveHours,
|
||
cutoffAt,
|
||
labels: options.labels,
|
||
});
|
||
}
|
||
const staleIssues = inactiveIssueCandidates(result.items, cutoffMs);
|
||
const base = {
|
||
ok: true,
|
||
command,
|
||
repo,
|
||
dryRun: options.dryRun,
|
||
state: "open",
|
||
inactiveHours: options.inactiveHours,
|
||
observedAt: observedAt.toISOString(),
|
||
cutoffAt,
|
||
limit: options.limit,
|
||
labels: options.labels,
|
||
scannedCount: result.items.length,
|
||
rawCount: result.rawCount,
|
||
staleCount: staleIssues.length,
|
||
pagination: issueListPaginationSummary(result),
|
||
hasMore: result.hasMore,
|
||
stale: issueLifecycleBatchSummary(staleIssues),
|
||
policy: {
|
||
basis: "GitHub issue updatedAt",
|
||
selectedWhen: "updatedAt is older than observedAt - inactiveHours",
|
||
commentsAndStateChangesCountAsActivity: true,
|
||
pullRequestsFiltered: true,
|
||
},
|
||
readCommands: {
|
||
dryRun: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${options.limit} --dry-run`,
|
||
openList: `bun scripts/cli.ts gh issue list --repo ${repo} --state open --limit ${options.limit} --json number,title,state,url,updatedAt`,
|
||
},
|
||
...(result.hasMore && options.limit < MAX_ISSUE_LIST_LIMIT
|
||
? {
|
||
next: {
|
||
command: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${Math.min(options.limit * 2, MAX_ISSUE_LIST_LIMIT)} --dry-run`,
|
||
note: "The scan reached the bounded --limit before GitHub pagination was exhausted; increase --limit before treating the cleanup as complete.",
|
||
},
|
||
}
|
||
: result.hasMore
|
||
? {
|
||
scanLimit: {
|
||
maxLimitReached: true,
|
||
maxLimit: MAX_ISSUE_LIST_LIMIT,
|
||
note: "The cleanup reached the maximum bounded issue scan; narrow with --label or split the policy before treating it as complete.",
|
||
},
|
||
}
|
||
: {}),
|
||
};
|
||
if (options.dryRun) {
|
||
return {
|
||
...base,
|
||
planned: true,
|
||
wouldCloseCount: staleIssues.length,
|
||
wouldCloseNumbers: staleIssues.map((issue) => issue.number),
|
||
note: staleIssues.length === 0
|
||
? "No open issues matched the inactive-hours policy; no GitHub issue would be closed."
|
||
: "Dry-run only; no GitHub issue was closed.",
|
||
};
|
||
}
|
||
|
||
const closed: GitHubIssue[] = [];
|
||
const failures: Array<Record<string, unknown>> = [];
|
||
for (const issue of staleIssues) {
|
||
const closedIssue = await closeIssueForBatch(repo, token, issue.number);
|
||
if (isGitHubError(closedIssue)) {
|
||
failures.push({
|
||
number: issue.number,
|
||
title: issue.title,
|
||
url: issue.html_url,
|
||
updatedAt: issue.updated_at ?? null,
|
||
degradedReason: closedIssue.degradedReason,
|
||
runnerDisposition: closedIssue.runnerDisposition,
|
||
message: closedIssue.message,
|
||
status: closedIssue.status ?? null,
|
||
});
|
||
continue;
|
||
}
|
||
closed.push(closedIssue);
|
||
}
|
||
|
||
return {
|
||
...base,
|
||
dryRun: false,
|
||
planned: false,
|
||
closedCount: closed.length,
|
||
failedCount: failures.length,
|
||
closed: issueLifecycleBatchSummary(closed),
|
||
failures,
|
||
rest: true,
|
||
ok: failures.length === 0,
|
||
...(failures.length > 0
|
||
? {
|
||
degradedReason: "github-transient" as GitHubDegradedReason,
|
||
runnerDisposition: "infra-blocked" as RunnerDisposition,
|
||
retryable: true,
|
||
}
|
||
: {}),
|
||
note: failures.length === 0
|
||
? "Closed all open issues that matched the inactive-hours policy."
|
||
: "Some stale issue close operations failed; rerun the same command after checking failures.",
|
||
};
|
||
}
|
||
|
||
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 issues = await listIssues(token, repo, "all", limit);
|
||
if (isGitHubError(issues)) return commandError(commandName, repo, issues);
|
||
const issueOnly = issues.items;
|
||
|
||
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.rawCount,
|
||
pagination: issueListPaginationSummary(issues),
|
||
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, noDump = false): 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,
|
||
...(noDump ? { noDump: true } : {}),
|
||
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 view ${number} --repo ${repo} --json body,title,state,head,base`;
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
readOnly: true,
|
||
rawDiffIncluded: false,
|
||
pullRequest: prCompactSummary(pr),
|
||
summary: {
|
||
files: totalFiles ?? files.length,
|
||
additions: totalAdditions ?? listedStats.additions,
|
||
deletions: totalDeletions ?? listedStats.deletions,
|
||
changes: totalChanges,
|
||
commits: numberOrNull(pr.commits),
|
||
source: fullStatsAvailable && totalFiles !== null ? "pull-request-rest" : "listed-files-rest",
|
||
},
|
||
files: files.map(prFileSummary),
|
||
filesReturned: files.length,
|
||
limit: boundedLimit,
|
||
truncation: {
|
||
truncated,
|
||
requestedLimit: limit,
|
||
appliedLimit: boundedLimit,
|
||
returned: files.length,
|
||
totalFiles,
|
||
maxLimit: MAX_PR_FILES_LIMIT,
|
||
},
|
||
next: {
|
||
command: nextCommand,
|
||
purpose: truncated ? "Fetch a larger bounded file summary page." : "Fetch full PR metadata/body; raw diffs remain intentionally excluded.",
|
||
},
|
||
request: {
|
||
method: "GET",
|
||
paths: [
|
||
`/repos/${owner}/${name}/pulls/${number}`,
|
||
`/repos/${owner}/${name}/pulls/${number}/files`,
|
||
],
|
||
},
|
||
};
|
||
}
|
||
|
||
async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read", disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
|
||
const { owner, name } = repoParts(repo);
|
||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
|
||
const summary = prSummary(pr);
|
||
const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null;
|
||
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [], closeoutMetadata: prCloseoutMetadataError(metadata) });
|
||
const selectionSummary = metadata === null ? summary : { ...summary, ...prMetadataSummary(metadata) };
|
||
return {
|
||
ok: true,
|
||
command: commandName,
|
||
repo,
|
||
...(disclosure === null ? {} : { disclosure }),
|
||
pullRequest: summary,
|
||
...(metadata === null ? {} : { closeoutMetadata: prCloseoutMetadata(metadata) }),
|
||
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(selectionSummary, jsonFields) }),
|
||
};
|
||
}
|
||
|
||
async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
|
||
return prRead(repo, token, number, jsonFields, "pr view", disclosure);
|
||
}
|
||
|
||
export function ghHelp(): unknown {
|
||
return {
|
||
command: "gh",
|
||
output: "json",
|
||
usage: [
|
||
"bun scripts/cli.ts gh auth status [--repo owner/name]",
|
||
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]",
|
||
"bun scripts/cli.ts gh issue view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments] [--raw|--full]",
|
||
"bun scripts/cli.ts gh issue read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]",
|
||
"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] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]",
|
||
"bun scripts/cli.ts gh issue edit <number> --body-file <file|-> [--repo owner/name] [--number N compat] [--full|--raw] [compat alias for issue update --mode replace]",
|
||
"bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]",
|
||
"bun scripts/cli.ts gh issue comment create <number> --body-file <file|->|--body <short-text> [--repo owner/name] [--number N compat] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment <short-text>|--comment-file <file|->] [--dry-run]",
|
||
"bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--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] [--number N compat] --board-issue 20",
|
||
"bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] [--number N compat] --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] [--number N compat] --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] [--number N compat] --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] [--number N compat] --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] [--number N compat] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
|
||
"bun scripts/cli.ts gh preflight <prNumber|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [compatibility alias for gh pr preflight]",
|
||
"bun scripts/cli.ts gh pr list [owner/repo] [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
|
||
"bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]",
|
||
"bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]",
|
||
"bun scripts/cli.ts gh pr view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--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 read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for pr view]",
|
||
"bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options]",
|
||
"bun scripts/cli.ts gh pr closeout <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]",
|
||
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--number N compat] [--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] [--number N compat] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--number N compat] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]",
|
||
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
|
||
],
|
||
defaults: { repo: DEFAULT_REPO },
|
||
notes: [
|
||
"Issue and PR create/read/update/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
|
||
"Token values are never printed; auth status reports only token source and presence.",
|
||
"issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.",
|
||
"issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,closed,closedAt,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 view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus 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 list/read/view/update/edit and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
|
||
"GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.",
|
||
"issue create accepts --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. 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 accepts files or - for stdin, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).",
|
||
"issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.",
|
||
"issue comment create accepts --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.",
|
||
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text> or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-file is the recommended path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view when full text is needed.",
|
||
"issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.",
|
||
"For one-shot issue writes, pipe reviewed Markdown through stdin: cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - or gh issue comment create <number> --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/PR Markdown writes use --body-file <file|-> for long or multiline content.",
|
||
"issue scan-escape classifies literal \\n findings as suspected-pollution, explanatory-mention, or risk, and emits cleanupSuggestions with body/comment ids plus diff-like previews. cleanup-plan is an alias that remains dry-run/read-only.",
|
||
"issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It reads only the board issue body, returns body size/SHA and parsed Markdown board sections, and no longer validates GitHub open/closed issue coverage against OPEN/CLOSED tables. The legacy coverage fields remain present as empty arrays/zero counts for compatibility.",
|
||
"issue board-row list/get reuse the board-audit table parser and are read-only. board-row update changes one table cell by issue number, returns old/new row, body SHA, body guard and request plan, and defaults to dry-run unless --expect-updated-at or --expect-body-sha is supplied for the guarded PATCH. Field aliases map status and validation to the 验收状态 column, tasks to 相关 Code Queue 任务, and focus to 当前关注点.",
|
||
"issue board-row upsert updates an existing row when the issue is already present, or generates a complete row in --section open|closed when missing. It returns operation=update or operation=add, defaults to dry-run, requires --expect-body-sha or --expect-updated-at before PATCH, and refuses section migration; use board-row move for OPEN/CLOSED migration.",
|
||
"issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation, and the shared #20 guard warns about HWLAB product/user issue routing in favor of pikasTech/HWLAB without refusing the write.",
|
||
"issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
|
||
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
|
||
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
|
||
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
|
||
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
|
||
"PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment delete treats --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
|
||
"PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.",
|
||
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
|
||
"PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. Use --full or --raw to include all fetched status contexts; gh pr merge is the separate guarded write path.",
|
||
"PR merge is a guarded write operation: it first reads closeout metadata, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Use --dry-run to see the exact merge plan without writing.",
|
||
],
|
||
};
|
||
}
|
||
|
||
export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> {
|
||
const [top, sub, third] = args;
|
||
if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp();
|
||
let options: GitHubOptions;
|
||
try {
|
||
options = parseOptions(args);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
const repo = optionValue(args, "--repo") ?? DEFAULT_REPO;
|
||
const unknownOption = /^unknown gh option:\s+(.+)$/u.exec(message)?.[1];
|
||
return unknownOption !== undefined
|
||
? unsupportedCommand(command, repo, message, unknownGhOptionDetails(args, unknownOption))
|
||
: validationError(command, repo, message);
|
||
}
|
||
if (options.notifyClaudeQqBriefDiff && !(top === "issue" && sub === "edit")) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--notify-claudeqq-brief-diff is only supported by gh issue edit 24");
|
||
}
|
||
if (optionWasProvided(args, "--state") && !(top === "issue" && (sub === "list" || sub === "board-row" && third === "list") || top === "pr" && sub === "list")) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--state is only supported by gh issue list, gh issue board-row list, and gh pr list");
|
||
}
|
||
if (optionWasProvided(args, "--json") && !(top === "issue" && (isIssueReadCommand(sub) || sub === "list"))) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
if (!(top === "pr" && (isPrReadCommand(sub) || sub === "list"))) {
|
||
return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list");
|
||
}
|
||
}
|
||
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "list" || sub === "update" || sub === "edit")) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "list" || sub === "preflight" || sub === "closeout")))) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit, gh pr list/read/view, and gh pr preflight/closeout.", {
|
||
supportedCommands: [
|
||
"bun scripts/cli.ts gh issue list --repo owner/name --limit 200 --full",
|
||
"bun scripts/cli.ts gh issue view owner/name#<number> --raw",
|
||
"bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state,comments",
|
||
"cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full",
|
||
"bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full",
|
||
"bun scripts/cli.ts gh pr view owner/name#<number> --raw",
|
||
`bun scripts/cli.ts gh pr view <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
|
||
"bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full",
|
||
],
|
||
});
|
||
}
|
||
if (optionWasProvided(args, "--stat") && !(top === "pr" && (sub === "diff" || sub === "files"))) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--stat is only supported by gh pr diff <number> --stat.", {
|
||
supportedCommands: [
|
||
"bun scripts/cli.ts gh pr files <number> --repo owner/name --limit 30",
|
||
"bun scripts/cli.ts gh pr diff <number> --stat --repo owner/name --limit 30",
|
||
],
|
||
});
|
||
}
|
||
if (optionWasProvided(args, "--number") && !allowsNumberTargetAlias(top, sub, third)) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
const standardViewCommand = top === "issue" || top === "pr" ? `gh ${top} view` : "gh issue/pr view";
|
||
return validationError(command, options.repo, `--number is only a compatibility alias for single numeric target commands; standard ${standardViewCommand} uses a positional number or URL target.`, {
|
||
supportedCommands: [
|
||
"bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state",
|
||
"bun scripts/cli.ts gh issue view https://github.com/owner/name/issues/<number> --raw",
|
||
"bun scripts/cli.ts gh pr view <number> --repo owner/name --json body,title,state,head,base",
|
||
"bun scripts/cli.ts gh pr view https://github.com/owner/name/pull/<number> --raw",
|
||
],
|
||
rejectedOption: "--number",
|
||
});
|
||
}
|
||
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" || sub === "list" || sub === "stale-close"))) {
|
||
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, gh issue list, and gh issue stale-close");
|
||
}
|
||
if (optionWasProvided(args, "--search") && !(top === "issue" && sub === "list")) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--search is only supported by gh issue list");
|
||
}
|
||
if (optionWasProvided(args, "--inactive-hours") && !(top === "issue" && sub === "stale-close")) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--inactive-hours is only supported by gh issue stale-close");
|
||
}
|
||
if (optionWasProvided(args, "--comment") && !(top === "issue" && (sub === "close" || sub === "reopen"))) {
|
||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||
return validationError(command, options.repo, "--comment/--comment-file is only supported by gh issue close/reopen; use gh issue comment create for standalone comments");
|
||
}
|
||
|
||
if (top === "auth" && sub === "status") return authStatus(options.repo);
|
||
|
||
if (top === "preflight") {
|
||
const resolved = resolvePositionalPrReference(args, 1, "preflight", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw), resolved);
|
||
}
|
||
|
||
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 resolved = resolvePositionalNumberReference("issue", args, 3, "issue comment delete", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
const commentId = resolved.number;
|
||
if (typeof commentId !== "number") return commentId;
|
||
if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "issue", commentId, true), resolved);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.repo, "issue comment delete", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true });
|
||
return withNumberOptionHint(commentDelete(resolved.repo, token, "issue", commentId, false), resolved);
|
||
}
|
||
if (sub === "comment" && third === "create") {
|
||
const resolved = resolvePositionalIssueReference(args, 3, "issue comment create", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
const issueNumber = resolved.number;
|
||
if (typeof issueNumber !== "number") return issueNumber;
|
||
if (options.dryRun) return withNumberOptionHint(issueComment(resolved.repo, "", issueNumber, { ...options, repo: resolved.repo }), resolved);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.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 withNumberOptionHint(issueComment(resolved.repo, token, issueNumber, { ...options, repo: resolved.repo }), resolved);
|
||
}
|
||
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 resolvedBoardRow = resolvePositionalIssueReference(args, 3, commandName, options);
|
||
if (isGitHubCommandResult(resolvedBoardRow)) return resolvedBoardRow;
|
||
const issueNumber = resolvedBoardRow.number;
|
||
if (typeof issueNumber !== "number") return issueNumber;
|
||
const boardRowOptions = { ...options, repo: resolvedBoardRow.repo };
|
||
if (action === "get") return withNumberOptionHint(issueBoardRowGet(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
|
||
if (action === "update") return withNumberOptionHint(issueBoardRowUpdate(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
|
||
if (action === "add") return withNumberOptionHint(issueBoardRowAdd(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
|
||
if (action === "upsert") return withNumberOptionHint(issueBoardRowUpsert(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
|
||
if (action === "move") return withNumberOptionHint(issueBoardRowMove(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
|
||
return withNumberOptionHint(issueBoardRowDelete(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
|
||
}
|
||
if (options.dryRun) {
|
||
if (sub === "create") return issueCreate(options.repo, "", options);
|
||
if (sub === "edit") {
|
||
const issueEditRef = resolvePositionalIssueReference(args, 2, "issue edit", options);
|
||
if (isGitHubCommandResult(issueEditRef)) return issueEditRef;
|
||
const issueNumber = issueEditRef.number;
|
||
const { token } = resolveToken(false);
|
||
return withNumberOptionHint(issueEdit(issueEditRef.repo, token ?? "", issueNumber, { ...options, repo: issueEditRef.repo }), issueEditRef);
|
||
}
|
||
if (sub === "update") {
|
||
const issueUpdateRef = resolvePositionalIssueReference(args, 2, "issue update", options);
|
||
if (isGitHubCommandResult(issueUpdateRef)) return issueUpdateRef;
|
||
const issueNumber = issueUpdateRef.number;
|
||
const { token } = resolveToken(false);
|
||
return withNumberOptionHint(issueEdit(issueUpdateRef.repo, token ?? "", issueNumber, { ...options, repo: issueUpdateRef.repo }, "issue update"), issueUpdateRef);
|
||
}
|
||
if (sub === "comment") {
|
||
const r = resolvePositionalIssueReference(args, 2, "issue comment", options);
|
||
if (isGitHubCommandResult(r)) return r;
|
||
return withNumberOptionHint(issueComment(r.repo, "", r.number, { ...options, repo: r.repo }), r);
|
||
}
|
||
if (sub === "close") {
|
||
const r = resolvePositionalIssueReference(args, 2, "issue close", options);
|
||
if (isGitHubCommandResult(r)) return r;
|
||
return withNumberOptionHint(issueState(r.repo, "", r.number, "closed", true, { ...options, repo: r.repo }), r);
|
||
}
|
||
if (sub === "reopen") {
|
||
const r = resolvePositionalIssueReference(args, 2, "issue reopen", options);
|
||
if (isGitHubCommandResult(r)) return r;
|
||
return withNumberOptionHint(issueState(r.repo, "", r.number, "open", true, { ...options, repo: r.repo }), r);
|
||
}
|
||
}
|
||
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 withNumberOptionHint(issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure), resolved);
|
||
return withNumberOptionHint(issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure), resolved);
|
||
}
|
||
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, options.search, options.labels, options.raw || options.full);
|
||
if (sub === "stale-close") return issueStaleClose(options.repo, token, options);
|
||
if (sub === "create") return issueCreate(options.repo, token, options);
|
||
if (sub === "edit") {
|
||
const r = resolvePositionalIssueReference(args, 2, "issue edit", options);
|
||
if (isGitHubCommandResult(r)) return r;
|
||
return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }), r);
|
||
}
|
||
if (sub === "update") {
|
||
const r = resolvePositionalIssueReference(args, 2, "issue update", options);
|
||
if (isGitHubCommandResult(r)) return r;
|
||
return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }, "issue update"), r);
|
||
}
|
||
if (sub === "comment") {
|
||
const r = resolvePositionalIssueReference(args, 2, "issue comment", options);
|
||
if (isGitHubCommandResult(r)) return r;
|
||
return withNumberOptionHint(issueComment(r.repo, token, r.number, { ...options, repo: r.repo }), r);
|
||
}
|
||
if (sub === "close") {
|
||
const r = resolvePositionalIssueReference(args, 2, "issue close", options);
|
||
if (isGitHubCommandResult(r)) return r;
|
||
return withNumberOptionHint(issueState(r.repo, token, r.number, "closed", options.dryRun, { ...options, repo: r.repo }), r);
|
||
}
|
||
if (sub === "reopen") {
|
||
const r = resolvePositionalIssueReference(args, 2, "issue reopen", options);
|
||
if (isGitHubCommandResult(r)) return r;
|
||
return withNumberOptionHint(issueState(r.repo, token, r.number, "open", options.dryRun, { ...options, repo: r.repo }), r);
|
||
}
|
||
}
|
||
|
||
if (top === "pr") {
|
||
if (sub === "diff") {
|
||
const resolved = resolvePositionalPrReference(args, 2, "pr diff", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
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 ${resolved.number} --repo ${resolved.repo} --limit 30`,
|
||
`bun scripts/cli.ts gh pr diff ${resolved.number} --stat --repo ${resolved.repo} --limit 30`,
|
||
],
|
||
});
|
||
}
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.repo, "pr diff --stat", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true });
|
||
return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr diff --stat"), resolved);
|
||
}
|
||
if (sub === "files") {
|
||
const resolved = resolvePositionalPrReference(args, 2, "pr files", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.repo, "pr files", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr files", { present: false, source: null, ghFallbackAttempted: true });
|
||
return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr files"), resolved);
|
||
}
|
||
if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics.");
|
||
if (sub === "preflight" || sub === "closeout") {
|
||
const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw), resolved);
|
||
}
|
||
if (sub === "comment" && third === "delete") {
|
||
const resolved = resolvePositionalNumberReference("pr", args, 3, "pr comment delete", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "pr", resolved.number, true), resolved);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.repo, "pr comment delete", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true });
|
||
return withNumberOptionHint(commentDelete(resolved.repo, token, "pr", resolved.number, false), resolved);
|
||
}
|
||
if (sub === "comment" && third === "create") {
|
||
const resolved = resolvePositionalPrReference(args, 3, "pr comment create", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
if (options.dryRun) return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.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 withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved);
|
||
}
|
||
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 resolved = resolvePositionalPrReference(args, 2, "pr comment", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved);
|
||
}
|
||
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 resolved = resolvePositionalPrReference(args, 2, "pr comment", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved);
|
||
}
|
||
if (sub === "update" || sub === "edit") {
|
||
const commandName = `pr ${sub}`;
|
||
const resolved = resolvePositionalPrReference(args, 2, commandName, options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
if (options.dryRun && options.mode === "replace") return withNumberOptionHint(prUpdate(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }, commandName), resolved);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.repo, commandName, probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
|
||
return withNumberOptionHint(prUpdate(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }, commandName), resolved);
|
||
}
|
||
if (sub === "close" || sub === "reopen") {
|
||
const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
if (options.dryRun) return withNumberOptionHint(prState(resolved.repo, "", resolved.number, sub === "close" ? "closed" : "open", true), resolved);
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(resolved.repo, `pr ${sub}`, probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||
return withNumberOptionHint(prState(resolved.repo, token, resolved.number, sub === "close" ? "closed" : "open", false), resolved);
|
||
}
|
||
if (sub === "merge") {
|
||
const resolved = resolvePositionalPrReference(args, 2, "pr merge", options);
|
||
if (isGitHubCommandResult(resolved)) return resolved;
|
||
const { token, probe } = resolveToken(true);
|
||
const missing = authRequired(options.repo, "pr merge", probe);
|
||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr merge", { present: false, source: null, ghFallbackAttempted: true });
|
||
return withNumberOptionHint(prMerge(resolved.repo, token, resolved.number, options), resolved);
|
||
}
|
||
if (sub !== "list" && !isPrReadCommand(sub)) {
|
||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/delete, and unsupported 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 withNumberOptionHint(prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure), resolved);
|
||
return withNumberOptionHint(prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure), resolved);
|
||
}
|
||
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, options.raw || options.full);
|
||
}
|
||
|
||
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });
|
||
}
|