scripts/fetch-review-state.mjs
scripts/fetch-review-state.mjsBrowse 11 files
4,053 tokens
16,191 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
1#!/usr/bin/env node2 3import { spawnSync } from 'node:child_process';4import { realpathSync } from 'node:fs';5import { mkdir, writeFile } from 'node:fs/promises';6import { dirname, resolve } from 'node:path';7import { fileURLToPath } from 'node:url';8import { renderReviewStateMarkdown as renderReviewStateMarkdownImpl } from './render-review-state.mjs';9import {10 assertReviewStateV2,11 formatCanonicalJson,12 normalizeReviewStateV2,13} from './review-artifacts.mjs';14 15const EXIT_SUCCESS = 0;16const EXIT_OPERATIONAL = 1;17const EXIT_CLI = 2;18 19const SPAWN_MAX_BUFFER_BYTES = 16 * 1024 * 1024;20const SUBPROCESS_TIMEOUT_MS = 30_000;21 22const THREADS_QUERY = `23 query($owner: String!, $repo: String!, $number: Int!, $threadsCursor: String) {24 repository(owner: $owner, name: $repo) {25 pullRequest(number: $number) {26 id27 url28 number29 title30 state31 headRefName32 baseRefName33 updatedAt34 reviewThreads(first: 100, after: $threadsCursor) {35 pageInfo { hasNextPage endCursor }36 nodes {37 id38 isResolved39 isOutdated40 path41 startLine42 line43 originalStartLine44 originalLine45 comments(first: 100) {46 pageInfo { hasNextPage endCursor }47 nodes {48 id49 url50 author { login }51 createdAt52 body53 reactionGroups { content users { totalCount } }54 }55 }56 }57 }58 }59 }60 }61`;62 63const THREAD_COMMENTS_QUERY = `64 query($threadId: ID!, $cursor: String) {65 node(id: $threadId) {66 ... on PullRequestReviewThread {67 comments(first: 100, after: $cursor) {68 pageInfo { hasNextPage endCursor }69 nodes {70 id71 url72 author { login }73 createdAt74 body75 reactionGroups { content users { totalCount } }76 }77 }78 }79 }80 }81`;82 83const REVIEWS_QUERY = `84 query($owner: String!, $repo: String!, $number: Int!, $reviewsCursor: String) {85 repository(owner: $owner, name: $repo) {86 pullRequest(number: $number) {87 reviews(first: 100, after: $reviewsCursor) {88 pageInfo { hasNextPage endCursor }89 nodes {90 id91 url92 author { login }93 state94 submittedAt95 body96 reactionGroups { content users { totalCount } }97 }98 }99 }100 }101 }102`;103 104const COMMENTS_QUERY = `105 query($owner: String!, $repo: String!, $number: Int!, $commentsCursor: String) {106 repository(owner: $owner, name: $repo) {107 pullRequest(number: $number) {108 comments(first: 100, after: $commentsCursor) {109 pageInfo { hasNextPage endCursor }110 nodes {111 id112 url113 author { login }114 createdAt115 body116 reactionGroups { content users { totalCount } }117 }118 }119 }120 }121 }122`;123 124function getHelpText() {125 return [126 'Usage:',127 ' fetch-review-state.mjs [--pr <url>] [--out <path.md>|-] [--out-json <path.json>|-] [--help]',128 '',129 'Purpose:',130 ' Fetch unresolved review threads, submitted review bodies, and PR issue comments.',131 ' Emit canonical review-state.json (v2 script-first schema). Markdown is derived output.',132 '',133 'Flags:',134 ' --pr <url> GitHub pull request URL (for example: https://github.com/OWNER/REPO/pull/123).',135 ' If omitted, the script attempts to discover the PR for the current git branch.',136 ' --out <path.md>|- Markdown output path. Use "-" to write markdown to stdout. Omit to skip markdown output.',137 ' --out-json <path.json>|-',138 ' JSON output path. If omitted and --out is a file path, defaults to same path with .json.',139 ' --help Show this help text and exit.',140 ].join('\n');141}142 143function parseCliArgs(argv) {144 const args = argv.slice(2);145 const result = { prUrl: null, outPath: null, outJsonPath: null, help: false };146 if (args.includes('--help')) {147 result.help = true;148 return result;149 }150 151 const knownFlags = new Set(['--pr', '--out', '--out-json']);152 let index = 0;153 while (index < args.length) {154 const arg = args[index];155 if (!arg.startsWith('--') || !knownFlags.has(arg)) {156 throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };157 }158 159 index += 1;160 if (index >= args.length) {161 throw { code: EXIT_CLI, message: `error: ${arg} requires a value` };162 }163 164 const value = args[index];165 if (arg === '--pr') {166 result.prUrl = value;167 } else if (arg === '--out') {168 result.outPath = value;169 } else if (arg === '--out-json') {170 result.outJsonPath = value;171 }172 index += 1;173 }174 175 if (result.outPath !== null && result.outPath !== '-' && !result.outPath.endsWith('.md')) {176 throw { code: EXIT_CLI, message: 'error: --out file path must end with .md' };177 }178 if (179 result.outJsonPath !== null &&180 result.outJsonPath !== '-' &&181 !result.outJsonPath.endsWith('.json')182 ) {183 throw { code: EXIT_CLI, message: 'error: --out-json file path must end with .json' };184 }185 if (result.outPath === '-' && result.outJsonPath === '-') {186 throw { code: EXIT_CLI, message: 'error: --out - cannot be combined with --out-json -' };187 }188 189 return result;190}191 192function renderReviewStateMarkdown(payload, options) {193 return renderReviewStateMarkdownImpl(payload, options);194}195 196function parsePrUrl(url) {197 if (typeof url !== 'string' || url.trim() === '') {198 return null;199 }200 201 const match = url202 .trim()203 .match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/)?(?:#.*)?$/i);204 if (!match) {205 return null;206 }207 208 return {209 owner: match[1],210 repo: match[2].replace(/\.git$/, ''),211 number: Number.parseInt(match[3], 10),212 };213}214 215function runSync(command, args, input) {216 const result = spawnSync(command, args, {217 encoding: 'utf-8',218 input: input ?? undefined,219 maxBuffer: SPAWN_MAX_BUFFER_BYTES,220 timeout: SUBPROCESS_TIMEOUT_MS,221 });222 if (result.error) {223 let detail;224 if (result.error.code === 'ETIMEDOUT') {225 detail = `${command} timed out after ${SUBPROCESS_TIMEOUT_MS / 1000} seconds`;226 } else if (result.error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {227 detail = `${command} output exceeded ${SPAWN_MAX_BUFFER_BYTES} bytes; raise SPAWN_MAX_BUFFER_BYTES`;228 } else {229 detail = `failed to execute ${command}: ${result.error.message}`;230 }231 return { stdout: '', stderr: detail, status: result.status ?? 1 };232 }233 if (result.signal) {234 return {235 stdout: result.stdout ?? '',236 stderr: `${command} was terminated by signal ${result.signal}`,237 status: result.status ?? 1,238 };239 }240 return { stdout: result.stdout, stderr: result.stderr, status: result.status };241}242 243function checkPreconditions({ requireGit }) {244 if (requireGit) {245 const git = runSync('which', ['git']);246 if (git.status !== 0) {247 return { ok: false, code: EXIT_OPERATIONAL, message: 'error: git not found on PATH' };248 }249 }250 const gh = runSync('which', ['gh']);251 if (gh.status !== 0) {252 return { ok: false, code: EXIT_OPERATIONAL, message: 'error: gh not found on PATH' };253 }254 255 const auth = runSync('gh', ['auth', 'status']);256 if (auth.status !== 0) {257 return {258 ok: false,259 code: EXIT_OPERATIONAL,260 message: 'error: gh is not authenticated; run "gh auth login" and try again',261 };262 }263 264 return { ok: true };265}266 267function getCurrentBranch() {268 const result = runSync('git', ['rev-parse', '--abbrev-ref', 'HEAD']);269 if (result.status !== 0) {270 return null;271 }272 return result.stdout.trim();273}274 275function discoverPrUrl(branchName) {276 const result = runSync('gh', [277 'pr',278 'list',279 '--head',280 branchName,281 '--state',282 'all',283 '--json',284 'url',285 ]);286 287 if (result.status !== 0) {288 return { code: EXIT_OPERATIONAL, error: 'error: gh pr list failed' };289 }290 291 let list;292 try {293 list = JSON.parse(result.stdout);294 } catch {295 return { code: EXIT_OPERATIONAL, error: 'error: gh pr list returned invalid JSON' };296 }297 298 if (!Array.isArray(list) || list.length === 0) {299 return {300 code: EXIT_OPERATIONAL,301 error: `error: no pull request found for current branch "${branchName}"; pass --pr <url>`,302 };303 }304 305 if (list.length > 1) {306 return {307 code: EXIT_OPERATIONAL,308 error: `error: multiple pull requests found for current branch "${branchName}"; pass --pr <url>`,309 };310 }311 312 return { url: list[0].url };313}314 315function fetchGraphQL(query, variables) {316 const body = JSON.stringify({ query, variables });317 const result = runSync('gh', ['api', 'graphql', '--input', '-'], body);318 if (result.status !== 0) {319 return { code: EXIT_OPERATIONAL, error: result.stderr || 'error: GitHub API request failed' };320 }321 322 try {323 const parsed = JSON.parse(result.stdout);324 if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {325 const messages = parsed.errors326 .map((err) =>327 typeof err?.message === 'string' && err.message.length > 0328 ? err.message329 : JSON.stringify(err),330 )331 .join('; ');332 return {333 code: EXIT_OPERATIONAL,334 error: `error: GitHub GraphQL returned errors: ${messages}`,335 };336 }337 return { data: parsed };338 } catch {339 return { code: EXIT_OPERATIONAL, error: 'error: GitHub API returned invalid JSON' };340 }341}342 343function paginateConnection(owner, repo, number, query, cursorVar, cursorValue) {344 const response = fetchGraphQL(query, {345 owner,346 repo,347 number,348 [cursorVar]: cursorValue ?? null,349 });350 351 if (response.error) {352 return response;353 }354 355 const pr = response.data?.data?.repository?.pullRequest;356 if (!pr) {357 return { code: EXIT_OPERATIONAL, error: 'error: pull request not found in GraphQL response' };358 }359 360 return { pr };361}362 363function paginateThreadComments(threadId, cursor) {364 const response = fetchGraphQL(THREAD_COMMENTS_QUERY, { threadId, cursor: cursor ?? null });365 if (response.error) {366 return response;367 }368 const connection = response.data?.data?.node?.comments;369 if (!connection) {370 return {371 code: EXIT_OPERATIONAL,372 error: 'error: thread comments connection missing in GraphQL response',373 };374 }375 return { connection };376}377 378function paginateAll(owner, repo, number) {379 let pr = null;380 let reviewThreads = [];381 let threadCursor = null;382 383 for (;;) {384 const page = paginateConnection(385 owner,386 repo,387 number,388 THREADS_QUERY,389 'threadsCursor',390 threadCursor,391 );392 if (page.error) {393 return page;394 }395 pr = page.pr;396 const connection = page.pr.reviewThreads;397 reviewThreads = reviewThreads.concat(connection?.nodes ?? []);398 if (!connection?.pageInfo?.hasNextPage) {399 break;400 }401 threadCursor = connection.pageInfo.endCursor;402 }403 404 for (const thread of reviewThreads) {405 let commentCursor = thread?.comments?.pageInfo?.endCursor ?? null;406 while (thread?.comments?.pageInfo?.hasNextPage) {407 const next = paginateThreadComments(thread.id, commentCursor);408 if (next.error) {409 return next;410 }411 thread.comments.nodes = (thread.comments.nodes ?? []).concat(next.connection.nodes ?? []);412 thread.comments.pageInfo = next.connection.pageInfo ?? {413 hasNextPage: false,414 endCursor: null,415 };416 commentCursor = thread.comments.pageInfo.endCursor;417 }418 }419 420 let reviews = [];421 let reviewCursor = null;422 for (;;) {423 const page = paginateConnection(424 owner,425 repo,426 number,427 REVIEWS_QUERY,428 'reviewsCursor',429 reviewCursor,430 );431 if (page.error) {432 return page;433 }434 const connection = page.pr.reviews;435 reviews = reviews.concat(connection?.nodes ?? []);436 if (!connection?.pageInfo?.hasNextPage) {437 break;438 }439 reviewCursor = connection.pageInfo.endCursor;440 }441 442 let issueComments = [];443 let commentsCursor = null;444 for (;;) {445 const page = paginateConnection(446 owner,447 repo,448 number,449 COMMENTS_QUERY,450 'commentsCursor',451 commentsCursor,452 );453 if (page.error) {454 return page;455 }456 const connection = page.pr.comments;457 issueComments = issueComments.concat(connection?.nodes ?? []);458 if (!connection?.pageInfo?.hasNextPage) {459 break;460 }461 commentsCursor = connection.pageInfo.endCursor;462 }463 464 return {465 pr,466 reviewThreads,467 reviews,468 issueComments,469 };470}471 472function deriveOutJsonPath(outPath, outJsonPath) {473 if (outJsonPath) {474 return outJsonPath;475 }476 if (!outPath || outPath === '-') {477 return null;478 }479 return outPath.replace(/\.md$/i, '.json');480}481 482async function writeOutput(outPath, text) {483 if (!outPath || outPath === '-') {484 process.stdout.write(text);485 return;486 }487 await mkdir(dirname(outPath), { recursive: true });488 await writeFile(outPath, text, 'utf8');489}490 491async function main() {492 let options;493 try {494 options = parseCliArgs(process.argv);495 } catch (error) {496 process.stderr.write(`${error.message}\n`);497 process.exit(error.code ?? EXIT_CLI);498 }499 500 if (options.help) {501 process.stdout.write(`${getHelpText()}\n`);502 process.exit(EXIT_SUCCESS);503 }504 505 const preconditions = checkPreconditions({ requireGit: !options.prUrl });506 if (!preconditions.ok) {507 process.stderr.write(`${preconditions.message}\n`);508 process.exit(preconditions.code);509 }510 511 let prUrl = options.prUrl;512 let sourceBranch = null;513 if (!prUrl) {514 sourceBranch = getCurrentBranch();515 if (!sourceBranch || sourceBranch === 'HEAD') {516 process.stderr.write(517 'error: cannot discover PR when in detached HEAD state; pass --pr <url>\n',518 );519 process.exit(EXIT_OPERATIONAL);520 }521 const discovered = discoverPrUrl(sourceBranch);522 if (discovered.error) {523 process.stderr.write(`${discovered.error}\n`);524 process.exit(discovered.code ?? EXIT_OPERATIONAL);525 }526 prUrl = discovered.url;527 }528 529 const parsedPr = parsePrUrl(prUrl);530 if (!parsedPr) {531 process.stderr.write(532 'error: invalid --pr value (expected GitHub PR URL like https://github.com/OWNER/REPO/pull/123)\n',533 );534 process.exit(EXIT_CLI);535 }536 537 if (!sourceBranch) {538 sourceBranch = getCurrentBranch();539 if (sourceBranch === 'HEAD') {540 sourceBranch = null;541 }542 }543 544 const payload = paginateAll(parsedPr.owner, parsedPr.repo, parsedPr.number);545 if (payload.error) {546 process.stderr.write(`${payload.error}\n`);547 process.exit(payload.code ?? EXIT_OPERATIONAL);548 }549 550 const fetchedAt = new Date().toISOString();551 const reviewState = normalizeReviewStateV2({552 fetchedAt,553 sourceBranch,554 pr: payload.pr,555 reviewThreads: payload.reviewThreads,556 reviews: payload.reviews,557 issueComments: payload.issueComments,558 });559 assertReviewStateV2(reviewState);560 561 const jsonText = formatCanonicalJson(reviewState);562 const outJsonPath = deriveOutJsonPath(options.outPath, options.outJsonPath);563 const markdown = renderReviewStateMarkdown(reviewState, {564 sourcePath: outJsonPath && outJsonPath !== '-' ? outJsonPath : undefined,565 });566 567 if (options.outPath !== null) {568 await writeOutput(options.outPath, markdown);569 }570 if (outJsonPath) {571 await writeOutput(outJsonPath, jsonText);572 }573 574 process.exit(EXIT_SUCCESS);575}576 577const isMain = (function computeIsMain() {578 try {579 const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;580 const currentModulePath = realpathSync(fileURLToPath(import.meta.url));581 return invokedScriptPath !== null && invokedScriptPath === currentModulePath;582 } catch {583 return false;584 }585})();586 587if (isMain) {588 main().catch((error) => {589 const message = error?.message ? String(error.message) : String(error);590 process.stderr.write(`${message}\n`);591 process.exit(EXIT_OPERATIONAL);592 });593}594 595export { deriveOutJsonPath, parseCliArgs, parsePrUrl, renderReviewStateMarkdown };596