scripts/render-review-state.mjs
scripts/render-review-state.mjsBrowse 11 files
1,973 tokens
7,658 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
1#!/usr/bin/env node2 3import { realpathSync } from 'node:fs';4import { mkdir, readFile, writeFile } from 'node:fs/promises';5import { dirname, resolve } from 'node:path';6import { fileURLToPath } from 'node:url';7 8import { assertReviewStateV2 } from './review-artifacts.mjs';9 10const EXIT_SUCCESS = 0;11const EXIT_OPERATIONAL = 1;12const EXIT_CLI = 2;13 14function getHelpText() {15 return [16 'Usage:',17 ' render-review-state.mjs --in <review-state.json> [--out <review-state.md>|-] [--help]',18 '',19 'Purpose:',20 ' Render deterministic Markdown (review-state.md) from review-state.json.',21 '',22 'Flags:',23 ' --in <path.json> Input path to review-state.json.',24 ' --out <path.md>|- Markdown output path. Use "-" to write to stdout. Defaults to stdout.',25 ' --help Show this help text and exit.',26 ].join('\n');27}28 29function parseCliArgs(argv) {30 const args = argv.slice(2);31 const result = { inPath: null, outPath: null, help: false };32 if (args.includes('--help')) {33 result.help = true;34 return result;35 }36 37 const knownFlags = new Set(['--in', '--out']);38 let index = 0;39 while (index < args.length) {40 const arg = args[index];41 if (!arg.startsWith('--') || !knownFlags.has(arg)) {42 throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };43 }44 45 index += 1;46 if (index >= args.length) {47 throw { code: EXIT_CLI, message: `error: ${arg} requires a value` };48 }49 50 const value = args[index];51 if (arg === '--in') {52 result.inPath = value;53 } else if (arg === '--out') {54 result.outPath = value;55 }56 index += 1;57 }58 59 if (!result.inPath) {60 throw { code: EXIT_CLI, message: 'error: --in is required' };61 }62 if (result.inPath === '-') {63 throw { code: EXIT_CLI, message: 'error: --in - is not supported' };64 }65 if (result.inPath !== '-' && !result.inPath.endsWith('.json')) {66 throw { code: EXIT_CLI, message: 'error: --in file path must end with .json' };67 }68 if (result.outPath !== null && result.outPath !== '-' && !result.outPath.endsWith('.md')) {69 throw { code: EXIT_CLI, message: 'error: --out file path must end with .md' };70 }71 72 return result;73}74 75function escapeTableCell(value) {76 return String(value ?? '')77 .replace(/\r?\n/g, ' ')78 .replace(/\\/g, '\\\\')79 .replace(/\|/g, '\\|')80 .replace(/\s+/g, ' ')81 .trim();82}83 84function formatCodeSpan(value) {85 const text = String(value ?? '')86 .replace(/\r?\n/g, ' ')87 .replace(/\|/g, '\\|')88 .replace(/\s+/g, ' ')89 .trim();90 const longestBacktickRun = (text.match(/`+/g) ?? []).reduce(91 (max, run) => Math.max(max, run.length),92 0,93 );94 const fence = '`'.repeat(longestBacktickRun + 1);95 const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : '';96 return `${fence}${pad}${text}${pad}${fence}`;97}98 99function formatLines(startLine, endLine) {100 if (Number.isInteger(startLine) && Number.isInteger(endLine)) {101 return `${startLine}-${endLine}`;102 }103 if (Number.isInteger(startLine)) {104 return String(startLine);105 }106 if (Number.isInteger(endLine)) {107 return String(endLine);108 }109 return '';110}111 112function summarizeBody(body, maxLength = 180) {113 const normalized = String(body ?? '')114 .replace(/\r?\n/g, ' ')115 .replace(/\s+/g, ' ')116 .trim();117 if (normalized.length <= maxLength) {118 return normalized;119 }120 return `${normalized.slice(0, maxLength - 1)}…`;121}122 123function formatAuthorLogin(author) {124 return typeof author?.login === 'string' && author.login.length > 0 ? author.login : '<deleted>';125}126 127export function renderReviewStateMarkdown(payload, { sourcePath }) {128 assertReviewStateV2(payload);129 130 const source = formatCodeSpan(sourcePath || 'review-state.json');131 const lines = [];132 133 lines.push('# Review State');134 lines.push('');135 lines.push(`PR: ${escapeTableCell(payload.pr.url)}`);136 lines.push(`Source: ${source}`);137 lines.push(`FetchedAt: ${escapeTableCell(payload.fetchedAt)}`);138 lines.push(`SourceBranch: ${escapeTableCell(payload.sourceBranch)}`);139 lines.push('');140 lines.push(`Unresolved threads: ${payload.reviewThreads.length}`);141 lines.push(`Reviews with body: ${payload.reviews.length}`);142 lines.push(`Issue comments: ${payload.issueComments.length}`);143 lines.push('');144 145 lines.push('## Unresolved Review Threads');146 lines.push('');147 lines.push('| Node ID | Path | Lines | Outdated | Comments | Primary comment |');148 lines.push('| --- | --- | --- | --- | --- | --- |');149 for (const thread of payload.reviewThreads) {150 const primaryComment = thread.comments[0];151 lines.push(152 [153 escapeTableCell(thread.nodeId),154 escapeTableCell(thread.path),155 escapeTableCell(formatLines(thread.startLine, thread.endLine)),156 thread.isOutdated ? 'yes' : 'no',157 escapeTableCell(thread.comments.length),158 escapeTableCell(summarizeBody(primaryComment?.body ?? '')),159 ]160 .join(' | ')161 .replace(/^/, '| ')162 .replace(/$/, ' |'),163 );164 }165 lines.push('');166 167 lines.push('## Reviews With Body');168 lines.push('');169 lines.push('| Node ID | Author | State | Submitted At | URL | Body excerpt |');170 lines.push('| --- | --- | --- | --- | --- | --- |');171 for (const review of payload.reviews) {172 lines.push(173 [174 escapeTableCell(review.nodeId),175 escapeTableCell(formatAuthorLogin(review.author)),176 escapeTableCell(review.state),177 escapeTableCell(review.submittedAt),178 escapeTableCell(review.url),179 escapeTableCell(summarizeBody(review.body)),180 ]181 .join(' | ')182 .replace(/^/, '| ')183 .replace(/$/, ' |'),184 );185 }186 lines.push('');187 188 lines.push('## Issue Comments');189 lines.push('');190 lines.push('| Node ID | Author | Created At | URL | Body excerpt |');191 lines.push('| --- | --- | --- | --- | --- |');192 for (const comment of payload.issueComments) {193 lines.push(194 [195 escapeTableCell(comment.nodeId),196 escapeTableCell(formatAuthorLogin(comment.author)),197 escapeTableCell(comment.createdAt),198 escapeTableCell(comment.url),199 escapeTableCell(summarizeBody(comment.body)),200 ]201 .join(' | ')202 .replace(/^/, '| ')203 .replace(/$/, ' |'),204 );205 }206 return `${lines.join('\n')}\n`;207}208 209async function readJson(path) {210 const raw = await readFile(path, 'utf8');211 return JSON.parse(raw);212}213 214async function writeOutput(outPath, text) {215 if (!outPath || outPath === '-') {216 process.stdout.write(text);217 return;218 }219 await mkdir(dirname(outPath), { recursive: true });220 await writeFile(outPath, text, 'utf8');221}222 223async function main() {224 const args = parseCliArgs(process.argv);225 if (args.help) {226 process.stdout.write(`${getHelpText()}\n`);227 process.exit(EXIT_SUCCESS);228 }229 230 const payload = await readJson(args.inPath);231 const markdown = renderReviewStateMarkdown(payload, { sourcePath: args.inPath });232 await writeOutput(args.outPath, markdown);233}234 235function safeRealpath(path) {236 try {237 return realpathSync(path);238 } catch {239 return null;240 }241}242 243const invokedScriptPath = process.argv[1] ? safeRealpath(resolve(process.argv[1])) : null;244const currentModulePath = safeRealpath(fileURLToPath(import.meta.url));245const isMain =246 invokedScriptPath !== null &&247 currentModulePath !== null &&248 invokedScriptPath === currentModulePath;249 250if (isMain) {251 main().catch((error) => {252 const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;253 const message = error?.message ? String(error.message) : String(error);254 process.stderr.write(`${message}\n`);255 process.exit(code);256 });257}258 259export { parseCliArgs };260