From 779721ea737deef3857224c22ac5055fb9f3ed6f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 15 Jun 2026 02:56:11 +0000 Subject: [PATCH] feat: add GitHub issue body patching --- .agents/skills/unidesk-gh/SKILL.md | 29 +- scripts/src/gh.ts | 446 +++++++++++++++++++++++++++-- scripts/src/help.ts | 2 +- 3 files changed, 459 insertions(+), 18 deletions(-) diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index e3fad668..45b0f3a5 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -93,6 +93,22 @@ EOF `edit` 是 `update --mode replace` 的兼容别名。正式写入默认先读当前 issue 做 body guard。 +### 局部修补正文 + +```bash +bun scripts/cli.ts gh issue patch \ + --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 `;输出包含 old/new bodySha、updatedAt、patch 摘要和 readCommands,不回显完整正文。 + ### 评论 ```bash @@ -108,11 +124,22 @@ bun scripts/cli.ts gh issue comment update \ 新的评论正文 EOF +# 局部修补评论 +bun scripts/cli.ts gh issue comment patch \ + --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 ``` -`edit` 是 `comment update` 的兼容别名。`--body ` 仅适合短单行。日常修正文案优先用 `update/edit` 保留评论 ID 和时间线;`delete` 只用于确实需要删除的评论。 +`edit` 是 `comment update` 的兼容别名。`--body ` 仅适合短单行。日常修正文案优先用 `patch` 或 `update/edit` 保留评论 ID 和时间线;`delete` 只用于确实需要删除的评论。`comment patch` 会先读取 comment id 对应的当前正文,把 envelope 应用到虚拟文件 `comment.md`,再 PATCH 单条评论;上下文不匹配时失败且不写入。 ### 关闭/重开 diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 91c949bb..abeddb27 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -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 } { + 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 } { + 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 `); + } + if (options.bodyPatchFile === undefined) throw new Error(`${command} requires --body-patch-stdin or --body-patch-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 } { + 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 | null { + if (!isRecord(value)) return null; + const diagnostics: Record = {}; + 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 | null { + if (!isRecord(value)) return null; + const result: Record = {}; + 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 = {}; + 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 { + const message = error instanceof Error ? error.message : String(error); + const summary: Record = { + 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 } | 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, 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 = { 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 = { 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 = { + 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(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`); } +async function getIssueComment(token: string, repo: string, commentId: number): Promise { + const { owner, name } = repoParts(repo); + return githubRequest(token, "GET", `/repos/${owner}/${name}/issues/comments/${commentId}`); +} + function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null, fields: IssueViewJsonField[] | undefined): Record | 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 { + const commandName = "issue patch"; + const concurrencyOptionError = assertConcurrencyOptions(options, commandName); + if (concurrencyOptionError !== null) return concurrencyOptionError; + let patchInput: { patch: string; patchSource: Record }; + 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 }; + 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(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 { if (options.bodyFile === undefined) { return validationError(commandName, repo, `${commandName} requires --body-stdin or --body-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 { + const command = commandName ?? `${ownerKind} comment patch`; + const concurrencyOptionError = assertConcurrencyOptions(options, command); + if (concurrencyOptionError !== null) return concurrencyOptionError; + let patchInput: { patch: string; patchSource: Record }; + 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 --repo ${repo} --json comments`, + full: `bun scripts/cli.ts gh issue view --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 --repo ${repo} --json comments`, + full: `bun scripts/cli.ts gh issue view --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 }; + 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(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 { const command = commandName ?? `${ownerKind} comment update`; let bodyInput: { body: string; bodySource: Record }; @@ -6782,10 +7157,12 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue read [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]", "bun scripts/cli.ts gh issue create --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") { diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 6bd35772..18c284c7 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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." },