
Review Fetch Phase
- 2 installs
- 418 repo stars
- Updated August 3, 2026
- prisma/prisma-next
Fetches canonical PR review state and renders derived state artifacts. Use when the user wants the state acquisition phase only (fetch, render, summarize) for a review-framework PR.
About
Fetches canonical PR review state and renders derived state artifacts. Use when the user wants the state acquisition phase only (fetch, render, summarize) for a review-framework PR. name: review-fetch-phase description: Fetches canonical PR review state and renders derived state artifacts. Use when the user wants the state acquisition phase only (fetch, render, summarize) for a review-framework PR.
- PR URL (for example: `https://github.com/OWNER/REPO/pull/123`)
- `<output-dir>/review-state.json`
- `<output-dir>/review-state.md`
- `<output-dir>/summary.txt`
- `<output-dir>/review-targets.json`
Review Fetch Phase by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,839 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
review-fetch-phase capabilities & compatibility
- Capabilities
- review fetch phase quick start · review fetch phase when to use guidance · review fetch phase integration patterns
What review-fetch-phase says it does
argument-hint: "[pr-url] [output-dir]"
Run only the state acquisition phase of the review-framework loop:
npx skills add https://github.com/prisma/prisma-next --skill review-fetch-phaseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 418 |
| Last updated | August 3, 2026 |
| Repository | prisma/prisma-next ↗ |
How do I use review-fetch-phase correctly?
Fetches canonical PR review state and renders derived state artifacts. Use when the user wants the state acquisition phase only (fetch, render, summarize) for a review-framework PR.
Who is it for?
Teams implementing review-fetch-phase workflows from the catalog.
Skip if: Skip when requirements clearly match a different specialized stack.
When should I use this skill?
User asks about review-fetch-phase, fetches canonical pr review state and renders derived state artifacts. use when the user w.
What you get
Working review-fetch-phase setup with validated configuration and next steps.
Files
Review Fetch Phase
Run only the state acquisition phase of the review-framework loop:
fetch canonical review state JSON (v2), validate it, and render all derived artifacts via scripts.
Run commands from this skill directory. All script paths below are relative to it.
Inputs
- Required:
- PR URL (for example:
https://github.com/OWNER/REPO/pull/123) - Optional:
- output directory
If output directory is omitted, derive:
wip/reviews/<owner>_<repo>_pr-<number>/
Behavior
1. Validate and parse PR URL, then compute deterministic paths:
<output-dir>/review-state.json<output-dir>/review-state.md<output-dir>/summary.txt<output-dir>/review-targets.json
2. Ensure <output-dir> exists. 3. Enforce artifact safety before generation (must be ignored by git):
node ./scripts/guard-review-artifacts-ignored.mjs --dir <output-dir>4. Run fetch script to produce canonical JSON:
node ./scripts/fetch-review-state.mjs --pr <PR_URL> --out-json <output-dir>/review-state.json5. Validate canonical JSON before deriving additional files:
node ./scripts/validate-review-state.mjs --in <output-dir>/review-state.json6. Render markdown from canonical JSON:
node ./scripts/render-review-state.mjs --in <output-dir>/review-state.json --out <output-dir>/review-state.md7. Generate text summary from canonical JSON:
node ./scripts/summarize-review-state.mjs --in <output-dir>/review-state.json --format text --out <output-dir>/summary.txt8. Extract deterministic triage targets for downstream bootstrap:
node ./scripts/extract-review-targets.mjs --in <output-dir>/review-state.json --out <output-dir>/review-targets.jsonTarget extraction includes:
- unresolved review threads
- pull-request reviews with body text
- issue comments with body text
Schema contract
review-state.jsonis canonical and must be schema version2.- No backward compatibility is provided for v1 artifacts.
- Derived artifacts (
review-state.md,summary.txt,review-targets.json) are regenerable from canonical JSON. - Review artifacts are generated files and must remain untracked in git.
Error handling
- Treat fetch failures as operational errors.
- If
gh apifails with TLS/cert errors in sandbox (x509/OSStatus -26276), fail fast and instruct rerun outside sandbox. - Never disable TLS verification.
Output to user
Return artifact paths:
review-state.jsonreview-state.mdsummary.txtreview-targets.json
Suggest next step:
/review-triage-phase <PR_URL> [output-dir]
{
"name": "@prisma-next/skill-review-fetch-phase",
"private": true,
"type": "module",
"version": "0.0.0",
"description": "Fetch phase package for review-framework skill",
"scripts": {
"fetch": "node scripts/fetch-review-state.mjs",
"render": "node scripts/render-review-state.mjs",
"summarize": "node scripts/summarize-review-state.mjs",
"validate": "node scripts/validate-review-state.mjs",
"targets": "node scripts/extract-review-targets.mjs",
"guard-ignored": "node scripts/guard-review-artifacts-ignored.mjs"
}
}
#!/usr/bin/env node
import { realpathSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertReviewStateV1, formatCanonicalJson } from './review-artifacts.mjs';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { inPath: null, outPath: null, help: false };
if (args.includes('--help')) {
result.help = true;
return result;
}
const knownFlags = new Set(['--in', '--out']);
let index = 0;
while (index < args.length) {
const arg = args[index];
if (!arg.startsWith('--') || !knownFlags.has(arg)) {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: `error: ${arg} requires a value` };
}
const value = args[index];
if (arg === '--in') {
result.inPath = value;
} else if (arg === '--out') {
result.outPath = value;
}
index += 1;
}
if (!result.inPath) {
throw { code: EXIT_CLI, message: 'error: --in is required' };
}
if (!result.outPath) {
throw { code: EXIT_CLI, message: 'error: --out is required' };
}
if (!result.inPath.endsWith('.json') || !result.outPath.endsWith('.json')) {
throw { code: EXIT_CLI, message: 'error: --in and --out must be .json paths' };
}
return result;
}
function getHelpText() {
return [
'Usage:',
' extract-review-targets.mjs --in <review-state.json> --out <review-targets.json>',
'',
'Purpose:',
' Build deterministic target index for triage bootstrapping.',
].join('\n');
}
function buildTargetsPayload(reviewState, inPath) {
return {
version: 1,
reviewState: {
path: inPath,
fetchedAt: reviewState.fetchedAt,
prUrl: reviewState.pr.url,
prNodeId: reviewState.pr.nodeId,
},
targets: reviewState.targets.map((target, index) => ({
order: index + 1,
...target,
})),
};
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
const raw = await readFile(args.inPath, 'utf8');
const reviewState = JSON.parse(raw);
assertReviewStateV1(reviewState);
const payload = buildTargetsPayload(reviewState, args.inPath);
await mkdir(dirname(args.outPath), { recursive: true });
await writeFile(args.outPath, formatCanonicalJson(payload), 'utf8');
}
const isMain = (() => {
try {
const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;
const currentModulePath = realpathSync(fileURLToPath(import.meta.url));
return invokedScriptPath !== null && invokedScriptPath === currentModulePath;
} catch {
return false;
}
})();
if (isMain) {
main().catch((error) => {
const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;
const message = error?.message ? String(error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(code);
});
}
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { renderReviewStateMarkdown as renderReviewStateMarkdownImpl } from './render-review-state.mjs';
import {
assertReviewStateV1,
formatCanonicalJson,
normalizeReviewStateV1,
} from './review-artifacts.mjs';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
const SPAWN_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
const SUBPROCESS_TIMEOUT_MS = 30_000;
const THREADS_QUERY = `
query($owner: String!, $repo: String!, $number: Int!, $threadsCursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
id
url
number
title
state
headRefName
baseRefName
updatedAt
reviewThreads(first: 100, after: $threadsCursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
isResolved
isOutdated
path
startLine
line
originalStartLine
originalLine
comments(first: 100) {
pageInfo { hasNextPage endCursor }
nodes {
id
url
author { login }
createdAt
body
reactionGroups { content users { totalCount } }
}
}
}
}
}
}
}
`;
const THREAD_COMMENTS_QUERY = `
query($threadId: ID!, $cursor: String) {
node(id: $threadId) {
... on PullRequestReviewThread {
comments(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
url
author { login }
createdAt
body
reactionGroups { content users { totalCount } }
}
}
}
}
}
`;
const REVIEWS_QUERY = `
query($owner: String!, $repo: String!, $number: Int!, $reviewsCursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviews(first: 100, after: $reviewsCursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
url
author { login }
state
submittedAt
body
reactionGroups { content users { totalCount } }
}
}
}
}
}
`;
const COMMENTS_QUERY = `
query($owner: String!, $repo: String!, $number: Int!, $commentsCursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
comments(first: 100, after: $commentsCursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
url
author { login }
createdAt
body
reactionGroups { content users { totalCount } }
}
}
}
}
}
`;
function getHelpText() {
return [
'Usage:',
' fetch-review-state.mjs [--pr <url>] [--out <path.md>|-] [--out-json <path.json>|-] [--help]',
'',
'Purpose:',
' Fetch unresolved review threads, submitted review bodies, and PR issue comments.',
' Emit canonical review-state.json (v2 script-first schema). Markdown is derived output.',
'',
'Flags:',
' --pr <url> GitHub pull request URL (for example: https://github.com/OWNER/REPO/pull/123).',
' If omitted, the script attempts to discover the PR for the current git branch.',
' --out <path.md>|- Markdown output path. Use "-" to write markdown to stdout. Omit to skip markdown output.',
' --out-json <path.json>|-',
' JSON output path. If omitted and --out is a file path, defaults to same path with .json.',
' --help Show this help text and exit.',
].join('\n');
}
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { prUrl: null, outPath: null, outJsonPath: null, help: false };
if (args.includes('--help')) {
result.help = true;
return result;
}
const knownFlags = new Set(['--pr', '--out', '--out-json']);
let index = 0;
while (index < args.length) {
const arg = args[index];
if (!arg.startsWith('--') || !knownFlags.has(arg)) {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: `error: ${arg} requires a value` };
}
const value = args[index];
if (arg === '--pr') {
result.prUrl = value;
} else if (arg === '--out') {
result.outPath = value;
} else if (arg === '--out-json') {
result.outJsonPath = value;
}
index += 1;
}
if (result.outPath !== null && result.outPath !== '-' && !result.outPath.endsWith('.md')) {
throw { code: EXIT_CLI, message: 'error: --out file path must end with .md' };
}
if (
result.outJsonPath !== null &&
result.outJsonPath !== '-' &&
!result.outJsonPath.endsWith('.json')
) {
throw { code: EXIT_CLI, message: 'error: --out-json file path must end with .json' };
}
if (result.outPath === '-' && result.outJsonPath === '-') {
throw { code: EXIT_CLI, message: 'error: --out - cannot be combined with --out-json -' };
}
return result;
}
function renderReviewStateMarkdown(payload, options) {
return renderReviewStateMarkdownImpl(payload, options);
}
function parsePrUrl(url) {
if (typeof url !== 'string' || url.trim() === '') {
return null;
}
const match = url
.trim()
.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/)?(?:#.*)?$/i);
if (!match) {
return null;
}
return {
owner: match[1],
repo: match[2].replace(/\.git$/, ''),
number: Number.parseInt(match[3], 10),
};
}
function runSync(command, args, input) {
const result = spawnSync(command, args, {
encoding: 'utf-8',
input: input ?? undefined,
maxBuffer: SPAWN_MAX_BUFFER_BYTES,
timeout: SUBPROCESS_TIMEOUT_MS,
});
if (result.error) {
let detail;
if (result.error.code === 'ETIMEDOUT') {
detail = `${command} timed out after ${SUBPROCESS_TIMEOUT_MS / 1000} seconds`;
} else if (result.error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
detail = `${command} output exceeded ${SPAWN_MAX_BUFFER_BYTES} bytes; raise SPAWN_MAX_BUFFER_BYTES`;
} else {
detail = `failed to execute ${command}: ${result.error.message}`;
}
return { stdout: '', stderr: detail, status: result.status ?? 1 };
}
if (result.signal) {
return {
stdout: result.stdout ?? '',
stderr: `${command} was terminated by signal ${result.signal}`,
status: result.status ?? 1,
};
}
return { stdout: result.stdout, stderr: result.stderr, status: result.status };
}
function checkPreconditions({ requireGit }) {
if (requireGit) {
const git = runSync('which', ['git']);
if (git.status !== 0) {
return { ok: false, code: EXIT_OPERATIONAL, message: 'error: git not found on PATH' };
}
}
const gh = runSync('which', ['gh']);
if (gh.status !== 0) {
return { ok: false, code: EXIT_OPERATIONAL, message: 'error: gh not found on PATH' };
}
const auth = runSync('gh', ['auth', 'status']);
if (auth.status !== 0) {
return {
ok: false,
code: EXIT_OPERATIONAL,
message: 'error: gh is not authenticated; run "gh auth login" and try again',
};
}
return { ok: true };
}
function getCurrentBranch() {
const result = runSync('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
if (result.status !== 0) {
return null;
}
return result.stdout.trim();
}
function discoverPrUrl(branchName) {
const result = runSync('gh', [
'pr',
'list',
'--head',
branchName,
'--state',
'all',
'--json',
'url',
]);
if (result.status !== 0) {
return { code: EXIT_OPERATIONAL, error: 'error: gh pr list failed' };
}
let list;
try {
list = JSON.parse(result.stdout);
} catch {
return { code: EXIT_OPERATIONAL, error: 'error: gh pr list returned invalid JSON' };
}
if (!Array.isArray(list) || list.length === 0) {
return {
code: EXIT_OPERATIONAL,
error: `error: no pull request found for current branch "${branchName}"; pass --pr <url>`,
};
}
if (list.length > 1) {
return {
code: EXIT_OPERATIONAL,
error: `error: multiple pull requests found for current branch "${branchName}"; pass --pr <url>`,
};
}
return { url: list[0].url };
}
function fetchGraphQL(query, variables) {
const body = JSON.stringify({ query, variables });
const result = runSync('gh', ['api', 'graphql', '--input', '-'], body);
if (result.status !== 0) {
return { code: EXIT_OPERATIONAL, error: result.stderr || 'error: GitHub API request failed' };
}
try {
const parsed = JSON.parse(result.stdout);
if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {
const messages = parsed.errors
.map((err) =>
typeof err?.message === 'string' && err.message.length > 0
? err.message
: JSON.stringify(err),
)
.join('; ');
return {
code: EXIT_OPERATIONAL,
error: `error: GitHub GraphQL returned errors: ${messages}`,
};
}
return { data: parsed };
} catch {
return { code: EXIT_OPERATIONAL, error: 'error: GitHub API returned invalid JSON' };
}
}
function paginateConnection(owner, repo, number, query, cursorVar, cursorValue) {
const response = fetchGraphQL(query, {
owner,
repo,
number,
[cursorVar]: cursorValue ?? null,
});
if (response.error) {
return response;
}
const pr = response.data?.data?.repository?.pullRequest;
if (!pr) {
return { code: EXIT_OPERATIONAL, error: 'error: pull request not found in GraphQL response' };
}
return { pr };
}
function paginateThreadComments(threadId, cursor) {
const response = fetchGraphQL(THREAD_COMMENTS_QUERY, { threadId, cursor: cursor ?? null });
if (response.error) {
return response;
}
const connection = response.data?.data?.node?.comments;
if (!connection) {
return {
code: EXIT_OPERATIONAL,
error: 'error: thread comments connection missing in GraphQL response',
};
}
return { connection };
}
function paginateAll(owner, repo, number) {
let pr = null;
let reviewThreads = [];
let threadCursor = null;
for (;;) {
const page = paginateConnection(
owner,
repo,
number,
THREADS_QUERY,
'threadsCursor',
threadCursor,
);
if (page.error) {
return page;
}
pr = page.pr;
const connection = page.pr.reviewThreads;
reviewThreads = reviewThreads.concat(connection?.nodes ?? []);
if (!connection?.pageInfo?.hasNextPage) {
break;
}
threadCursor = connection.pageInfo.endCursor;
}
for (const thread of reviewThreads) {
let commentCursor = thread?.comments?.pageInfo?.endCursor ?? null;
while (thread?.comments?.pageInfo?.hasNextPage) {
const next = paginateThreadComments(thread.id, commentCursor);
if (next.error) {
return next;
}
thread.comments.nodes = (thread.comments.nodes ?? []).concat(next.connection.nodes ?? []);
thread.comments.pageInfo = next.connection.pageInfo ?? {
hasNextPage: false,
endCursor: null,
};
commentCursor = thread.comments.pageInfo.endCursor;
}
}
let reviews = [];
let reviewCursor = null;
for (;;) {
const page = paginateConnection(
owner,
repo,
number,
REVIEWS_QUERY,
'reviewsCursor',
reviewCursor,
);
if (page.error) {
return page;
}
const connection = page.pr.reviews;
reviews = reviews.concat(connection?.nodes ?? []);
if (!connection?.pageInfo?.hasNextPage) {
break;
}
reviewCursor = connection.pageInfo.endCursor;
}
let issueComments = [];
let commentsCursor = null;
for (;;) {
const page = paginateConnection(
owner,
repo,
number,
COMMENTS_QUERY,
'commentsCursor',
commentsCursor,
);
if (page.error) {
return page;
}
const connection = page.pr.comments;
issueComments = issueComments.concat(connection?.nodes ?? []);
if (!connection?.pageInfo?.hasNextPage) {
break;
}
commentsCursor = connection.pageInfo.endCursor;
}
return {
pr,
reviewThreads,
reviews,
issueComments,
};
}
function deriveOutJsonPath(outPath, outJsonPath) {
if (outJsonPath) {
return outJsonPath;
}
if (!outPath || outPath === '-') {
return null;
}
return outPath.replace(/\.md$/i, '.json');
}
async function writeOutput(outPath, text) {
if (!outPath || outPath === '-') {
process.stdout.write(text);
return;
}
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, text, 'utf8');
}
async function main() {
let options;
try {
options = parseCliArgs(process.argv);
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exit(error.code ?? EXIT_CLI);
}
if (options.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
const preconditions = checkPreconditions({ requireGit: !options.prUrl });
if (!preconditions.ok) {
process.stderr.write(`${preconditions.message}\n`);
process.exit(preconditions.code);
}
let prUrl = options.prUrl;
let sourceBranch = null;
if (!prUrl) {
sourceBranch = getCurrentBranch();
if (!sourceBranch || sourceBranch === 'HEAD') {
process.stderr.write(
'error: cannot discover PR when in detached HEAD state; pass --pr <url>\n',
);
process.exit(EXIT_OPERATIONAL);
}
const discovered = discoverPrUrl(sourceBranch);
if (discovered.error) {
process.stderr.write(`${discovered.error}\n`);
process.exit(discovered.code ?? EXIT_OPERATIONAL);
}
prUrl = discovered.url;
}
const parsedPr = parsePrUrl(prUrl);
if (!parsedPr) {
process.stderr.write(
'error: invalid --pr value (expected GitHub PR URL like https://github.com/OWNER/REPO/pull/123)\n',
);
process.exit(EXIT_CLI);
}
if (!sourceBranch) {
sourceBranch = getCurrentBranch();
if (sourceBranch === 'HEAD') {
sourceBranch = null;
}
}
const payload = paginateAll(parsedPr.owner, parsedPr.repo, parsedPr.number);
if (payload.error) {
process.stderr.write(`${payload.error}\n`);
process.exit(payload.code ?? EXIT_OPERATIONAL);
}
const fetchedAt = new Date().toISOString();
const reviewState = normalizeReviewStateV1({
fetchedAt,
sourceBranch,
pr: payload.pr,
reviewThreads: payload.reviewThreads,
reviews: payload.reviews,
issueComments: payload.issueComments,
});
assertReviewStateV1(reviewState);
const jsonText = formatCanonicalJson(reviewState);
const outJsonPath = deriveOutJsonPath(options.outPath, options.outJsonPath);
const markdown = renderReviewStateMarkdown(reviewState, {
sourcePath: outJsonPath && outJsonPath !== '-' ? outJsonPath : undefined,
});
if (options.outPath !== null) {
await writeOutput(options.outPath, markdown);
}
if (outJsonPath) {
await writeOutput(outJsonPath, jsonText);
}
process.exit(EXIT_SUCCESS);
}
const isMain = (function computeIsMain() {
try {
const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;
const currentModulePath = realpathSync(fileURLToPath(import.meta.url));
return invokedScriptPath !== null && invokedScriptPath === currentModulePath;
} catch {
return false;
}
})();
if (isMain) {
main().catch((error) => {
const message = error?.message ? String(error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(EXIT_OPERATIONAL);
});
}
export { deriveOutJsonPath, parseCliArgs, parsePrUrl, renderReviewStateMarkdown };
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
const RELATIVE_ARTIFACT_PATHS = [
'review-state.json',
'review-state.md',
'summary.txt',
'review-targets.json',
'review-actions.json',
'review-actions.md',
];
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { outputDir: null, help: false };
if (args.includes('--help')) {
result.help = true;
return result;
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg !== '--dir') {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: 'error: --dir requires a value' };
}
result.outputDir = args[index];
}
if (!result.outputDir) {
throw { code: EXIT_CLI, message: 'error: --dir is required' };
}
return result;
}
function getHelpText() {
return [
'Usage:',
' guard-review-artifacts-ignored.mjs --dir <output-dir>',
'',
'Purpose:',
' Fail fast if generated review artifacts are not git-ignored.',
].join('\n');
}
function runGitCheckIgnore(path) {
const result = spawnSync('git', ['check-ignore', '--quiet', path], { encoding: 'utf8' });
return result.status === 0;
}
function isTracked(path) {
const result = spawnSync('git', ['ls-files', '--error-unmatch', path], { encoding: 'utf8' });
return result.status === 0;
}
function ensureInsideRepo(path) {
const root = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
if (root.status !== 0) {
throw new Error('error: not in a git repository');
}
const repoRoot = root.stdout.trim();
const absolutePath = resolve(path);
const relativePath = relative(repoRoot, absolutePath);
if (
relativePath === '' ||
relativePath === '..' ||
relativePath.startsWith(`..${sep}`) ||
isAbsolute(relativePath)
) {
throw new Error(`error: output dir must be inside repo: ${repoRoot}`);
}
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
ensureInsideRepo(args.outputDir);
const tracked = [];
const notIgnored = [];
for (const relativePath of RELATIVE_ARTIFACT_PATHS) {
const fullPath = join(args.outputDir, relativePath);
if (isTracked(fullPath)) {
tracked.push(fullPath);
continue;
}
const ignored = runGitCheckIgnore(fullPath);
if (!ignored) {
notIgnored.push(fullPath);
}
}
if (tracked.length > 0) {
process.stderr.write(
`error: review artifacts are tracked in git and must be untracked first:\n${tracked
.map((path) => `- ${path}`)
.join('\n')}\n`,
);
process.stderr.write(
'hint: run `git rm --cached <paths>` once, then keep them ignored via .gitignore.\n',
);
process.exit(EXIT_OPERATIONAL);
}
if (notIgnored.length > 0) {
process.stderr.write(
`error: review artifacts must be git-ignored. Missing ignore coverage for:\n${notIgnored
.map((path) => `- ${path}`)
.join('\n')}\n`,
);
process.exit(EXIT_OPERATIONAL);
}
process.stdout.write('ok: review artifact paths are ignored by git\n');
}
const isMain = (() => {
try {
const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;
const currentModulePath = realpathSync(fileURLToPath(import.meta.url));
return invokedScriptPath !== null && invokedScriptPath === currentModulePath;
} catch {
return false;
}
})();
if (isMain) {
main().catch((error) => {
const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;
const message = error?.message ? String(error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(code);
});
}
#!/usr/bin/env node
import { realpathSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertReviewStateV1 } from './review-artifacts.mjs';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
function getHelpText() {
return [
'Usage:',
' render-review-state.mjs --in <review-state.json> [--out <review-state.md>|-] [--help]',
'',
'Purpose:',
' Render deterministic Markdown (review-state.md) from review-state.json.',
'',
'Flags:',
' --in <path.json> Input path to review-state.json.',
' --out <path.md>|- Markdown output path. Use "-" to write to stdout. Defaults to stdout.',
' --help Show this help text and exit.',
].join('\n');
}
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { inPath: null, outPath: null, help: false };
if (args.includes('--help')) {
result.help = true;
return result;
}
const knownFlags = new Set(['--in', '--out']);
let index = 0;
while (index < args.length) {
const arg = args[index];
if (!arg.startsWith('--') || !knownFlags.has(arg)) {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: `error: ${arg} requires a value` };
}
const value = args[index];
if (arg === '--in') {
result.inPath = value;
} else if (arg === '--out') {
result.outPath = value;
}
index += 1;
}
if (!result.inPath) {
throw { code: EXIT_CLI, message: 'error: --in is required' };
}
if (result.inPath === '-') {
throw { code: EXIT_CLI, message: 'error: --in - is not supported' };
}
if (result.inPath !== '-' && !result.inPath.endsWith('.json')) {
throw { code: EXIT_CLI, message: 'error: --in file path must end with .json' };
}
if (result.outPath !== null && result.outPath !== '-' && !result.outPath.endsWith('.md')) {
throw { code: EXIT_CLI, message: 'error: --out file path must end with .md' };
}
return result;
}
function escapeTableCell(value) {
return String(value ?? '')
.replace(/\r?\n/g, ' ')
.replace(/\|/g, '\\|')
.replace(/\s+/g, ' ')
.trim();
}
function formatLines(startLine, endLine) {
if (Number.isInteger(startLine) && Number.isInteger(endLine)) {
return `${startLine}-${endLine}`;
}
if (Number.isInteger(startLine)) {
return String(startLine);
}
if (Number.isInteger(endLine)) {
return String(endLine);
}
return '';
}
function summarizeBody(body, maxLength = 180) {
const normalized = String(body ?? '')
.replace(/\r?\n/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1)}…`;
}
function formatAuthorLogin(author) {
return typeof author?.login === 'string' && author.login.length > 0 ? author.login : '<deleted>';
}
export function renderReviewStateMarkdown(payload, { sourcePath }) {
assertReviewStateV1(payload);
const source = sourcePath ? escapeTableCell(sourcePath) : 'review-state.json';
const lines = [];
lines.push('# Review State');
lines.push('');
lines.push(`PR: ${escapeTableCell(payload.pr.url)}`);
lines.push(`Source: \`${source}\``);
lines.push(`FetchedAt: ${escapeTableCell(payload.fetchedAt)}`);
lines.push(`SourceBranch: ${escapeTableCell(payload.sourceBranch)}`);
lines.push('');
lines.push(`Unresolved threads: ${payload.reviewThreads.length}`);
lines.push(`Reviews with body: ${payload.reviews.length}`);
lines.push(`Issue comments: ${payload.issueComments.length}`);
lines.push('');
lines.push('## Unresolved Review Threads');
lines.push('');
lines.push('| Node ID | Path | Lines | Outdated | Comments | Primary comment |');
lines.push('| --- | --- | --- | --- | --- | --- |');
for (const thread of payload.reviewThreads) {
const primaryComment = thread.comments[0];
lines.push(
[
escapeTableCell(thread.nodeId),
escapeTableCell(thread.path),
escapeTableCell(formatLines(thread.startLine, thread.endLine)),
thread.isOutdated ? 'yes' : 'no',
escapeTableCell(thread.comments.length),
escapeTableCell(summarizeBody(primaryComment?.body ?? '')),
]
.join(' | ')
.replace(/^/, '| ')
.replace(/$/, ' |'),
);
}
lines.push('');
lines.push('## Reviews With Body');
lines.push('');
lines.push('| Node ID | Author | State | Submitted At | URL | Body excerpt |');
lines.push('| --- | --- | --- | --- | --- | --- |');
for (const review of payload.reviews) {
lines.push(
[
escapeTableCell(review.nodeId),
escapeTableCell(formatAuthorLogin(review.author)),
escapeTableCell(review.state),
escapeTableCell(review.submittedAt),
escapeTableCell(review.url),
escapeTableCell(summarizeBody(review.body)),
]
.join(' | ')
.replace(/^/, '| ')
.replace(/$/, ' |'),
);
}
lines.push('');
lines.push('## Issue Comments');
lines.push('');
lines.push('| Node ID | Author | Created At | URL | Body excerpt |');
lines.push('| --- | --- | --- | --- | --- |');
for (const comment of payload.issueComments) {
lines.push(
[
escapeTableCell(comment.nodeId),
escapeTableCell(formatAuthorLogin(comment.author)),
escapeTableCell(comment.createdAt),
escapeTableCell(comment.url),
escapeTableCell(summarizeBody(comment.body)),
]
.join(' | ')
.replace(/^/, '| ')
.replace(/$/, ' |'),
);
}
return `${lines.join('\n')}\n`;
}
async function readJson(path) {
const raw = await readFile(path, 'utf8');
return JSON.parse(raw);
}
async function writeOutput(outPath, text) {
if (!outPath || outPath === '-') {
process.stdout.write(text);
return;
}
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, text, 'utf8');
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
const payload = await readJson(args.inPath);
const markdown = renderReviewStateMarkdown(payload, { sourcePath: args.inPath });
await writeOutput(args.outPath, markdown);
}
function safeRealpath(path) {
try {
return realpathSync(path);
} catch {
return null;
}
}
const invokedScriptPath = process.argv[1] ? safeRealpath(resolve(process.argv[1])) : null;
const currentModulePath = safeRealpath(fileURLToPath(import.meta.url));
const isMain =
invokedScriptPath !== null &&
currentModulePath !== null &&
invokedScriptPath === currentModulePath;
if (isMain) {
main().catch((error) => {
const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;
const message = error?.message ? String(error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(code);
});
}
export { parseCliArgs };
const REVIEW_STATE_VERSION = 2;
const TARGET_KIND_VALUES = new Set([
'review_thread',
'review_comment',
'pull_request_review',
'issue_comment',
]);
function compareNullableStringsAsc(a, b) {
const left = a ?? '';
const right = b ?? '';
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
function compareNullableNumbersAsc(a, b) {
const left = a ?? Number.MAX_SAFE_INTEGER;
const right = b ?? Number.MAX_SAFE_INTEGER;
return left - right;
}
function stripReviewFrameworkMarkers(body) {
if (typeof body !== 'string') {
return '';
}
return body
.replace(/<!--\s*review-framework:[\s\S]*?-->/g, '')
.replace(/<!--\s*internal state start\s*-->[\s\S]*?<!--\s*internal state end\s*-->/g, '')
.replace(/\n{2,}/g, '\n')
.replace(/[ \t]+\n/g, '\n')
.trimEnd();
}
function normalizeReactionGroups(groups) {
if (!Array.isArray(groups)) {
return [];
}
const normalized = groups.map((group) => {
const rawTotalCount = group?.users?.totalCount;
const totalCount = Number.isFinite(rawTotalCount) ? Math.max(0, Math.trunc(rawTotalCount)) : 0;
return {
content: String(group?.content ?? ''),
users: { totalCount },
};
});
normalized.sort((a, b) => {
if (a.content < b.content) return -1;
if (a.content > b.content) return 1;
return 0;
});
return normalized;
}
function earliestCommentCreatedAt(comments) {
if (!Array.isArray(comments) || comments.length === 0) {
return null;
}
let earliest = null;
for (const comment of comments) {
if (typeof comment?.createdAt === 'string' && comment.createdAt.length > 0) {
if (earliest === null || comment.createdAt < earliest) {
earliest = comment.createdAt;
}
}
}
return earliest;
}
function sortThreadComments(comments) {
return [...comments].sort((a, b) => {
const createdAtOrder = compareNullableStringsAsc(a.createdAt, b.createdAt);
if (createdAtOrder !== 0) {
return createdAtOrder;
}
return compareNullableStringsAsc(a.nodeId, b.nodeId);
});
}
function sortReviewThreads(threads) {
return [...threads].sort((a, b) => {
const pathOrder = compareNullableStringsAsc(a.path, b.path);
if (pathOrder !== 0) {
return pathOrder;
}
const startLineOrder = compareNullableNumbersAsc(a.startLine, b.startLine);
if (startLineOrder !== 0) {
return startLineOrder;
}
const earliestOrder = compareNullableStringsAsc(
earliestCommentCreatedAt(a.comments),
earliestCommentCreatedAt(b.comments),
);
if (earliestOrder !== 0) {
return earliestOrder;
}
return compareNullableStringsAsc(a.nodeId, b.nodeId);
});
}
function sortReviews(reviews) {
return [...reviews].sort((a, b) => {
const submittedAtOrder = compareNullableStringsAsc(a.submittedAt, b.submittedAt);
if (submittedAtOrder !== 0) {
return submittedAtOrder;
}
return compareNullableStringsAsc(a.nodeId, b.nodeId);
});
}
function sortIssueComments(comments) {
return [...comments].sort((a, b) => {
const createdAtOrder = compareNullableStringsAsc(a.createdAt, b.createdAt);
if (createdAtOrder !== 0) {
return createdAtOrder;
}
return compareNullableStringsAsc(a.nodeId, b.nodeId);
});
}
function normalizeAuthor(author) {
return {
login: typeof author?.login === 'string' ? author.login : null,
};
}
function normalizeBody(body) {
return stripReviewFrameworkMarkers(body ?? '');
}
function normalizeThreadComment(comment) {
if (typeof comment?.id !== 'string' || comment.id.length === 0) {
return null;
}
return {
nodeId: comment.id,
url: typeof comment.url === 'string' ? comment.url : null,
author: normalizeAuthor(comment.author),
createdAt: typeof comment.createdAt === 'string' ? comment.createdAt : null,
body: normalizeBody(comment.body),
reactionGroups: normalizeReactionGroups(comment.reactionGroups),
};
}
function summarizeBody(body, maxLength = 160) {
if (typeof body !== 'string') {
return '';
}
return body.replace(/\s+/g, ' ').trim().slice(0, maxLength);
}
function normalizeReview(review) {
if (typeof review?.id !== 'string' || review.id.length === 0) {
return null;
}
const body = normalizeBody(review.body);
if (body.trim().length === 0) {
return null;
}
return {
nodeId: review.id,
url: typeof review.url === 'string' ? review.url : null,
author: normalizeAuthor(review.author),
state: typeof review.state === 'string' ? review.state : null,
submittedAt: typeof review.submittedAt === 'string' ? review.submittedAt : null,
body,
reactionGroups: normalizeReactionGroups(review.reactionGroups),
};
}
function isActionableReview(review) {
return review.state === 'CHANGES_REQUESTED' || review.state === 'COMMENTED';
}
function normalizeIssueComment(comment) {
if (typeof comment?.id !== 'string' || comment.id.length === 0) {
return null;
}
const body = normalizeBody(comment.body);
if (body.trim().length === 0) {
return null;
}
return {
nodeId: comment.id,
url: typeof comment.url === 'string' ? comment.url : null,
author: normalizeAuthor(comment.author),
createdAt: typeof comment.createdAt === 'string' ? comment.createdAt : null,
body,
reactionGroups: normalizeReactionGroups(comment.reactionGroups),
replies: [],
};
}
function normalizeReviewStateV1(input) {
const normalizedThreads = [];
const threadCandidates = Array.isArray(input?.reviewThreads) ? input.reviewThreads : [];
for (const thread of threadCandidates) {
if (thread?.isResolved !== false) continue;
if (typeof thread?.id !== 'string' || thread.id.length === 0) continue;
const normalizedComments = [];
const commentCandidates = Array.isArray(thread?.comments?.nodes) ? thread.comments.nodes : [];
for (const comment of commentCandidates) {
const normalizedComment = normalizeThreadComment(comment);
if (normalizedComment) normalizedComments.push(normalizedComment);
}
const sortedComments = sortThreadComments(normalizedComments);
const primaryComment = sortedComments[0] ?? null;
if (primaryComment === null) continue;
const startLine =
Number.isInteger(thread.startLine) && thread.startLine >= 0
? thread.startLine
: Number.isInteger(thread.originalStartLine) && thread.originalStartLine >= 0
? thread.originalStartLine
: null;
const endLine =
Number.isInteger(thread.line) && thread.line >= 0
? thread.line
: Number.isInteger(thread.originalLine) && thread.originalLine >= 0
? thread.originalLine
: null;
normalizedThreads.push({
threadKey: `review_thread:${thread.id}`,
nodeId: thread.id,
isResolved: false,
isOutdated: Boolean(thread.isOutdated),
path: typeof thread.path === 'string' ? thread.path : null,
startLine,
endLine,
ordering: {
path: typeof thread.path === 'string' ? thread.path : null,
startLine,
earliestCommentCreatedAt: earliestCommentCreatedAt(sortedComments),
nodeId: thread.id,
},
primaryComment: {
nodeId: primaryComment.nodeId,
url: primaryComment.url,
authorLogin: primaryComment.author.login,
createdAt: primaryComment.createdAt,
bodySnippet: summarizeBody(primaryComment.body),
},
targetHint: {
kind: 'review_thread',
nodeId: thread.id,
url: primaryComment.url,
},
isActionableCandidate: !thread.isOutdated,
comments: sortedComments,
});
}
const normalizedReviews = [];
const reviewCandidates = Array.isArray(input?.reviews) ? input.reviews : [];
for (const review of reviewCandidates) {
const normalizedReview = normalizeReview(review);
if (normalizedReview) normalizedReviews.push(normalizedReview);
}
const normalizedIssueComments = [];
const issueCommentCandidates = Array.isArray(input?.issueComments) ? input.issueComments : [];
for (const issueComment of issueCommentCandidates) {
const normalizedComment = normalizeIssueComment(issueComment);
if (normalizedComment) normalizedIssueComments.push(normalizedComment);
}
const reviewThreads = sortReviewThreads(normalizedThreads);
const sortedReviews = sortReviews(normalizedReviews);
const sortedIssueComments = sortIssueComments(normalizedIssueComments);
const threadTargets = reviewThreads.map((thread) => ({
targetKey: thread.threadKey,
kind: 'review_thread',
nodeId: thread.nodeId,
url: thread.targetHint.url,
threadNodeId: thread.nodeId,
path: thread.path,
startLine: thread.startLine,
endLine: thread.endLine,
isOutdated: thread.isOutdated,
isActionableCandidate: thread.isActionableCandidate,
primaryCommentNodeId: thread.primaryComment?.nodeId ?? null,
primaryCommentAuthorLogin: thread.primaryComment?.authorLogin ?? null,
primaryCommentCreatedAt: thread.primaryComment?.createdAt ?? null,
}));
const reviewTargets = sortedReviews.map((review) => ({
targetKey: `pull_request_review:${review.nodeId}`,
kind: 'pull_request_review',
nodeId: review.nodeId,
url: review.url,
path: null,
startLine: null,
endLine: null,
isOutdated: false,
isActionableCandidate: isActionableReview(review),
primaryCommentNodeId: review.nodeId,
primaryCommentAuthorLogin: review.author.login,
primaryCommentCreatedAt: review.submittedAt,
}));
const issueCommentTargets = sortedIssueComments.map((comment) => ({
targetKey: `issue_comment:${comment.nodeId}`,
kind: 'issue_comment',
nodeId: comment.nodeId,
url: comment.url,
path: null,
startLine: null,
endLine: null,
isOutdated: false,
isActionableCandidate: true,
primaryCommentNodeId: comment.nodeId,
primaryCommentAuthorLogin: comment.author.login,
primaryCommentCreatedAt: comment.createdAt,
}));
return {
version: REVIEW_STATE_VERSION,
fetchedAt: String(input?.fetchedAt ?? ''),
sourceBranch: typeof input?.sourceBranch === 'string' ? input.sourceBranch : null,
pr: {
url: typeof input?.pr?.url === 'string' ? input.pr.url : null,
nodeId: typeof input?.pr?.id === 'string' ? input.pr.id : null,
number: Number.isInteger(input?.pr?.number) ? input.pr.number : null,
title: typeof input?.pr?.title === 'string' ? input.pr.title : null,
state: typeof input?.pr?.state === 'string' ? input.pr.state : null,
headRefName: typeof input?.pr?.headRefName === 'string' ? input.pr.headRefName : null,
baseRefName: typeof input?.pr?.baseRefName === 'string' ? input.pr.baseRefName : null,
updatedAt: typeof input?.pr?.updatedAt === 'string' ? input.pr.updatedAt : null,
},
reviewThreads,
targets: [...threadTargets, ...reviewTargets, ...issueCommentTargets],
reviews: sortedReviews,
issueComments: sortedIssueComments,
};
}
function isNonEmptyString(value) {
return typeof value === 'string' && value.length > 0;
}
function validateReactionGroupShape(group, pointer) {
if (!isNonEmptyString(group?.content)) {
throw new TypeError(`${pointer}.content must be a non-empty string`);
}
if (!Number.isInteger(group?.users?.totalCount) || group.users.totalCount < 0) {
throw new TypeError(`${pointer}.users.totalCount must be a non-negative integer`);
}
}
function validateBodyEntryShape(entry, pointer) {
if (!isNonEmptyString(entry?.nodeId)) {
throw new TypeError(`${pointer}.nodeId must be a non-empty string`);
}
if (entry.url !== null && entry.url !== undefined && typeof entry.url !== 'string') {
throw new TypeError(`${pointer}.url must be string or null`);
}
if (typeof entry?.author !== 'object' || entry.author === null) {
throw new TypeError(`${pointer}.author must be an object`);
}
if (
entry.author.login !== null &&
entry.author.login !== undefined &&
typeof entry.author.login !== 'string'
) {
throw new TypeError(`${pointer}.author.login must be string or null`);
}
if (
entry.createdAt !== null &&
entry.createdAt !== undefined &&
typeof entry.createdAt !== 'string'
) {
throw new TypeError(`${pointer}.createdAt must be string or null`);
}
if (typeof entry.body !== 'string') {
throw new TypeError(`${pointer}.body must be a string`);
}
if (!Array.isArray(entry.reactionGroups)) {
throw new TypeError(`${pointer}.reactionGroups must be an array`);
}
for (let index = 0; index < entry.reactionGroups.length; index += 1) {
validateReactionGroupShape(entry.reactionGroups[index], `${pointer}.reactionGroups[${index}]`);
}
}
function validateReviewBodyShape(entry, pointer) {
if (typeof entry !== 'object' || entry === null) {
throw new TypeError(`${pointer} must be an object`);
}
if (!isNonEmptyString(entry.nodeId)) {
throw new TypeError(`${pointer}.nodeId must be a non-empty string`);
}
if (typeof entry.author !== 'object' || entry.author === null) {
throw new TypeError(`${pointer}.author must be an object`);
}
if (
entry.author.login !== null &&
entry.author.login !== undefined &&
typeof entry.author.login !== 'string'
) {
throw new TypeError(`${pointer}.author.login must be string or null`);
}
if (!isNonEmptyString(entry.body)) {
throw new TypeError(`${pointer}.body must be a non-empty string`);
}
if (!Array.isArray(entry.reactionGroups)) {
throw new TypeError(`${pointer}.reactionGroups must be an array`);
}
for (let index = 0; index < entry.reactionGroups.length; index += 1) {
validateReactionGroupShape(entry.reactionGroups[index], `${pointer}.reactionGroups[${index}]`);
}
}
function validateIssueCommentShape(entry, pointer) {
validateReviewBodyShape(entry, pointer);
if (!Array.isArray(entry.replies)) {
throw new TypeError(`${pointer}.replies must be an array`);
}
}
function assertReviewStateV1(reviewState) {
if (typeof reviewState !== 'object' || reviewState === null) {
throw new TypeError('review-state must be an object');
}
if (reviewState.version !== REVIEW_STATE_VERSION) {
throw new TypeError(`review-state version must be ${REVIEW_STATE_VERSION}`);
}
if (!isNonEmptyString(reviewState.fetchedAt)) {
throw new TypeError('review-state fetchedAt must be a non-empty string');
}
const pr = reviewState.pr;
if (typeof pr !== 'object' || pr === null) {
throw new TypeError('review-state pr must be an object');
}
if (!isNonEmptyString(pr.nodeId)) {
throw new TypeError('review-state pr.nodeId must be a non-empty string');
}
if (!Array.isArray(reviewState.reviewThreads)) {
throw new TypeError('review-state reviewThreads must be an array');
}
for (let index = 0; index < reviewState.reviewThreads.length; index += 1) {
const thread = reviewState.reviewThreads[index];
const pointer = `review-state reviewThreads[${index}]`;
if (!isNonEmptyString(thread?.threadKey)) {
throw new TypeError(`${pointer}.threadKey must be a non-empty string`);
}
if (!isNonEmptyString(thread?.nodeId)) {
throw new TypeError(`${pointer}.nodeId must be a non-empty string`);
}
if (thread?.isResolved !== false) {
throw new TypeError(`${pointer}.isResolved must be false`);
}
if (typeof thread?.ordering !== 'object' || thread.ordering === null) {
throw new TypeError(`${pointer}.ordering must be an object`);
}
if (thread?.targetHint?.kind !== 'review_thread') {
throw new TypeError(`${pointer}.targetHint.kind must be review_thread`);
}
if (!isNonEmptyString(thread?.targetHint?.nodeId)) {
throw new TypeError(`${pointer}.targetHint.nodeId must be a non-empty string`);
}
if (typeof thread?.isActionableCandidate !== 'boolean') {
throw new TypeError(`${pointer}.isActionableCandidate must be a boolean`);
}
if (!Array.isArray(thread.comments)) {
throw new TypeError(`${pointer}.comments must be an array`);
}
for (let commentIndex = 0; commentIndex < thread.comments.length; commentIndex += 1) {
validateBodyEntryShape(thread.comments[commentIndex], `${pointer}.comments[${commentIndex}]`);
}
}
if (!Array.isArray(reviewState.reviews)) {
throw new TypeError('review-state reviews must be an array');
}
for (let index = 0; index < reviewState.reviews.length; index += 1) {
validateReviewBodyShape(reviewState.reviews[index], `review-state reviews[${index}]`);
}
if (!Array.isArray(reviewState.issueComments)) {
throw new TypeError('review-state issueComments must be an array');
}
for (let index = 0; index < reviewState.issueComments.length; index += 1) {
validateIssueCommentShape(
reviewState.issueComments[index],
`review-state issueComments[${index}]`,
);
}
if (!Array.isArray(reviewState.targets)) {
throw new TypeError('review-state targets must be an array');
}
for (let index = 0; index < reviewState.targets.length; index += 1) {
const target = reviewState.targets[index];
const pointer = `review-state targets[${index}]`;
if (!isNonEmptyString(target?.targetKey)) {
throw new TypeError(`${pointer}.targetKey must be a non-empty string`);
}
if (!TARGET_KIND_VALUES.has(target?.kind)) {
throw new TypeError(`${pointer}.kind must be a supported target kind`);
}
if (!isNonEmptyString(target?.nodeId)) {
throw new TypeError(`${pointer}.nodeId must be a non-empty string`);
}
}
return reviewState;
}
function formatCanonicalJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
export {
assertReviewStateV1,
formatCanonicalJson,
normalizeReviewStateV1,
REVIEW_STATE_VERSION,
stripReviewFrameworkMarkers,
};
#!/usr/bin/env node
import { realpathSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertReviewStateV1, formatCanonicalJson } from './review-artifacts.mjs';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
function getHelpText() {
return [
'Usage:',
' summarize-review-state.mjs --in <review-state.json> [--format text|json] [--out <path>|-] [--help]',
'',
'Purpose:',
' Render deterministic summaries from review-state.json with no network access.',
'',
'Flags:',
' --in <path.json> Input path to review-state.json.',
' --format text|json Summary output format. Defaults to text.',
' --out <path>|- Output path. Use "-" to write to stdout. Defaults to stdout.',
' --help Show this help text and exit.',
].join('\n');
}
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { inPath: null, format: 'text', outPath: null, help: false };
if (args.includes('--help')) {
result.help = true;
return result;
}
const knownFlags = new Set(['--in', '--format', '--out']);
let index = 0;
while (index < args.length) {
const arg = args[index];
if (!arg.startsWith('--') || !knownFlags.has(arg)) {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: `error: ${arg} requires a value` };
}
const value = args[index];
if (arg === '--in') {
result.inPath = value;
} else if (arg === '--format') {
result.format = value;
} else if (arg === '--out') {
result.outPath = value;
}
index += 1;
}
if (!result.inPath) {
throw { code: EXIT_CLI, message: 'error: --in is required' };
}
if (result.inPath === '-') {
throw { code: EXIT_CLI, message: 'error: --in - is not supported' };
}
if (result.inPath !== '-' && !result.inPath.endsWith('.json')) {
throw { code: EXIT_CLI, message: 'error: --in file path must end with .json' };
}
if (result.format !== 'text' && result.format !== 'json') {
throw { code: EXIT_CLI, message: 'error: --format must be text or json' };
}
if (result.outPath !== null && result.outPath !== '-') {
if (result.format === 'json' && !result.outPath.endsWith('.json')) {
throw {
code: EXIT_CLI,
message: 'error: --out file path must end with .json for --format json',
};
}
if (result.format === 'text' && !result.outPath.endsWith('.txt')) {
throw {
code: EXIT_CLI,
message: 'error: --out file path must end with .txt for --format text',
};
}
}
return result;
}
export function buildReviewStateSummary(payload) {
assertReviewStateV1(payload);
return {
version: 1,
pr: {
url: payload.pr.url,
nodeId: payload.pr.nodeId,
number: payload.pr.number,
title: payload.pr.title,
state: payload.pr.state,
},
fetchedAt: payload.fetchedAt,
sourceBranch: payload.sourceBranch,
counts: {
unresolvedThreads: payload.reviewThreads.length,
reviewsWithBody: payload.reviews.length,
issueComments: payload.issueComments.length,
},
unresolvedThreadNodeIds: payload.reviewThreads.map((thread) => thread.nodeId),
reviewNodeIds: payload.reviews.map((review) => review.nodeId),
issueCommentNodeIds: payload.issueComments.map((comment) => comment.nodeId),
};
}
export function renderReviewStateSummaryText(summary) {
const lines = [];
lines.push('Review State Summary');
lines.push(`PR: ${summary.pr.url ?? ''}`);
lines.push(`FetchedAt: ${summary.fetchedAt}`);
lines.push(`SourceBranch: ${summary.sourceBranch ?? ''}`);
lines.push('');
lines.push(`Unresolved threads: ${summary.counts.unresolvedThreads}`);
lines.push(`Reviews with body: ${summary.counts.reviewsWithBody}`);
lines.push(`Issue comments: ${summary.counts.issueComments}`);
lines.push('');
lines.push('Unresolved thread nodeIds:');
for (const nodeId of summary.unresolvedThreadNodeIds) {
lines.push(`- ${nodeId}`);
}
lines.push('');
lines.push('Review nodeIds:');
for (const nodeId of summary.reviewNodeIds) {
lines.push(`- ${nodeId}`);
}
lines.push('');
lines.push('Issue comment nodeIds:');
for (const nodeId of summary.issueCommentNodeIds) {
lines.push(`- ${nodeId}`);
}
return `${lines.join('\n')}\n`;
}
export function renderReviewStateSummaryJson(summary) {
return formatCanonicalJson(summary);
}
async function readJson(path) {
const raw = await readFile(path, 'utf8');
return JSON.parse(raw);
}
async function writeOutput(outPath, text) {
if (!outPath || outPath === '-') {
process.stdout.write(text);
return;
}
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, text, 'utf8');
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
const payload = await readJson(args.inPath);
const summary = buildReviewStateSummary(payload);
const output =
args.format === 'json'
? renderReviewStateSummaryJson(summary)
: renderReviewStateSummaryText(summary);
await writeOutput(args.outPath, output);
}
function safeRealpath(path) {
try {
return realpathSync(path);
} catch {
return null;
}
}
const invokedScriptPath = process.argv[1] ? safeRealpath(resolve(process.argv[1])) : null;
const currentModulePath = safeRealpath(fileURLToPath(import.meta.url));
const isMain =
invokedScriptPath !== null &&
currentModulePath !== null &&
invokedScriptPath === currentModulePath;
if (isMain) {
main().catch((error) => {
const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;
const message = error?.message ? String(error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(code);
});
}
export { parseCliArgs };
#!/usr/bin/env node
import { realpathSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertReviewStateV1, REVIEW_STATE_VERSION } from './review-artifacts.mjs';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { inPath: null, help: false };
if (args.includes('--help')) {
result.help = true;
return result;
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg !== '--in') {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: 'error: --in requires a value' };
}
result.inPath = args[index];
}
if (!result.inPath) {
throw { code: EXIT_CLI, message: 'error: --in is required' };
}
if (!result.inPath.endsWith('.json')) {
throw { code: EXIT_CLI, message: 'error: --in file path must end with .json' };
}
return result;
}
function getHelpText() {
return [
'Usage:',
' validate-review-state.mjs --in <review-state.json>',
'',
'Purpose:',
` Validate canonical review-state.json schema (v${REVIEW_STATE_VERSION}).`,
].join('\n');
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
const raw = await readFile(args.inPath, 'utf8');
const parsed = JSON.parse(raw);
assertReviewStateV1(parsed);
process.stdout.write(`ok: ${args.inPath}\n`);
}
const isMain = (() => {
try {
const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;
const currentModulePath = realpathSync(fileURLToPath(import.meta.url));
return invokedScriptPath !== null && invokedScriptPath === currentModulePath;
} catch {
return false;
}
})();
if (isMain) {
main().catch((error) => {
const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;
const message = error?.message ? String(error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(code);
});
}
Related skills
FAQ
What does review-fetch-phase do?
Fetches canonical PR review state and renders derived state artifacts. Use when the user wants the state acquisition phase only (fetch, render, summarize) for a review-framework PR.
When should I use review-fetch-phase?
User asks about review-fetch-phase, fetches canonical pr review state and renders derived state artifacts. use when the user w.
Is this skill safe to install?
Review the Security Audits panel on this page before installing in production.