fix: 支持 GitHub 评论原地编辑
This commit is contained in:
@@ -597,6 +597,11 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
|
||||
sendJson(res, 201, { id: 9002, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/36#issuecomment-9002", user: { login: "tester" }, created_at: "2026-05-20T06:02:00Z", updated_at: "2026-05-20T06:02:00Z" });
|
||||
return;
|
||||
}
|
||||
if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/comments/9002") {
|
||||
const parsed = JSON.parse(body) as JsonRecord;
|
||||
sendJson(res, 200, { id: 9002, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/36#issuecomment-9002", user: { login: "tester" }, created_at: "2026-05-20T06:02:00Z", updated_at: "2026-05-20T06:04:00Z" });
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues") {
|
||||
const parsed = JSON.parse(body) as JsonRecord;
|
||||
const labels = Array.isArray(parsed.labels) ? parsed.labels.map(String) : [];
|
||||
@@ -665,7 +670,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
|
||||
assertCondition(notes.some((line) => line.includes("GitHub issue URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain issue view/read URL and shorthand targets", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("--number is accepted on single issue/comment numeric target commands") && line.includes("Comment delete treats --number as commentId")), "gh help should document issue --number compatibility scope", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("issue comment create accepts --body-stdin") && line.includes("--body only for short single-line text")), "gh help should document issue comment heredoc stdin and inline safety limits", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("issue comment create/update/edit accept --body-stdin") && line.includes("--body only for short single-line text")), "gh help should document issue comment heredoc stdin and inline safety limits", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("board-row update changes one table cell")), "gh help should describe board-row update safety", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("board-row upsert updates an existing row")), "gh help should describe board-row upsert safety", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("board-row add/move/delete are row-scoped")), "gh help should describe board-row row mutation safety", { notes });
|
||||
@@ -1707,6 +1712,34 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
|
||||
const inlinePayload = JSON.parse(inlinePost?.body ?? "{}") as JsonRecord;
|
||||
assertCondition(inlinePayload.body === inlineBody, "inline issue comment REST payload should preserve short text", inlinePayload);
|
||||
|
||||
const commentUpdateBody = "修正:保留评论 ID 的原地编辑";
|
||||
const commentUpdateDryRunRequestCountBefore = mock.requests.length;
|
||||
const commentUpdateDryRun = await runCli(["gh", "issue", "comment", "update", "9002", "--repo", "pikasTech/unidesk", "--body", commentUpdateBody, "--dry-run"], env);
|
||||
assertCondition(commentUpdateDryRun.status === 0, "issue comment update dry-run should succeed", commentUpdateDryRun.json ?? { stdout: commentUpdateDryRun.stdout });
|
||||
assertCondition(commentUpdateDryRun.json?.command === "gh issue comment update 9002 --repo pikasTech/unidesk --body <body:redacted> --dry-run", "outer gh command should redact issue comment update inline body", commentUpdateDryRun.json ?? {});
|
||||
const commentUpdateDryRunData = dataOf(commentUpdateDryRun.json ?? {});
|
||||
assertCondition(commentUpdateDryRunData.command === "issue comment update" && commentUpdateDryRunData.dryRun === true && commentUpdateDryRunData.commentId === 9002, "issue comment update dry-run should plan by commentId", commentUpdateDryRunData);
|
||||
assertCondition(typeof commentUpdateDryRunData.bodySha === "string" && String(commentUpdateDryRunData.bodySha).length === 64 && Number(commentUpdateDryRunData.bodyChars ?? 0) === commentUpdateBody.length, "issue comment update dry-run should expose body metadata", commentUpdateDryRunData);
|
||||
const commentUpdateRequest = commentUpdateDryRunData.request as JsonRecord;
|
||||
assertCondition(commentUpdateRequest.method === "PATCH" && String(commentUpdateRequest.path ?? "").includes("/issues/comments/{comment_id}"), "issue comment update dry-run should plan PATCH comment endpoint", commentUpdateRequest);
|
||||
const commentUpdateDryRunWriteCount = mock.requests.slice(commentUpdateDryRunRequestCountBefore).filter((request) => request.method === "PATCH" && request.url.includes("/issues/comments/")).length;
|
||||
assertCondition(commentUpdateDryRunWriteCount === 0, "issue comment update dry-run must not PATCH GitHub", { requests: mock.requests.slice(commentUpdateDryRunRequestCountBefore) });
|
||||
|
||||
const commentEditStdinBody = "编辑别名:stdin 正文\n\n- 保留 `code`\n";
|
||||
const commentEditRequestCountBefore = mock.requests.length;
|
||||
const commentEdit = await runCli(["gh", "issue", "comment", "edit", "--number", "9002", "--repo", "pikasTech/unidesk", "--body-stdin"], env, commentEditStdinBody);
|
||||
assertCondition(commentEdit.status === 0, "issue comment edit should accept --number compatibility alias and stdin", commentEdit.json ?? { stdout: commentEdit.stdout });
|
||||
const commentEditData = dataOf(commentEdit.json ?? {});
|
||||
assertCondition(commentEditData.command === "issue comment edit" && commentEditData.commentId === 9002, "issue comment edit should report alias command and commentId", commentEditData);
|
||||
const commentEditHint = commentEditData.standardSyntaxHint as JsonRecord;
|
||||
assertCondition(String(commentEditHint.standardCommand ?? "").includes("gh issue comment edit 9002 --repo pikasTech/unidesk"), "issue comment edit --number should point to positional commentId syntax", commentEditHint);
|
||||
const commentEditSummary = commentEditData.comment as JsonRecord;
|
||||
assertCondition(commentEditSummary.id === 9002 && commentEditSummary.bodyOmitted === true && !("body" in commentEditSummary), "issue comment edit should preserve id and omit full body", commentEditSummary);
|
||||
const commentEditPatch = mock.requests.slice(commentEditRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/comments/9002");
|
||||
assertCondition(commentEditPatch !== undefined, "issue comment edit should PATCH issue comments endpoint", { requests: mock.requests.slice(commentEditRequestCountBefore) });
|
||||
const commentEditPayload = JSON.parse(commentEditPatch?.body ?? "{}") as JsonRecord;
|
||||
assertCondition(commentEditPayload.body === commentEditStdinBody, "issue comment edit payload should preserve stdin Markdown", commentEditPayload);
|
||||
|
||||
const closeComment = "收口:CLI close --comment contract";
|
||||
const closeDryRunRequestCountBefore = mock.requests.length;
|
||||
const closeDryRun = await runCli(["gh", "issue", "close", "20", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env);
|
||||
@@ -1864,7 +1897,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
|
||||
"issue close/reopen supports --comment-stdin dry-run without writes",
|
||||
"issue comment create supports short inline --body dry-run and write with bounded output",
|
||||
"issue comment create supports --body-stdin and compatible --body-file -, and still rejects missing, blank, multiline inline, polluted inline, secret-like inline, and mixed body sources",
|
||||
"issue comment create/delete follows CRUD shape",
|
||||
"issue comment create/update/edit/delete follows CRUD shape",
|
||||
"issue hard delete is structurally unsupported",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -242,6 +242,11 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
|
||||
sendJson(res, 201, { id: 9101, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/pull/42#issuecomment-9101", user: { login: "runner" }, created_at: "2026-05-20T06:10:00Z", updated_at: "2026-05-20T06:10:00Z" });
|
||||
return;
|
||||
}
|
||||
if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") {
|
||||
const parsed = JSON.parse(body) as JsonRecord;
|
||||
sendJson(res, 200, { id: 9101, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/pull/42#issuecomment-9101", user: { login: "runner" }, created_at: "2026-05-20T06:10:00Z", updated_at: "2026-05-20T06:12:00Z" });
|
||||
return;
|
||||
}
|
||||
if (req.method === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
@@ -311,7 +316,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
assertCondition(notes.some((line) => line.includes("PR view is the canonical")), "gh help should state pr view is canonical", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state pr read is alias", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("GitHub PR URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain pr view/read URL and shorthand targets", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("--number is accepted on single PR/comment numeric target commands") && line.includes("PR comment delete treats --number as commentId")), "gh help should document --number compatibility hint", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("--number is accepted on single PR/comment numeric target commands") && line.includes("PR comment update/edit/delete treat --number as commentId")), "gh help should document --number compatibility hint", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("PR list defaults to --state all")), "gh help should document pr list default state", { notes });
|
||||
assertCondition(notes.some((line) => line.includes("stateDetail") && line.includes("mergedAt")), "gh help should describe closeout field normalization", { notes });
|
||||
@@ -741,6 +746,33 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
const prInlinePayload = JSON.parse(prInlineCommentRequest?.body ?? "{}") as JsonRecord;
|
||||
assertCondition(prInlinePayload.body === prInlineCommentBody, "pr inline comment payload should preserve --body text", prInlinePayload);
|
||||
|
||||
const prCommentUpdateBody = "PR 评论原地修正";
|
||||
const prCommentUpdateDryRunRequestCountBefore = mock2.requests.length;
|
||||
const prCommentUpdateDryRun = await runCli(["gh", "pr", "comment", "update", "9101", "--repo", "pikasTech/unidesk", "--body", prCommentUpdateBody, "--dry-run"], env2);
|
||||
assertCondition(prCommentUpdateDryRun.status === 0, "pr comment update dry-run should succeed", prCommentUpdateDryRun.json ?? { stdout: prCommentUpdateDryRun.stdout });
|
||||
assertCondition(prCommentUpdateDryRun.json?.command === "gh pr comment update 9101 --repo pikasTech/unidesk --body <body:redacted> --dry-run", "outer gh command should redact PR comment update inline body", prCommentUpdateDryRun.json ?? {});
|
||||
const prCommentUpdateDryRunData = dataOf(prCommentUpdateDryRun.json ?? {});
|
||||
assertCondition(prCommentUpdateDryRunData.command === "pr comment update" && prCommentUpdateDryRunData.commentId === 9101 && prCommentUpdateDryRunData.dryRun === true, "pr comment update dry-run should report commentId", prCommentUpdateDryRunData);
|
||||
const prCommentUpdateRequest = prCommentUpdateDryRunData.request as JsonRecord;
|
||||
assertCondition(prCommentUpdateRequest.method === "PATCH" && String(prCommentUpdateRequest.path ?? "").includes("/issues/comments/{comment_id}"), "pr comment update dry-run should plan PATCH comment endpoint", prCommentUpdateRequest);
|
||||
const prCommentUpdateDryRunWriteCount = mock2.requests.slice(prCommentUpdateDryRunRequestCountBefore).filter((request) => request.method === "PATCH" && request.url.includes("/issues/comments/")).length;
|
||||
assertCondition(prCommentUpdateDryRunWriteCount === 0, "pr comment update dry-run must not PATCH GitHub", { requests: mock2.requests.slice(prCommentUpdateDryRunRequestCountBefore) });
|
||||
|
||||
const prCommentEditBody = "PR edit 别名\n\n- 保留 `code`\n";
|
||||
const prCommentEditRequestCountBefore = mock2.requests.length;
|
||||
const prCommentEdit = await runCli(["gh", "pr", "comment", "edit", "--number", "9101", "--repo", "pikasTech/unidesk", "--body-stdin"], env2, prCommentEditBody);
|
||||
assertCondition(prCommentEdit.status === 0, "pr comment edit should accept --number compatibility alias and stdin", prCommentEdit.json ?? { stdout: prCommentEdit.stdout });
|
||||
const prCommentEditData = dataOf(prCommentEdit.json ?? {});
|
||||
assertCondition(prCommentEditData.command === "pr comment edit" && prCommentEditData.commentId === 9101, "pr comment edit should report alias command and commentId", prCommentEditData);
|
||||
const prCommentEditHint = prCommentEditData.standardSyntaxHint as JsonRecord;
|
||||
assertCondition(String(prCommentEditHint.standardCommand ?? "").includes("gh pr comment edit 9101 --repo pikasTech/unidesk"), "pr comment edit --number should point to positional commentId syntax", prCommentEditHint);
|
||||
const prCommentEditSummary = prCommentEditData.comment as JsonRecord;
|
||||
assertCondition(prCommentEditSummary.id === 9101 && prCommentEditSummary.bodyOmitted === true && !("body" in prCommentEditSummary), "pr comment edit should preserve id and omit full body", prCommentEditSummary);
|
||||
const prCommentEditPatch = mock2.requests.slice(prCommentEditRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/comments/9101");
|
||||
assertCondition(prCommentEditPatch !== undefined, "pr comment edit should PATCH issue comments endpoint", { requests: mock2.requests.slice(prCommentEditRequestCountBefore) });
|
||||
const prCommentEditPayload = JSON.parse(prCommentEditPatch?.body ?? "{}") as JsonRecord;
|
||||
assertCondition(prCommentEditPayload.body === prCommentEditBody, "pr comment edit payload should preserve stdin Markdown", prCommentEditPayload);
|
||||
|
||||
const commentDelete = await runCli(["gh", "pr", "comment", "delete", "9101", "--repo", "pikasTech/unidesk"], env2);
|
||||
assertCondition(commentDelete.status === 0, "pr comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout });
|
||||
const commentDeleteData = dataOf(commentDelete.json ?? {});
|
||||
@@ -799,7 +831,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
"pr update/edit use low-noise REST PATCH without GraphQL projectCards",
|
||||
"pr edit supports --body-stdin without echoing full body",
|
||||
"pr update append and close/reopen are available",
|
||||
"pr comment create/delete follows CRUD shape, --body-stdin, and --body remains supported",
|
||||
"pr comment create/update/edit/delete follows CRUD shape, --body-stdin, and --body remains supported",
|
||||
"pr merge is guarded by preflight and uses REST",
|
||||
"pr hard delete is blocked",
|
||||
"pr create validation failures are structured",
|
||||
|
||||
+112
-18
@@ -1339,32 +1339,32 @@ function secretLikeInlineFindings(body: string): string[] {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function readIssueCommentBody(options: GitHubOptions): { body: string; bodySource: Record<string, unknown> } {
|
||||
function readIssueCommentBody(options: GitHubOptions, command = "issue comment create"): { body: string; bodySource: Record<string, unknown> } {
|
||||
if (options.bodyFile !== undefined && options.body !== undefined) {
|
||||
throw new Error("issue comment create accepts only one body source: --body-file/--body-stdin or --body");
|
||||
throw new Error(`${command} accepts only one body source: --body-file/--body-stdin or --body`);
|
||||
}
|
||||
if (options.bodyFile !== undefined) {
|
||||
return readMarkdownBodyFileOrStdin(options.bodyFile);
|
||||
}
|
||||
if (options.body === undefined) throw new Error("issue comment create requires --body-stdin, --body-file <file|->, or --body <text>");
|
||||
if (options.body === undefined) throw new Error(`${command} requires --body-stdin, --body-file <file|->, or --body <text>`);
|
||||
const body = options.body;
|
||||
const trimmed = body.trim();
|
||||
const shellPollution = shellPollutionEvidence(body);
|
||||
const secretLike = secretLikeInlineFindings(body);
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error("issue comment create --body must not be blank; use --body-stdin with a quoted heredoc for reviewed Markdown");
|
||||
throw new Error(`${command} --body must not be blank; use --body-stdin with a quoted heredoc for reviewed Markdown`);
|
||||
}
|
||||
if (body.length > MAX_INLINE_ISSUE_COMMENT_BODY_CHARS) {
|
||||
throw new Error(`issue comment create --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-stdin with a quoted heredoc for long Markdown`);
|
||||
throw new Error(`${command} --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-stdin with a quoted heredoc for long Markdown`);
|
||||
}
|
||||
if (body.includes("\n") || body.includes("\r")) {
|
||||
throw new Error("issue comment create --body supports short single-line text only; use --body-stdin with a quoted heredoc for multiline Markdown");
|
||||
throw new Error(`${command} --body supports short single-line text only; use --body-stdin with a quoted heredoc for multiline Markdown`);
|
||||
}
|
||||
if (shellPollution.length > 0) {
|
||||
throw new Error(`issue comment create --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-stdin with a quoted heredoc for reviewed Markdown bytes`);
|
||||
throw new Error(`${command} --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-stdin with a quoted heredoc for reviewed Markdown bytes`);
|
||||
}
|
||||
if (secretLike.length > 0) {
|
||||
throw new Error(`issue comment create --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`);
|
||||
throw new Error(`${command} --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`);
|
||||
}
|
||||
return {
|
||||
body,
|
||||
@@ -1902,8 +1902,9 @@ function commanderBriefNotificationPlan(issueNumber: number, body: string, diff:
|
||||
};
|
||||
}
|
||||
|
||||
function writeBodyPlan(command: "issue create" | "issue comment create" | "pr create" | "pr comment", repo: string, body: string, bodySource: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const isIssueWrite = command === "issue create" || command === "issue comment create";
|
||||
function writeBodyPlan(command: "issue create" | "issue comment create" | "issue comment update" | "pr create" | "pr comment" | "pr comment update", repo: string, body: string, bodySource: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const isIssueWrite = command === "issue create" || command === "issue comment create" || command === "issue comment update";
|
||||
const isCommentUpdate = command === "issue comment update" || command === "pr comment update";
|
||||
const source = String(bodySource.kind ?? "unknown");
|
||||
const requestBody: Record<string, unknown> = {
|
||||
bodyChars: body.length,
|
||||
@@ -1919,10 +1920,18 @@ function writeBodyPlan(command: "issue create" | "issue comment create" | "pr cr
|
||||
bodyPreviewLines: previewLines(body),
|
||||
...bodySafetySignals(body),
|
||||
request: {
|
||||
method: "POST",
|
||||
method: isCommentUpdate ? "PATCH" : "POST",
|
||||
path: isIssueWrite
|
||||
? (command === "issue create" ? "/repos/{owner}/{repo}/issues" : "/repos/{owner}/{repo}/issues/{issue_number}/comments")
|
||||
: (command === "pr create" ? "/repos/{owner}/{repo}/pulls" : "/repos/{owner}/{repo}/issues/{issue_number}/comments"),
|
||||
? (command === "issue create"
|
||||
? "/repos/{owner}/{repo}/issues"
|
||||
: isCommentUpdate
|
||||
? "/repos/{owner}/{repo}/issues/comments/{comment_id}"
|
||||
: "/repos/{owner}/{repo}/issues/{issue_number}/comments")
|
||||
: (command === "pr create"
|
||||
? "/repos/{owner}/{repo}/pulls"
|
||||
: isCommentUpdate
|
||||
? "/repos/{owner}/{repo}/issues/comments/{comment_id}"
|
||||
: "/repos/{owner}/{repo}/issues/{issue_number}/comments"),
|
||||
body: requestBody,
|
||||
},
|
||||
validation: {
|
||||
@@ -5910,7 +5919,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
|
||||
async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
let bodyInput: { body: string; bodySource: Record<string, unknown> };
|
||||
try {
|
||||
bodyInput = readIssueCommentBody(options);
|
||||
bodyInput = readIssueCommentBody(options, "issue comment create");
|
||||
} catch (error) {
|
||||
return validationError("issue comment create", repo, error instanceof Error ? error.message : String(error), { issueNumber });
|
||||
}
|
||||
@@ -5946,6 +5955,63 @@ async function issueComment(repo: string, token: string, issueNumber: number, op
|
||||
};
|
||||
}
|
||||
|
||||
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> };
|
||||
try {
|
||||
bodyInput = readIssueCommentBody(options, command);
|
||||
} catch (error) {
|
||||
return validationError(command, repo, error instanceof Error ? error.message : String(error), { commentId });
|
||||
}
|
||||
const { body, bodySource } = bodyInput;
|
||||
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 update 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 writePlanCommand = ownerKind === "issue" ? "issue comment update" : "pr comment update";
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
dryRun: true,
|
||||
planned: true,
|
||||
commentId,
|
||||
readCommands,
|
||||
...writeBodyPlan(writePlanCommand, repo, body, bodySource, { commentId }),
|
||||
};
|
||||
}
|
||||
const { owner, name } = repoParts(repo);
|
||||
const comment = await githubRequest<GitHubComment>(token, "PATCH", `/repos/${owner}/${name}/issues/comments/${commentId}`, { body });
|
||||
if (isGitHubError(comment)) return commandError(command, repo, comment, { commentId });
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
commentId,
|
||||
comment: compactCommentSummary(comment),
|
||||
bodySource,
|
||||
bodyChars: body.length,
|
||||
bodySha: bodySha(body),
|
||||
bodyPreview: preview(body),
|
||||
source: String(bodySource.kind ?? "unknown"),
|
||||
readCommands,
|
||||
request: {
|
||||
method: "PATCH",
|
||||
path: `/repos/${owner}/${name}/issues/comments/${commentId}`,
|
||||
bodyChars: body.length,
|
||||
},
|
||||
rest: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> {
|
||||
const command = `${ownerKind} comment delete`;
|
||||
const { owner, name } = repoParts(repo);
|
||||
@@ -6719,6 +6785,8 @@ export function ghHelp(): unknown {
|
||||
"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 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]",
|
||||
"bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]",
|
||||
@@ -6745,6 +6813,8 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh pr edit <number> [--title title] [--body-stdin|--body-file <file|->|--body <text>] [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-stdin|--body-file <file|->|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment create <number> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment update <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment edit <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]",
|
||||
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]",
|
||||
@@ -6767,7 +6837,7 @@ 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 comment create accepts --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.",
|
||||
"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 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.",
|
||||
@@ -6779,10 +6849,10 @@ export function ghHelp(): unknown {
|
||||
"issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation, and the shared #20 guard warns about HWLAB product/user issue routing in favor of pikasTech/HWLAB without refusing the write.",
|
||||
"issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
|
||||
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
|
||||
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
|
||||
"comment update/edit PATCHes /repos/{owner}/{repo}/issues/comments/{comment_id} and preserves the comment id/timeline; comment delete is supported because GitHub supports deleting issue comments, but routine wording fixes should use update/edit. issue/pr hard delete is unsupported and close is the lifecycle alternative.",
|
||||
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
|
||||
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
|
||||
"PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment delete treats --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
|
||||
"PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment update/edit/delete treat --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
|
||||
"PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.",
|
||||
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
|
||||
"PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. Use --full or --raw to include all fetched status contexts; gh pr merge is the separate guarded write path.",
|
||||
@@ -6957,6 +7027,19 @@ 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 === "update" || third === "edit")) {
|
||||
const commandName = `issue comment ${third}`;
|
||||
const resolved = resolvePositionalNumberReference("issue", args, 3, commandName, options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
const commentId = resolved.number;
|
||||
if (typeof commentId !== "number") return commentId;
|
||||
const updateOptions = { ...options, repo: resolved.repo };
|
||||
if (options.dryRun) return withNumberOptionHint(commentUpdate(resolved.repo, "", "issue", commentId, updateOptions, commandName), resolved);
|
||||
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(commentUpdate(resolved.repo, token, "issue", commentId, updateOptions, commandName), resolved);
|
||||
}
|
||||
if (sub === "comment" && third === "create") {
|
||||
const resolved = resolvePositionalIssueReference(args, 3, "issue comment create", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
@@ -7120,6 +7203,17 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(commentDelete(resolved.repo, token, "pr", resolved.number, false), resolved);
|
||||
}
|
||||
if (sub === "comment" && (third === "update" || third === "edit")) {
|
||||
const commandName = `pr comment ${third}`;
|
||||
const resolved = resolvePositionalNumberReference("pr", args, 3, commandName, options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
const updateOptions = { ...options, repo: resolved.repo };
|
||||
if (options.dryRun) return withNumberOptionHint(commentUpdate(resolved.repo, "", "pr", resolved.number, updateOptions, commandName), resolved);
|
||||
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(commentUpdate(resolved.repo, token, "pr", resolved.number, updateOptions, commandName), resolved);
|
||||
}
|
||||
if (sub === "comment" && third === "create") {
|
||||
const resolved = resolvePositionalPrReference(args, 3, "pr comment create", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
@@ -7177,7 +7271,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
return withNumberOptionHint(prMerge(resolved.repo, token, resolved.number, options), resolved);
|
||||
}
|
||||
if (sub !== "list" && !isPrReadCommand(sub)) {
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/delete, and unsupported delete.");
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/update/edit/delete, and unsupported delete.");
|
||||
}
|
||||
if (sub === "read" || sub === "view") {
|
||||
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);
|
||||
|
||||
Reference in New Issue
Block a user