08e6ee575b
Co-authored-by: Codex <codex@noreply.local>
368 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|