Files
pikasTech-unidesk/scripts/src/gh/comments-and-state.ts
T
2026-06-26 00:57:39 +08:00

368 lines
15 KiB
TypeScript

// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
// Moved mechanically from scripts/src/gh.ts:7148-7472.
import path from "node:path";
import { bodySha, preview, previewLines, readIssueLifecycleCommentBody, repoParts } from "./auth-and-safety";
import { assertConcurrencyOptions, validateCommentConcurrency } from "./body-guards";
import { applyVirtualBodyPatch, bodyPatchErrorSummary, readBodyPatch, readIssueCommentBody } from "./body-input";
import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, issueCommentReadCommands, validationError, writeBodyPlan } from "./client";
import { getIssueComment } from "./issue-read";
import { issueLifecycleDisclosure, issueLifecycleSummary } from "./issue-summary";
import { isRecord } from "./notify-claudeqq";
import { commentSummary, compactCommentSummary, ghShort, ghTable, ghText } from "./render";
import type { GitHubCommandResult, GitHubComment, GitHubIssue, GitHubOptions } from "./types";
export function withIssueCommentCreateRendered(result: GitHubCommandResult): GitHubCommandResult {
return {
...result,
contentType: "text/plain",
renderedText: renderIssueCommentCreateTable(result),
};
}
export function renderIssueCommentCreateTable(result: GitHubCommandResult): string {
const comment = isRecord(result.comment) ? result.comment : {};
const readCommands = isRecord(result.readCommands) ? result.readCommands : {};
const status = result.dryRun === true ? "dry-run" : "created";
const issueNumber = ghText(result.issueNumber);
const commentId = ghText(comment.id ?? result.commentId);
const sourceKind = isRecord(result.bodySource) ? result.bodySource.kind : result.source;
const lines = [
`gh issue comment create (${status})`,
"",
ghTable(["ISSUE", "COMMENT", "STATUS", "BODY", "URL"], [[
issueNumber === "-" ? "-" : `#${issueNumber}`,
commentId,
status,
`${ghText(result.bodyChars ?? comment.bodyChars)} chars`,
ghShort(ghText(comment.url ?? result.url), 96),
]]),
"",
"Summary:",
` repo=${ghText(result.repo)} source=${ghText(sourceKind)} bodySha=${ghText(result.bodySha ?? comment.bodySha)}`,
"",
"Next:",
` ${ghText(readCommands.comments ?? readCommands.full ?? readCommands.raw)}`,
"",
"Disclosure:",
" default view is a bounded table; use --full/--raw or the read command for full comment metadata/body.",
];
return lines.join("\n");
}
export 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, "issue comment create");
} catch (error) {
return validationError("issue comment create", repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const { body, bodySource } = bodyInput;
if (options.dryRun) {
return withIssueCommentCreateRendered({
ok: true,
command: "issue comment create",
repo,
dryRun: true,
planned: true,
issueNumber,
readCommands: issueCommentReadCommands(repo, issueNumber),
...writeBodyPlan("issue comment create", repo, body, bodySource, { issueNumber }),
});
}
const { owner, name } = repoParts(repo);
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
return withIssueCommentCreateRendered({
ok: true,
command: "issue comment create",
repo,
issueNumber,
comment: compactCommentSummary(comment),
bodySource,
bodyChars: body.length,
bodySha: bodySha(body),
bodyPreview: preview(body),
source: String(bodySource.kind ?? "unknown"),
readCommands: issueCommentReadCommands(repo, issueNumber),
rest: true,
});
}
export 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,
};
}
export 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,
};
}
export async function commentView(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, includeBody: boolean, commandName?: string): Promise<GitHubCommandResult> {
const command = commandName ?? `${ownerKind} comment view`;
const comment = await getIssueComment(token, repo, commentId);
if (isGitHubError(comment)) return commandError(command, repo, comment, { commentId });
const body = comment.body ?? "";
return {
ok: true,
command,
repo,
commentId,
comment: includeBody ? commentSummary(comment) : compactCommentSummary(comment),
bodyChars: body.length,
bodySha: bodySha(body),
bodyOmitted: !includeBody,
fullBodyIncluded: includeBody,
readCommands: {
compact: `bun scripts/cli.ts gh ${ownerKind} comment view ${commentId} --repo ${repo}`,
full: `bun scripts/cli.ts gh ${ownerKind} comment view ${commentId} --repo ${repo} --full`,
raw: `bun scripts/cli.ts gh ${ownerKind} comment view ${commentId} --repo ${repo} --raw`,
},
request: {
method: "GET",
path: `/repos/{owner}/{repo}/issues/comments/${commentId}`,
},
rest: true,
};
}
export 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);
if (dryRun) {
return {
ok: true,
command,
repo,
dryRun: true,
planned: true,
commentId,
request: { method: "DELETE", path: `/repos/{owner}/{repo}/issues/comments/${commentId}` },
};
}
const result = await githubRequest<null>(token, "DELETE", `/repos/${owner}/${name}/issues/comments/${commentId}`);
if (isGitHubError(result)) return commandError(command, repo, result, { commentId });
return {
ok: true,
command,
repo,
commentId,
deleted: true,
request: { method: "DELETE", path: `/repos/${owner}/${name}/issues/comments/${commentId}` },
rest: true,
};
}
export async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean, options?: GitHubOptions): Promise<GitHubCommandResult> {
const command = state === "closed" ? "issue close" : "issue reopen";
let lifecycleComment: { body: string; bodySource: Record<string, unknown> } | null = null;
try {
lifecycleComment = options === undefined ? null : readIssueLifecycleCommentBody(options, command);
} catch (error) {
return validationError(command, repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
if (dryRun) {
return {
ok: true,
command,
dryRun: true,
repo,
issueNumber,
disclosure: issueLifecycleDisclosure(repo, issueNumber, true),
readCommands: issueBodyReadCommands(repo, issueNumber),
comment: lifecycleComment === null
? null
: {
planned: true,
...writeBodyPlan("issue comment create", repo, lifecycleComment.body, lifecycleComment.bodySource, { issueNumber }),
},
wouldPatch: { state },
};
}
const { owner, name } = repoParts(repo);
let commentSummary: Record<string, unknown> | null = null;
if (lifecycleComment !== null) {
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body: lifecycleComment.body });
if (isGitHubError(comment)) {
return commandError(command, repo, comment, {
issueNumber,
phase: "comment",
commentBodySource: lifecycleComment.bodySource,
commentBodyChars: lifecycleComment.body.length,
commentBodySha: bodySha(lifecycleComment.body),
});
}
commentSummary = compactCommentSummary(comment);
}
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state });
if (isGitHubError(issue)) return commandError(command, repo, issue, { issueNumber, phase: "state", comment: commentSummary });
return {
ok: true,
command,
repo,
comment: commentSummary,
issue: issueLifecycleSummary(issue),
disclosure: issueLifecycleDisclosure(repo, issueNumber, false),
readCommands: issueBodyReadCommands(repo, issueNumber),
rest: true,
};
}