fix: harden gh issue body updates

This commit is contained in:
Codex
2026-05-20 10:54:40 +00:00
parent 845b0aa5d7
commit 8f2dc6d33c
4 changed files with 576 additions and 22 deletions
+337 -18
View File
@@ -1,15 +1,35 @@
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 = "https://api.github.com";
const GITHUB_API = (process.env.UNIDESK_GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/u, "");
const USER_AGENT = "unidesk-cli-gh";
const PREVIEW_CHARS = 240;
const REQUEST_TIMEOUT_MS = 20_000;
const MIN_SAFE_ISSUE_BODY_CHARS = 20;
const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL = "http://backend-core:8080/api/microservices/claudeqq/proxy";
const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID = "645275593";
const CODE_QUEUE_BOARD_TARGET_ISSUE = 20;
const COMMANDER_BRIEF_TARGET_ISSUE = 24;
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_BODY_PROFILES = {
"code-queue-board": {
label: "Code Queue long board issue #20",
issueNumber: CODE_QUEUE_BOARD_TARGET_ISSUE,
requiredHeadings: ["## 看板(OPEN"],
},
"commander-brief": {
label: "Commander brief body-only issue #24",
issueNumber: COMMANDER_BRIEF_TARGET_ISSUE,
requiredHeadings: ["## 常驻观察与长期建议"],
},
} as const;
type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number];
type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES;
type IssueBodyProfileOption = "auto" | IssueBodyProfileName;
type GitHubDegradedReason =
| "missing-binary"
@@ -102,11 +122,16 @@ interface GitHubOptions {
limit: number;
draft: boolean;
notifyClaudeQqBriefDiff: boolean;
allowShortBody: boolean;
title?: string;
body?: string;
bodyFile?: string;
base?: string;
head?: string;
jsonFields?: IssueViewJsonField[];
expectUpdatedAt?: string;
expectBodySha?: string;
bodyProfile: IssueBodyProfileOption;
}
interface GitHubErrorPayload {
@@ -198,6 +223,18 @@ 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 positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const raw = optionValue(args, name);
if (raw === undefined) return defaultValue;
@@ -206,9 +243,26 @@ function positiveIntegerOption(args: string[], name: string, defaultValue: numbe
return Math.min(value, maxValue);
}
function parseIssueViewJsonFields(args: string[]): IssueViewJsonField[] | undefined {
const requested = commaListOption(args, "--json");
if (requested === undefined) return undefined;
const allowed = new Set<string>(ISSUE_VIEW_JSON_FIELDS);
const unsupported = requested.filter((field) => !allowed.has(field));
if (unsupported.length > 0) {
throw new Error(`unsupported gh issue view --json field(s): ${unsupported.join(", ")}; supported fields: ${ISSUE_VIEW_JSON_FIELDS.join(",")}`);
}
return requested as IssueViewJsonField[];
}
function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
const raw = optionValue(args, "--body-profile") ?? "auto";
if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw;
throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`);
}
function validateKnownOptions(args: string[]): void {
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head"]);
const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff"]);
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--expect-updated-at", "--expect-body-sha", "--body-profile"]);
const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body"]);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) continue;
@@ -229,11 +283,16 @@ function parseOptions(args: string[]): GitHubOptions {
limit: positiveIntegerOption(args, "--limit", 30, 100),
draft: hasFlag(args, "--draft"),
notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"),
allowShortBody: hasFlag(args, "--allow-short-body"),
title: optionValue(args, "--title"),
body: optionValue(args, "--body"),
bodyFile: optionValue(args, "--body-file"),
base: optionValue(args, "--base"),
head: optionValue(args, "--head"),
jsonFields: parseIssueViewJsonFields(args),
expectUpdatedAt: optionValue(args, "--expect-updated-at"),
expectBodySha: optionValue(args, "--expect-body-sha"),
bodyProfile: parseIssueBodyProfile(args),
};
}
@@ -332,17 +391,51 @@ function previewLines(text: string, maxLines = 12): string[] {
return text.split(/\r?\n/).slice(0, maxLines);
}
function dryRunBody(repo: string, title: string | undefined, body: string): Record<string, unknown> {
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 {
repo,
...(title === undefined ? {} : { title }),
bodyChars: body.length,
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
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),
};
}
@@ -807,6 +900,138 @@ function issueSummary(issue: GitHubIssue): Record<string, unknown> {
};
}
function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null {
if (requested !== "auto") return requested;
if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board";
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return "commander-brief";
return null;
}
function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption): Record<string, unknown> {
const profileName = issueProfileFor(issueNumber, requested);
if (profileName === null) return { profile: null, applied: false, requiredHeadings: [], missingHeadings: [], ok: true };
const profile = ISSUE_BODY_PROFILES[profileName];
const issueMatchesProfile = issueNumber === profile.issueNumber;
const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading));
return {
profile: profileName,
label: profile.label,
applied: true,
expectedIssueNumber: profile.issueNumber,
issueMatchesProfile,
requiredHeadings: profile.requiredHeadings,
missingHeadings,
ok: issueMatchesProfile && missingHeadings.length === 0,
};
}
function validationBoolean(record: Record<string, unknown>, key: string): boolean {
return record[key] === true;
}
function validationStringArray(record: Record<string, unknown>, key: string): string[] {
const value = record[key];
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions): GitHubCommandResult | null {
const trimmed = body.trim();
const isLiteralNull = trimmed.toLowerCase() === "null";
const isBlank = trimmed.length === 0;
const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS;
const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile);
const profileOk = validationBoolean(profileValidation, "ok");
const failures: string[] = [];
if (isLiteralNull) failures.push("literal-null-body");
if (isBlank) failures.push("blank-body");
if (isShort && !options.allowShortBody) failures.push("short-body");
if (!profileOk) {
if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch");
const missingHeadings = validationStringArray(profileValidation, "missingHeadings");
if (missingHeadings.length > 0) failures.push("profile-heading-missing");
}
if (failures.length === 0) return null;
const overrideAvailable = failures.every((failure) => failure === "short-body");
const message = overrideAvailable
? "issue edit body is shorter than the safe default; pass --allow-short-body only for intentional short writes"
: "issue edit body failed safety guard; refusing to write possible body-only issue corruption";
return validationError("issue edit", repo, message, {
issueNumber,
guard: {
ok: false,
failures,
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
allowShortBody: options.allowShortBody,
shortBodyOverrideAvailable: overrideAvailable,
bodySource: { kind: "body-file", path: options.bodyFile ?? null },
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
...bodySafetySignals(body),
isLiteralNull,
isBlank,
isShort,
profile: profileValidation,
},
});
}
function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions): Record<string, unknown> {
const trimmed = body.trim();
const profile = issueProfileValidation(issueNumber, body, options.bodyProfile);
const warnings: string[] = [];
if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) {
warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail");
}
if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk");
if (profile.ok === false) warnings.push("profile guard would fail");
const signals = bodySafetySignals(body);
const shellPollution = signals.shellPollution as Record<string, unknown>;
if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected");
return {
ok: trimmed.length > 0
&& trimmed.toLowerCase() !== "null"
&& (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody)
&& profile.ok === true,
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
allowShortBody: options.allowShortBody,
warnings,
profile,
...signals,
};
}
function assertConcurrencyOptions(options: GitHubOptions): GitHubCommandResult | null {
if (options.expectBodySha === undefined) return null;
try {
normalizeExpectedSha(options.expectBodySha);
return null;
} catch (error) {
return validationError("issue edit", options.repo, error instanceof Error ? error.message : String(error), {
expectBodySha: options.expectBodySha,
});
}
}
function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions): GitHubCommandResult | null {
const checks: Record<string, unknown> = {
issueNumber,
expectUpdatedAt: options.expectUpdatedAt ?? null,
actualUpdatedAt: oldIssue.updated_at ?? null,
expectBodySha: options.expectBodySha ?? null,
actualBodySha: bodySha(oldIssue.body ?? ""),
};
if (options.expectUpdatedAt !== undefined && oldIssue.updated_at !== options.expectUpdatedAt) {
return validationError("issue edit", repo, "--expect-updated-at did not match current GitHub issue updated_at; refusing stale body overwrite", checks);
}
if (options.expectBodySha !== undefined) {
const expected = normalizeExpectedSha(options.expectBodySha);
if (bodySha(oldIssue.body ?? "") !== expected) {
return validationError("issue edit", repo, "--expect-body-sha did not match current GitHub issue body; refusing stale body overwrite", checks);
}
}
return null;
}
function commentSummary(comment: GitHubComment): Record<string, unknown> {
return {
id: comment.id,
@@ -1077,17 +1302,39 @@ async function getIssue(token: string, repo: string, issueNumber: number): Promi
return githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`);
}
async function issueView(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> {
function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null, fields: IssueViewJsonField[] | undefined): Record<string, unknown> | null {
if (fields === undefined) return null;
const summary = issueSummary(issue);
const selected: Record<string, unknown> = {};
for (const field of fields) {
if (field === "comments") {
selected.comments = (comments ?? []).map(commentSummary);
} else {
selected[field] = summary[field];
}
}
return selected;
}
async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined): Promise<GitHubCommandResult> {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError("issue view", repo, issue, { issueNumber });
const comments = await listIssueComments(token, repo, issueNumber);
const needsComments = jsonFields === undefined || jsonFields.includes("comments");
const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null;
if (isGitHubError(comments)) return commandError("issue view", repo, comments, { issueNumber, issue: issueSummary(issue) });
return {
ok: true,
command: "issue view",
repo,
issue: issueSummary(issue),
comments: comments.map(commentSummary),
...(comments === null ? {} : { comments: comments.map(commentSummary) }),
...(jsonFields === undefined ? {} : {
jsonFields,
json: selectedIssueJson(issue, comments, jsonFields),
compatibility: {
legacyJsonBodyPath: ".data.issue.body",
},
}),
};
}
@@ -1103,20 +1350,60 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const body = readBodyFile(options.bodyFile, "issue edit");
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
if (bodyGuard !== null) return bodyGuard;
const concurrencyOptionError = assertConcurrencyOptions(options);
if (concurrencyOptionError !== null) return concurrencyOptionError;
let oldIssue: GitHubIssue | null = null;
let briefDiff: CommanderBriefDiff | null = null;
const claudeQqConfig = commanderBriefClaudeQqConfig();
if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) {
return validationError("issue edit", repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber });
}
if (options.notifyClaudeQqBriefDiff && !options.dryRun) {
const needsReadBeforeEdit = !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined);
if (needsReadBeforeEdit) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber, phase: "read-before-edit" });
oldIssue = issue;
briefDiff = commanderBriefDiff(issue.body ?? "", body);
const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options);
if (concurrencyError !== null) return concurrencyError;
if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", body);
}
if (options.dryRun) {
const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", body) : null;
const guard = issueEditGuardSummary(issueNumber, body, options);
let dryRunOldBody: Record<string, unknown> = {
fetched: false,
bodyChars: null,
bodySha: null,
updatedAt: null,
reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run",
};
if (token.length > 0) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) {
dryRunOldBody = {
fetched: false,
bodyChars: null,
bodySha: null,
updatedAt: null,
readError: {
degradedReason: issue.degradedReason,
runnerDisposition: issue.runnerDisposition,
message: issue.message,
status: issue.status ?? null,
},
};
} else {
const oldBody = issue.body ?? "";
dryRunOldBody = {
fetched: true,
bodyChars: oldBody.length,
bodySha: bodySha(oldBody),
updatedAt: issue.updated_at ?? null,
};
}
}
return {
ok: true,
command: "issue edit",
@@ -1124,6 +1411,20 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
dryRun: true,
issueNumber,
...dryRunBody(repo, options.title, body),
guard,
bodyOnlySafety: {
oldBody: dryRunOldBody,
newBody: {
bodyChars: body.length,
bodySha: bodySha(body),
},
},
concurrency: {
dryRunNoWrite: true,
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH",
},
wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile },
...(options.notifyClaudeQqBriefDiff
? {
@@ -1141,7 +1442,11 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
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("issue edit", repo, issue, { issueNumber });
if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), rest: true };
const guard = issueEditGuardSummary(issueNumber, body, options);
const concurrency = oldIssue === null
? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }
: { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null };
if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), guard, concurrency, rest: true };
const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", body);
const claudeqq = diff.ok
@@ -1158,6 +1463,8 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
command: "issue edit",
repo,
issue: issueSummary(issue),
guard,
concurrency,
rest: true,
commanderBriefNotification: {
issueNumber,
@@ -1368,9 +1675,9 @@ export function ghHelp(): unknown {
output: "json",
usage: [
"bun scripts/cli.ts gh auth status [--repo owner/name]",
"bun scripts/cli.ts gh issue view <number> [--repo owner/name]",
"bun scripts/cli.ts gh issue view <number> [--repo owner/name] [--json body,title,state,comments]",
"bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [--title title] [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]",
"bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]",
"bun scripts/cli.ts gh issue comment <number> --body-file <file> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]",
@@ -1384,7 +1691,10 @@ export function ghHelp(): unknown {
notes: [
"Issue create/edit/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 view supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"issue edit --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 and #24 body-only profiles require their stable headings.",
"issue edit dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.",
"Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.",
"issue edit 24 --notify-claudeqq-brief-diff reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
@@ -1411,6 +1721,14 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
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 (options.jsonFields !== undefined && !(top === "issue" && sub === "view")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--json field selection is only supported by gh issue view");
}
if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && sub === "edit")) {
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 edit");
}
if (top === "auth" && sub === "status") return authStatus(options.repo);
@@ -1419,7 +1737,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (sub === "create") return issueCreate(options.repo, "", options);
if (sub === "edit") {
const issueNumber = parseNumber(third, "issue edit");
return issueEdit(options.repo, "", issueNumber, options);
const { token } = resolveToken(false);
return issueEdit(options.repo, token ?? "", issueNumber, options);
}
if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options);
if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true);
@@ -1429,7 +1748,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
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 === "view") return issueView(options.repo, token, parseNumber(third, "issue view"));
if (sub === "view") return issueView(options.repo, token, parseNumber(third, "issue view"), options.jsonFields);
if (sub === "create") return issueCreate(options.repo, token, options);
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);
if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options);