// 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 { let bodyInput: { body: string; bodySource: Record }; 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(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 { 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, }; } export 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 }; 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 --repo ${repo} --json comments`, full: `bun scripts/cli.ts gh issue view --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 --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 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(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 { 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 { 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(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 { const command = state === "closed" ? "issue close" : "issue reopen"; let lifecycleComment: { body: string; bodySource: Record } | 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 | null = null; if (lifecycleComment !== null) { const comment = await githubRequest(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(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, }; }