feat: add GitHub issue body patching

This commit is contained in:
Codex
2026-06-15 02:56:11 +00:00
parent 5289f08682
commit 779721ea73
3 changed files with 459 additions and 18 deletions
+28 -1
View File
@@ -93,6 +93,22 @@ EOF
`edit``update --mode replace` 的兼容别名。正式写入默认先读当前 issue 做 body guard。
### 局部修补正文
```bash
bun scripts/cli.ts gh issue patch <number|owner/repo#number> \
--repo owner/name --body-patch-stdin <<'PATCH'
*** Begin Patch
*** Update File: issue.md
@@
-old text
+new text
*** End Patch
PATCH
```
`issue patch` 会先读取当前 issue 正文,把 Codex `apply_patch` envelope 应用到虚拟文件 `issue.md`,再通过现有 issue body guard 写回。支持 `--dry-run``--expect-updated-at``--expect-body-sha``--body-patch-file <file|->`;输出包含 old/new bodySha、updatedAt、patch 摘要和 readCommands,不回显完整正文。
### 评论
```bash
@@ -108,11 +124,22 @@ bun scripts/cli.ts gh issue comment update <commentId> \
新的评论正文
EOF
# 局部修补评论
bun scripts/cli.ts gh issue comment patch <commentId> \
--repo owner/name --body-patch-stdin <<'PATCH'
*** Begin Patch
*** Update File: comment.md
@@
-old text
+new text
*** End Patch
PATCH
# 删除评论
bun scripts/cli.ts gh issue comment delete <commentId>
```
`edit``comment update` 的兼容别名。`--body <short-text>` 仅适合短单行。日常修正文案优先用 `update/edit` 保留评论 ID 和时间线;`delete` 只用于确实需要删除的评论。
`edit``comment update` 的兼容别名。`--body <short-text>` 仅适合短单行。日常修正文案优先用 `patch``update/edit` 保留评论 ID 和时间线;`delete` 只用于确实需要删除的评论。`comment patch` 会先读取 comment id 对应的当前正文,把 envelope 应用到虚拟文件 `comment.md`,再 PATCH 单条评论;上下文不匹配时失败且不写入。
### 关闭/重开
+430 -16
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { ApplyPatchV2Error, deriveUpdatedContent, parseApplyPatchV2, type PatchHunk } from "./apply-patch-v2";
import { coreInternalFetch } from "./microservices";
const DEFAULT_REPO = "pikasTech/unidesk";
@@ -54,13 +55,13 @@ 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",
"--body-file", "--body-patch-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", "--description",
]);
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]);
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--body-patch-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]);
const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
const ISSUE_SCAN_MAX_FINDINGS = 60;
const ISSUE_BODY_PROFILES = {
@@ -354,6 +355,7 @@ interface GitHubOptions {
title?: string;
body?: string;
bodyFile?: string;
bodyPatchFile?: string;
comment?: string;
commentFile?: string;
repoDescription?: string;
@@ -718,7 +720,7 @@ function isPrReadCommand(sub: string | undefined): boolean {
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 === "read" || sub === "view" || sub === "edit" || sub === "update" || sub === "patch" || 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;
@@ -904,6 +906,7 @@ function parseOptions(args: string[]): GitHubOptions {
title: optionValue(args, "--title"),
body: optionValue(args, "--body"),
bodyFile: stdinAliasFileOption(args, "--body-file", "--body-stdin"),
bodyPatchFile: stdinAliasFileOption(args, "--body-patch-file", "--body-patch-stdin"),
comment: optionValue(args, "--comment"),
commentFile: stdinAliasFileOption(args, "--comment-file", "--comment-stdin"),
repoDescription: optionValue(args, "--description"),
@@ -1307,6 +1310,140 @@ function readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource:
return { body: readFileSync(path, "utf8"), bodySource: { kind: "body-file", path } };
}
function readBodyPatchFileOrStdin(path: string): { patch: string; patchSource: Record<string, unknown> } {
if (path === "-") {
return { patch: readFileSync(0, "utf8"), patchSource: { kind: "body-patch-stdin", path: "-" } };
}
if (!existsSync(path)) throw new Error(`body patch file not found: ${path}`);
return { patch: readFileSync(path, "utf8"), patchSource: { kind: "body-patch-file", path } };
}
function readBodyPatch(options: GitHubOptions, command: string): { patch: string; patchSource: Record<string, unknown> } {
const conflictingSources: string[] = [];
if (options.body !== undefined) conflictingSources.push("--body");
if (options.bodyFile !== undefined) conflictingSources.push(options.bodyFile === "-" ? "--body-stdin" : "--body-file");
if (options.comment !== undefined) conflictingSources.push("--comment");
if (options.commentFile !== undefined) conflictingSources.push(options.commentFile === "-" ? "--comment-stdin" : "--comment-file");
if (conflictingSources.length > 0) {
throw new Error(`${command} accepts only a patch source; remove ${conflictingSources.join(",")} and use --body-patch-stdin or --body-patch-file <file|->`);
}
if (options.bodyPatchFile === undefined) throw new Error(`${command} requires --body-patch-stdin or --body-patch-file <file|->`);
return readBodyPatchFileOrStdin(options.bodyPatchFile);
}
function patchPathList(hunks: PatchHunk[]): string[] {
return Array.from(new Set(hunks.map((hunk) => hunk.path)));
}
function applyVirtualBodyPatch(virtualPath: "issue.md" | "comment.md", oldBody: string, patchText: string): { newBody: string; patchSummary: Record<string, unknown> } {
const parsed = parseApplyPatchV2(patchText);
let newBody = oldBody;
let updateChunkCount = 0;
for (let index = 0; index < parsed.hunks.length; index += 1) {
const hunk = parsed.hunks[index] as PatchHunk;
if (hunk.kind !== "update") {
throw new ApplyPatchV2Error("GitHub body patch supports only Update File hunks", {
hunk: index + 1,
action: hunk.kind,
path: hunk.path,
expectedPath: virtualPath,
});
}
if (hunk.path !== virtualPath) {
throw new ApplyPatchV2Error("GitHub body patch target path mismatch", {
hunk: index + 1,
path: hunk.path,
expectedPath: virtualPath,
});
}
if (hunk.movePath !== null && hunk.movePath !== hunk.path) {
throw new ApplyPatchV2Error("GitHub body patch does not support Move to", {
hunk: index + 1,
path: hunk.path,
targetPath: hunk.movePath,
expectedPath: virtualPath,
});
}
updateChunkCount += hunk.chunks.length;
newBody = deriveUpdatedContent(virtualPath, newBody, hunk.chunks).newContent;
}
if (newBody === oldBody) {
throw new ApplyPatchV2Error("GitHub body patch did not change the body; no GitHub write will be performed", {
path: virtualPath,
hunkCount: parsed.hunks.length,
});
}
return {
newBody,
patchSummary: {
engine: "apply-patch-v2",
virtualFile: virtualPath,
patchBytes: Buffer.byteLength(patchText, "utf8"),
hunkCount: parsed.hunks.length,
updateChunkCount,
paths: patchPathList(parsed.hunks),
hints: parsed.hints,
},
};
}
function sanitizedPatchDiagnostics(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const diagnostics: Record<string, unknown> = {};
const candidateLines = value.firstExpectedLineCandidates;
if (Array.isArray(candidateLines)) diagnostics.firstExpectedLineCandidates = candidateLines.filter((item): item is number => typeof item === "number");
for (const key of ["firstExpectedLineCandidatesTruncated", "bestPrefixMatchedLines", "bestPrefixStartLine", "likelyMissingAddedPrefixes", "likelyStaleOrOversizedContext"]) {
const item = value[key];
if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) diagnostics[key] = item;
}
return Object.keys(diagnostics).length > 0 ? diagnostics : null;
}
function sanitizedPatchDetails(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const result: Record<string, unknown> = {};
for (const key of ["path", "targetPath", "expectedPath", "hunk", "chunk", "action", "status", "line", "hunkCount"]) {
const item = value[key];
if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) result[key] = item;
}
if (typeof value.expected === "string") {
result.expectedLineCount = value.expected.length === 0 ? 0 : value.expected.split(/\r?\n/u).length;
result.expectedChars = value.expected.length;
}
const diagnostics = sanitizedPatchDiagnostics(value.diagnostics);
if (diagnostics !== null) result.diagnostics = diagnostics;
if (Array.isArray(value.partialChanges)) result.partialChanges = value.partialChanges.filter((item): item is string => typeof item === "string");
if (Array.isArray(value.outcomes)) {
result.outcomes = value.outcomes
.filter(isRecord)
.map((outcome) => {
const summary: Record<string, unknown> = {};
for (const key of ["hunk", "action", "status", "path", "targetPath", "change"]) {
const item = outcome[key];
if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) summary[key] = item;
}
if (isRecord(outcome.error) && typeof outcome.error.message === "string") summary.errorMessage = outcome.error.message;
return summary;
});
}
if (isRecord(value.failed)) result.failed = sanitizedPatchDetails(value.failed);
if (isRecord(value.cause)) result.cause = sanitizedPatchDetails(value.cause);
return Object.keys(result).length > 0 ? result : null;
}
function bodyPatchErrorSummary(error: unknown): Record<string, unknown> {
const message = error instanceof Error ? error.message : String(error);
const summary: Record<string, unknown> = {
name: error instanceof Error ? error.name : "Error",
message,
bodyTextRedacted: true,
note: "Patch diagnostics omit expected/context text so GitHub body contents and credentials are not echoed.",
};
const details = error instanceof ApplyPatchV2Error ? sanitizedPatchDetails(error.details) : null;
if (details !== null) summary.details = details;
return summary;
}
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/--body-stdin or --body`);
if (options.bodyFile !== undefined) {
@@ -4371,7 +4508,7 @@ function validationStringArray(record: Record<string, unknown>, key: string): st
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): GitHubCommandResult | null {
function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}, commandName = "issue edit", bodySource: Record<string, unknown> = { kind: "body-file", path: options.bodyFile ?? null }): GitHubCommandResult | null {
const trimmed = body.trim();
const isLiteralNull = trimmed.toLowerCase() === "null";
const isBlank = trimmed.length === 0;
@@ -4393,9 +4530,9 @@ function validateIssueBodyGuard(repo: string, issueNumber: number, body: string,
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, {
? `${commandName} body is shorter than the safe default; pass --allow-short-body only for intentional short writes`
: `${commandName} body failed safety guard; refusing to write possible body-only issue corruption`;
return validationError(commandName, repo, message, {
issueNumber,
guard: {
ok: false,
@@ -4403,7 +4540,7 @@ function validateIssueBodyGuard(repo: string, issueNumber: number, body: string,
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
allowShortBody: options.allowShortBody,
shortBodyOverrideAvailable: overrideAvailable,
bodySource: { kind: "body-file", path: options.bodyFile ?? null },
bodySource,
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
codeQueueBoardHint: boardBriefHint,
@@ -4458,7 +4595,7 @@ function assertConcurrencyOptions(options: GitHubOptions, commandName: string):
}
}
function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions): GitHubCommandResult | null {
function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions, commandName = "issue edit"): GitHubCommandResult | null {
const checks: Record<string, unknown> = {
issueNumber,
expectUpdatedAt: options.expectUpdatedAt ?? null,
@@ -4467,12 +4604,32 @@ function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: G
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);
return validationError(commandName, 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 validationError(commandName, repo, "--expect-body-sha did not match current GitHub issue body; refusing stale body overwrite", checks);
}
}
return null;
}
function validateCommentConcurrency(repo: string, commentId: number, oldComment: GitHubComment, options: GitHubOptions, commandName: string): GitHubCommandResult | null {
const checks: Record<string, unknown> = {
commentId,
expectUpdatedAt: options.expectUpdatedAt ?? null,
actualUpdatedAt: oldComment.updated_at ?? null,
expectBodySha: options.expectBodySha ?? null,
actualBodySha: bodySha(oldComment.body ?? ""),
};
if (options.expectUpdatedAt !== undefined && oldComment.updated_at !== options.expectUpdatedAt) {
return validationError(commandName, repo, "--expect-updated-at did not match current GitHub comment updated_at; refusing stale body overwrite", checks);
}
if (options.expectBodySha !== undefined) {
const expected = normalizeExpectedSha(options.expectBodySha);
if (bodySha(oldComment.body ?? "") !== expected) {
return validationError(commandName, repo, "--expect-body-sha did not match current GitHub comment body; refusing stale body overwrite", checks);
}
}
return null;
@@ -5477,6 +5634,11 @@ async function getIssue(token: string, repo: string, issueNumber: number): Promi
return githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`);
}
async function getIssueComment(token: string, repo: string, commentId: number): Promise<GitHubComment | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubComment>(token, "GET", `/repos/${owner}/${name}/issues/comments/${commentId}`);
}
function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null, fields: IssueViewJsonField[] | undefined): Record<string, unknown> | null {
if (fields === undefined) return null;
const summary = issueSummary(issue);
@@ -5720,6 +5882,109 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true };
}
async function issuePatch(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const commandName = "issue patch";
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
if (concurrencyOptionError !== null) return concurrencyOptionError;
let patchInput: { patch: string; patchSource: Record<string, unknown> };
try {
patchInput = readBodyPatch(options, commandName);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const { patch, patchSource } = patchInput;
const oldIssue = await getIssue(token, repo, issueNumber);
if (isGitHubError(oldIssue)) return commandError(commandName, repo, oldIssue, { issueNumber, phase: "read-before-patch" });
const concurrencyError = validateIssueConcurrency(repo, issueNumber, oldIssue, options, commandName);
if (concurrencyError !== null) return concurrencyError;
const oldBody = oldIssue.body ?? "";
let patched: { newBody: string; patchSummary: Record<string, unknown> };
try {
patched = applyVirtualBodyPatch("issue.md", oldBody, patch);
} catch (error) {
return validationError(commandName, repo, "body patch failed; no GitHub write performed", {
issueNumber,
patchSource,
patchError: bodyPatchErrorSummary(error),
oldBody: {
bodyChars: oldBody.length,
bodySha: bodySha(oldBody),
updatedAt: oldIssue.updated_at ?? null,
},
noWrite: true,
});
}
const profileContext: IssueProfileValidationContext = { issueTitle: oldIssue.title, issueBody: oldIssue.body, issueMetadataFetched: true };
const guard = validateIssueBodyGuard(repo, issueNumber, patched.newBody, options, profileContext, commandName, patchSource);
if (guard !== null) return guard;
const guardSummary = issueEditGuardSummary(issueNumber, patched.newBody, options, profileContext);
const bodyChange = {
oldBodyChars: oldBody.length,
newBodyChars: patched.newBody.length,
deltaChars: patched.newBody.length - oldBody.length,
oldBodySha: bodySha(oldBody),
newBodySha: bodySha(patched.newBody),
oldUpdatedAt: oldIssue.updated_at ?? null,
};
const concurrency = {
checked: true,
oldIssueUpdatedAt: oldIssue.updated_at ?? null,
oldBodySha: bodySha(oldBody),
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
};
if (options.dryRun) {
return {
ok: true,
command: commandName,
repo,
dryRun: true,
planned: true,
issueNumber,
patchSource,
patch: patched.patchSummary,
...dryRunBody(repo, undefined, patched.newBody),
disclosure: issueWriteDisclosure(options, repo, issueNumber, true),
readCommands: issueBodyReadCommands(repo, issueNumber),
guard: guardSummary,
concurrency: { ...concurrency, dryRunNoWrite: true },
bodyChange,
wouldPatch: {
issueNumber,
bodyChars: patched.newBody.length,
bodySha: bodySha(patched.newBody),
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/${issueNumber}`,
},
};
}
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { body: patched.newBody });
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
return {
ok: true,
command: commandName,
repo,
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
patchSource,
patch: patched.patchSummary,
bodyChange: {
...bodyChange,
newUpdatedAt: issue.updated_at ?? null,
},
guard: guardSummary,
concurrency,
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
readCommands: issueBodyReadCommands(repo, issueNumber),
request: {
method: "PATCH",
path: `/repos/${owner}/${name}/issues/${issueNumber}`,
bodyChars: patched.newBody.length,
},
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-stdin or --body-file <file|->`, { issueNumber });
@@ -5955,6 +6220,116 @@ async function issueComment(repo: string, token: string, issueNumber: number, op
};
}
async function commentPatch(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, options: GitHubOptions, commandName?: string): Promise<GitHubCommandResult> {
const command = commandName ?? `${ownerKind} comment patch`;
const concurrencyOptionError = assertConcurrencyOptions(options, command);
if (concurrencyOptionError !== null) return concurrencyOptionError;
let patchInput: { patch: string; patchSource: Record<string, unknown> };
try {
patchInput = readBodyPatch(options, command);
} catch (error) {
return validationError(command, repo, error instanceof Error ? error.message : String(error), { commentId });
}
const { patch, patchSource } = patchInput;
const readCommands = ownerKind === "issue"
? {
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`,
note: "GitHub comment patch targets commentId directly; use the owning issue number to read the surrounding comments.",
}
: {
comments: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --json comments`,
full: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --full`,
note: "GitHub PR comments are issue comments; use the owning PR number through gh issue view to read the surrounding comments.",
};
const oldComment = await getIssueComment(token, repo, commentId);
if (isGitHubError(oldComment)) return commandError(command, repo, oldComment, { commentId, phase: "read-before-patch" });
const concurrencyError = validateCommentConcurrency(repo, commentId, oldComment, options, command);
if (concurrencyError !== null) return concurrencyError;
const oldBody = oldComment.body ?? "";
let patched: { newBody: string; patchSummary: Record<string, unknown> };
try {
patched = applyVirtualBodyPatch("comment.md", oldBody, patch);
} catch (error) {
return validationError(command, repo, "body patch failed; no GitHub write performed", {
commentId,
patchSource,
patchError: bodyPatchErrorSummary(error),
oldBody: {
bodyChars: oldBody.length,
bodySha: bodySha(oldBody),
updatedAt: oldComment.updated_at ?? null,
},
noWrite: true,
});
}
const bodyChange = {
oldBodyChars: oldBody.length,
newBodyChars: patched.newBody.length,
deltaChars: patched.newBody.length - oldBody.length,
oldBodySha: bodySha(oldBody),
newBodySha: bodySha(patched.newBody),
oldUpdatedAt: oldComment.updated_at ?? null,
};
const concurrency = {
checked: true,
oldCommentUpdatedAt: oldComment.updated_at ?? null,
oldBodySha: bodySha(oldBody),
expectUpdatedAt: options.expectUpdatedAt ?? null,
expectBodySha: options.expectBodySha ?? null,
};
if (options.dryRun) {
return {
ok: true,
command,
repo,
dryRun: true,
planned: true,
commentId,
patchSource,
patch: patched.patchSummary,
bodyPreview: preview(patched.newBody),
bodyPreviewLines: previewLines(patched.newBody, 4),
bodyChars: patched.newBody.length,
bodySha: bodySha(patched.newBody),
bodyChange,
concurrency: { ...concurrency, dryRunNoWrite: true },
readCommands,
wouldPatch: {
commentId,
bodyChars: patched.newBody.length,
bodySha: bodySha(patched.newBody),
method: "PATCH",
path: `/repos/{owner}/{repo}/issues/comments/${commentId}`,
},
};
}
const { owner, name } = repoParts(repo);
const comment = await githubRequest<GitHubComment>(token, "PATCH", `/repos/${owner}/${name}/issues/comments/${commentId}`, { body: patched.newBody });
if (isGitHubError(comment)) return commandError(command, repo, comment, { commentId });
return {
ok: true,
command,
repo,
commentId,
comment: compactCommentSummary(comment),
patchSource,
patch: patched.patchSummary,
bodyChange: {
...bodyChange,
newUpdatedAt: comment.updated_at ?? null,
},
concurrency,
readCommands,
request: {
method: "PATCH",
path: `/repos/${owner}/${name}/issues/comments/${commentId}`,
bodyChars: patched.newBody.length,
},
rest: true,
};
}
async function commentUpdate(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, options: GitHubOptions, commandName?: string): Promise<GitHubCommandResult> {
const command = commandName ?? `${ownerKind} comment update`;
let bodyInput: { body: string; bodySource: Record<string, unknown> };
@@ -6782,10 +7157,12 @@ export function ghHelp(): unknown {
"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-stdin|--body-file <file|->) [--label label[,label...]]... [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue update <number> --mode replace|append (--body-stdin|--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 patch <number|url|owner/repo#number> --body-patch-stdin [--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-stdin|--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-stdin --notify-claudeqq-brief-diff [--dry-run]",
"bun scripts/cli.ts gh issue comment create <number> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue comment update <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue comment patch <commentId> --body-patch-stdin [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue comment edit <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for issue comment update]",
"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-stdin|--comment-file <file|->] [--dry-run]",
@@ -6829,7 +7206,7 @@ export function ghHelp(): unknown {
"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.",
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch 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-stdin or --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-stdin is the first-class heredoc/stdin source for Markdown bodies. Use quoted heredoc syntax such as bun scripts/cli.ts gh issue comment create 1 --body-stdin <<'EOF' so real newlines, backticks, and tables are read as stdin bytes instead of shell arguments.",
@@ -6837,7 +7214,9 @@ export function ghHelp(): unknown {
"issue edit is a compatibility alias for issue update --mode replace.",
"issue update accepts --body-stdin or --body-file <file|->, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, 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 patch reads the current GitHub issue body, applies a Codex apply_patch envelope against virtual file issue.md from --body-patch-stdin or --body-patch-file <file|->, then runs the same issue body guard before PATCH. It returns old/new bodySha, updatedAt, patch summary, and bounded previews; context mismatch fails with redacted diagnostics and no GitHub write.",
"issue comment create/update/edit accept --body-stdin or --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. Use comment update/edit to correct existing wording in place; delete remains for intentional removal.",
"issue comment patch reads the current issue comment by commentId, applies a Codex apply_patch envelope against virtual file comment.md, then PATCHes only that comment. It returns comment id, old/new bodySha, updatedAt, patch summary, and redacted mismatch diagnostics without echoing the full comment body.",
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text>, --comment-stdin, or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-stdin is the first-class heredoc 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, prefer quoted heredoc stdin: bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin <<'EOF' ... EOF or gh issue comment create <number> --body-stdin <<'EOF' ... EOF. --body-file <file|-> remains available for reusable files and pipes.",
@@ -6890,14 +7269,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
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")))) {
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "list" || sub === "update" || sub === "edit" || sub === "patch")) || 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.", {
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit/patch, 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",
"bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin --full <<'EOF'\n<reviewed body>\nEOF",
"bun scripts/cli.ts gh issue patch <number> --repo owner/name --body-patch-stdin --full <<'PATCH'\n*** Begin Patch\n*** Update File: issue.md\n@@\n-old\n+new\n*** End Patch\nPATCH",
"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")}`,
@@ -6931,9 +7311,24 @@ 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, "--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 issueBodyGuardCommand = top === "issue" && (sub === "edit" || sub === "update" || sub === "patch" || sub === "board-row" && ["update", "add", "move", "delete", "upsert"].includes(third ?? ""));
const issueCommentPatchCommand = top === "issue" && sub === "comment" && third === "patch";
if ((options.allowShortBody || optionWasProvided(args, "--body-profile")) && !issueBodyGuardCommand) {
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");
return validationError(command, options.repo, "--allow-short-body and --body-profile are only supported by gh issue update/edit/patch and gh issue board-row update/add/move/delete/upsert");
}
if ((options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined) && !(issueBodyGuardCommand || issueCommentPatchCommand)) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--expect-updated-at and --expect-body-sha are only supported by gh issue update/edit/patch, gh issue comment patch, and gh issue board-row update/add/move/delete/upsert");
}
if ((optionWasProvided(args, "--body-patch-file") || optionWasProvided(args, "--body-patch-stdin")) && !(top === "issue" && (sub === "patch" || sub === "comment" && third === "patch"))) {
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--body-patch-file and --body-patch-stdin are only supported by gh issue patch and gh issue comment patch", {
supportedCommands: [
"bun scripts/cli.ts gh issue patch <number> --repo owner/name --body-patch-stdin",
"bun scripts/cli.ts gh issue comment patch <commentId> --repo owner/name --body-patch-stdin",
],
});
}
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";
@@ -7027,6 +7422,17 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
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 === "patch") {
const commandName = "issue comment patch";
const resolved = resolvePositionalNumberReference("issue", args, 3, commandName, options);
if (isGitHubCommandResult(resolved)) return resolved;
const commentId = resolved.number;
if (typeof commentId !== "number") return commentId;
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(commentPatch(resolved.repo, token, "issue", commentId, { ...options, repo: resolved.repo }, commandName), resolved);
}
if (sub === "comment" && (third === "update" || third === "edit")) {
const commandName = `issue comment ${third}`;
const resolved = resolvePositionalNumberReference("issue", args, 3, commandName, options);
@@ -7086,6 +7492,14 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (action === "move") return withNumberOptionHint(issueBoardRowMove(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
return withNumberOptionHint(issueBoardRowDelete(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
}
if (sub === "patch") {
const resolved = resolvePositionalIssueReference(args, 2, "issue patch", options);
if (isGitHubCommandResult(resolved)) return resolved;
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, "issue patch", probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "issue patch", { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(issuePatch(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved);
}
if (options.dryRun) {
if (sub === "create") return issueCreate(options.repo, "", options);
if (sub === "edit") {
+1 -1
View File
@@ -56,7 +56,7 @@ export function rootHelp(): unknown {
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
{ command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." },
{ command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and guarded PR merge." },
{ command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, issue/comment apply_patch body patching, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and guarded PR merge." },
{ command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run approval preview without live bridges or message sends." },
{ command: "hwlab nodes control-plane|git-mirror|secret --node <node> --lane <lane>", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared infra/tools-image/Argo bootstrap and G14 v0.3+ runtime lanes, with the node identity passed as data." },
{ command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." },