fix: harden gh issue body updates
This commit is contained in:
+337
-18
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user