scripts/post-review-thread-reply.mjs
scripts/post-review-thread-reply.mjsBrowse 7 files
2,697 tokens
10,398 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
1#!/usr/bin/env node2 3import { spawnSync } from 'node:child_process';4import { readFileSync, realpathSync } from 'node:fs';5import { resolve } from 'node:path';6import { fileURLToPath } from 'node:url';7 8const EXIT_SUCCESS = 0;9const EXIT_OPERATIONAL = 1;10const EXIT_CLI = 2;11const SUBPROCESS_TIMEOUT_MS = 30_000;12 13function parseCliArgs(argv) {14 const args = argv.slice(2);15 const result = {16 help: false,17 repo: null,18 prNumber: null,19 commentNodeId: null,20 body: null,21 bodyFile: null,22 };23 24 if (args.includes('--help')) {25 result.help = true;26 return result;27 }28 29 for (let index = 0; index < args.length; index += 1) {30 const arg = args[index];31 if (32 arg !== '--repo' &&33 arg !== '--pr' &&34 arg !== '--comment-node-id' &&35 arg !== '--body' &&36 arg !== '--body-file'37 ) {38 throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };39 }40 index += 1;41 if (index >= args.length) {42 throw { code: EXIT_CLI, message: `error: ${arg} requires a value` };43 }44 const value = args[index];45 if (arg === '--repo') result.repo = value;46 if (arg === '--pr') result.prNumber = value;47 if (arg === '--comment-node-id') result.commentNodeId = value;48 if (arg === '--body') result.body = value;49 if (arg === '--body-file') result.bodyFile = value;50 }51 52 if (!result.repo) {53 throw { code: EXIT_CLI, message: 'error: --repo is required (OWNER/REPO)' };54 }55 if (!result.prNumber || !/^[1-9]\d*$/.test(result.prNumber)) {56 throw {57 code: EXIT_CLI,58 message: 'error: --pr is required (positive integer pull request number; e.g. 123)',59 };60 }61 if (!result.commentNodeId) {62 throw { code: EXIT_CLI, message: 'error: --comment-node-id is required' };63 }64 if (result.body === null && result.bodyFile === null) {65 throw { code: EXIT_CLI, message: 'error: provide exactly one of --body or --body-file' };66 }67 if (result.body !== null && result.bodyFile !== null) {68 throw { code: EXIT_CLI, message: 'error: provide only one of --body or --body-file' };69 }70 71 return result;72}73 74function getHelpText() {75 return [76 'Usage:',77 ' post-review-thread-reply.mjs --repo <OWNER/REPO> --pr <NUMBER> --comment-node-id <NODE_ID> (--body <TEXT> | --body-file <PATH>)',78 '',79 'Purpose:',80 ' Post acknowledgement to a review-target node and exit with a JSON result.',81 '',82 ' Behaviour by node type (auto-detected via GraphQL):',83 ' * PullRequestReviewComment (inline thread comment, PRRC_…): post an inline',84 ' reply via repos/{repo}/pulls/{pr}/comments with in_reply_to.',85 ' * PullRequestReview (review body, PRR_…): review bodies do not accept inline',86 ' replies, so post a top-level PR issue comment via',87 ' repos/{repo}/issues/{pr}/comments. The response kind is "issue_comment".',88 '',89 ' Anything else exits with a clear "unsupported node kind" error.',90 ].join('\n');91}92 93function run(command, args, input = null) {94 const result = spawnSync(command, args, {95 encoding: 'utf8',96 input: input ?? undefined,97 timeout: SUBPROCESS_TIMEOUT_MS,98 });99 if (result.error) {100 if (result.error.code === 'ETIMEDOUT') {101 throw new Error(`error: ${command} timed out after ${SUBPROCESS_TIMEOUT_MS / 1000} seconds`);102 }103 throw new Error(`error: failed to execute ${command}: ${result.error.message}`);104 }105 if (result.signal) {106 throw new Error(`error: ${command} was terminated by signal ${result.signal}`);107 }108 if (result.status !== 0) {109 throw new Error(110 `error: ${command} ${args.join(' ')} failed: ${result.stderr || result.stdout}`.trim(),111 );112 }113 return result.stdout;114}115 116function assertCommandAvailable(command, installHint) {117 const probe = spawnSync(command, ['--version'], {118 encoding: 'utf8',119 timeout: SUBPROCESS_TIMEOUT_MS,120 });121 if (probe.error?.code === 'ETIMEDOUT') {122 throw new Error(123 `error: required dependency "${command}" timed out after ${SUBPROCESS_TIMEOUT_MS / 1000} seconds.`,124 );125 }126 if (probe.signal) {127 throw new Error(128 `error: required dependency "${command}" was terminated by signal ${probe.signal}.`,129 );130 }131 if (probe.error || probe.status !== 0) {132 throw new Error(133 `error: required dependency "${command}" is not available. Install ${installHint} and retry.`,134 );135 }136}137 138function parseApiResponse(jsonText, contextDescription) {139 let parsed;140 try {141 parsed = JSON.parse(jsonText);142 } catch (parseError) {143 throw new Error(`error: failed to parse ${contextDescription}: ${parseError.message}`);144 }145 if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {146 const messages = parsed.errors147 .map((err) =>148 typeof err?.message === 'string' && err.message.length > 0149 ? err.message150 : JSON.stringify(err),151 )152 .join('; ');153 throw new Error(`error: ${messages}`);154 }155 return parsed;156}157 158function resolveTargetNode(commentNodeId) {159 const query = [160 'query($id:ID!){',161 ' node(id:$id){',162 ' __typename',163 ' ... on PullRequestReviewComment {',164 ' databaseId',165 ' pullRequest { number repository { nameWithOwner } }',166 ' }',167 ' ... on PullRequestReview {',168 ' databaseId',169 ' pullRequest { number repository { nameWithOwner } }',170 ' }',171 ' }',172 '}',173 ].join('\n');174 const response = run('gh', [175 'api',176 'graphql',177 '-f',178 `query=${query}`,179 '-F',180 `id=${commentNodeId}`,181 ]);182 const parsed = parseApiResponse(response, 'GraphQL node lookup response');183 const node = parsed?.data?.node;184 if (!node || typeof node !== 'object') {185 throw new Error(`error: GraphQL node lookup returned no node for id ${commentNodeId}`);186 }187 const typename = node.__typename;188 const databaseId =189 typeof node.databaseId === 'number'190 ? node.databaseId191 : typeof node.databaseId === 'string' && node.databaseId.length > 0192 ? Number.parseInt(node.databaseId, 10)193 : null;194 if (typename !== 'PullRequestReviewComment' && typename !== 'PullRequestReview') {195 throw new Error(196 `error: unsupported node kind "${typename ?? 'unknown'}" for ${commentNodeId} (expected PullRequestReviewComment or PullRequestReview)`,197 );198 }199 if (databaseId === null || Number.isNaN(databaseId)) {200 throw new Error(`error: failed to resolve databaseId for ${typename} node ${commentNodeId}`);201 }202 const repo =203 typeof node.pullRequest?.repository?.nameWithOwner === 'string'204 ? node.pullRequest.repository.nameWithOwner205 : null;206 const prNumber = typeof node.pullRequest?.number === 'number' ? node.pullRequest.number : null;207 if (!repo || prNumber === null) {208 throw new Error(209 `error: GraphQL node lookup did not return owning repo and PR for ${commentNodeId}`,210 );211 }212 return { kind: typename, databaseId, repo, prNumber };213}214 215function assertNodeBelongsTo(target, expectedRepo, expectedPrNumber, commentNodeId) {216 if (target.repo.toLowerCase() !== expectedRepo.toLowerCase()) {217 throw new Error(218 `error: ${commentNodeId} belongs to ${target.repo}#${target.prNumber}, not ${expectedRepo}#${expectedPrNumber}`,219 );220 }221 if (target.prNumber !== expectedPrNumber) {222 throw new Error(223 `error: ${commentNodeId} belongs to ${target.repo}#${target.prNumber}, not ${expectedRepo}#${expectedPrNumber}`,224 );225 }226}227 228function readBody(body, bodyFile) {229 if (body !== null) {230 return body;231 }232 return readFileSync(resolve(bodyFile), 'utf8');233}234 235function postInlineReply(repo, prNumber, body, inReplyToDatabaseId) {236 const response = run('gh', [237 'api',238 `repos/${repo}/pulls/${prNumber}/comments`,239 '--method',240 'POST',241 '-f',242 `body=${body}`,243 '-F',244 `in_reply_to=${inReplyToDatabaseId}`,245 ]);246 const parsed = parseApiResponse(response, 'inline-reply REST response');247 if (typeof parsed?.id !== 'number') {248 throw new Error('error: reply was posted but response did not include a numeric comment id');249 }250 return parsed.id;251}252 253function postIssueComment(repo, prNumber, body) {254 const response = run('gh', [255 'api',256 `repos/${repo}/issues/${prNumber}/comments`,257 '--method',258 'POST',259 '-f',260 `body=${body}`,261 ]);262 const parsed = parseApiResponse(response, 'issue-comment REST response');263 if (typeof parsed?.id !== 'number') {264 throw new Error(265 'error: top-level PR comment was posted but response did not include a numeric comment id',266 );267 }268 return parsed.id;269}270 271async function main() {272 const args = parseCliArgs(process.argv);273 if (args.help) {274 process.stdout.write(`${getHelpText()}\n`);275 process.exit(EXIT_SUCCESS);276 }277 278 assertCommandAvailable('gh', 'GitHub CLI (`gh`)');279 280 const target = resolveTargetNode(args.commentNodeId);281 assertNodeBelongsTo(target, args.repo, Number.parseInt(args.prNumber, 10), args.commentNodeId);282 const body = readBody(args.body, args.bodyFile);283 284 if (target.kind === 'PullRequestReviewComment') {285 const replyId = postInlineReply(args.repo, args.prNumber, body, target.databaseId);286 process.stdout.write(287 `${JSON.stringify({288 ok: true,289 kind: 'review_thread_reply',290 replyCommentId: replyId,291 inReplyTo: target.databaseId,292 commentNodeId: args.commentNodeId,293 })}\n`,294 );295 return;296 }297 298 const issueCommentId = postIssueComment(args.repo, args.prNumber, body);299 process.stdout.write(300 `${JSON.stringify({301 ok: true,302 kind: 'issue_comment',303 issueCommentId,304 reviewDatabaseId: target.databaseId,305 commentNodeId: args.commentNodeId,306 note: 'PullRequestReview targets do not accept inline replies; posted a top-level PR issue comment instead.',307 })}\n`,308 );309}310 311const isMain = (() => {312 try {313 const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;314 const currentModulePath = realpathSync(fileURLToPath(import.meta.url));315 return invokedScriptPath !== null && invokedScriptPath === currentModulePath;316 } catch {317 return false;318 }319})();320 321if (isMain) {322 main().catch((error) => {323 const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;324 const message = error?.message ? String(error.message) : String(error);325 process.stderr.write(`${message}\n`);326 process.exit(code);327 });328}329