
Review Implement Phase
- 2 installs
- 418 repo stars
- Updated August 3, 2026
- prisma/prisma-next
Implements triaged review actions, commits focused fixes, and posts Done plus resolves threads. Use when the user wants only the implementation phase of the review-framework workflow.
About
Implements triaged review actions, commits focused fixes, and posts Done plus resolves threads. Use when the user wants only the implementation phase of the review-framework workflow. name: review-implement-phase description: Implements triaged review actions, commits focused fixes, and posts Done plus resolves threads. Use when the user wants only the implementation phase of the review-framework workflow.
- existing `review-actions.json` in output dir
- scope constraints (specific action IDs or files)
- `/review-fetch-phase <PR_URL> [output-dir]`
- `/review-triage-phase <PR_URL> [output-dir]`
- `status: pending | in_progress`
Review Implement 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-implement-phase capabilities & compatibility
- Capabilities
- review implement phase quick start · review implement phase when to use guidance · review implement phase integration patterns
What review-implement-phase says it does
argument-hint: "[pr-url] [output-dir]"
Run only the implementation phase of the review-framework loop:
npx skills add https://github.com/prisma/prisma-next --skill review-implement-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-implement-phase correctly?
Implements triaged review actions, commits focused fixes, and posts Done plus resolves threads. Use when the user wants only the implementation phase of the review-framework workflow.
Who is it for?
Teams implementing review-implement-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-implement-phase, implements triaged review actions, commits focused fixes, and posts done plus resolves thr.
What you get
Working review-implement-phase setup with validated configuration and next steps.
Files
Review Implement Phase
Run only the implementation phase of the review-framework loop:
take triaged will_address actions, make code changes, commit in logical steps, post GitHub status updates, and update action status.
Run commands from this skill directory. All script paths below are relative to it.
Inputs
- Required:
- PR URL
- existing
review-actions.jsonin output dir - Optional:
- output directory
- scope constraints (specific action IDs or files)
If output directory is omitted, derive:
wip/reviews/<owner>_<repo>_pr-<number>/
Preconditions
<output-dir>/review-actions.json must exist and be valid v2.
System dependencies required on PATH:
gh(GitHub CLI)
If gh is missing, halt immediately and ask the user to install it. The implement-phase scripts no longer depend on jq.
GitHub admin capability must be available before starting implementation:
node ./scripts/check-github-admin-ready.mjs --pr <PR_URL>If missing, instruct user to run:
/review-fetch-phase <PR_URL> [output-dir]/review-triage-phase <PR_URL> [output-dir]
Behavior
1. Read actions JSON and select actionable rows:
decision: will_addressstatus: pending | in_progress
2. Preflight GitHub admin capability:
- run
check-github-admin-ready.mjsand fail fast if unavailable
3. Always post standalone comments (never pending PR reviews):
- When posting progress updates, do not create a PR review (draft/pending or otherwise).
- Forbidden flows:
gh pr review --comment ...- GraphQL
addPullRequestReview,addPullRequestReviewComment,addPullRequestReviewThread(this workflow never uses pending reviews) - Allowed flows:
- thread replies via
addPullRequestReviewThreadReply(or wrapper script) - issue comments via
addComment(or wrapper script) - Before starting implementation:
- Detect pending reviews authored by the acting user on this PR.
- If any exist, halt and clean them up (submit or dismiss) before continuing.
- After posting any "On it" / "Done" comment:
- Re-check for pending reviews authored by the acting user.
- If any exist, the workflow is blocked until they are cleaned up.
- Implementation requirement:
- For
review_threadtargets, always reply using thread replies (never inline PR review comments). - If you only have the thread node id, first fetch the thread’s primary comment node id, then call
addPullRequestReviewThreadReply. - For
pull_request_reviewtargets (review-body findings,PRR_…node ids), inline replies are not possible.post-review-thread-reply.mjsauto-detects this and posts a top-level PR issue comment instead (responsekind: "issue_comment"); there is no thread to resolve, so the implementer skipsresolve-review-thread.mjsfor these and records the issue-comment id in the action'sdonerecord.
4. Delegate implementation to:
./agents/review-implementer.md
5. Require implementer responsibilities:
- make code changes
- run relevant checks
- create focused commits
- post "On it" when starting each action
- post "Done" when finished (universal); resolve the thread only when `target.kind === "review_thread"` and a
threadNodeIdis available.pull_request_reviewtargets have no inline thread, so the implementer skips the resolve step for them and records the issue-comment id in the action'sdonerecord (per behavior step 3). - use encoded helper scripts for thread admin operations:
node ./scripts/post-review-thread-reply.mjs --repo <owner>/<repo> --pr <number> --comment-node-id <primaryCommentNodeId> --body "<text>"(works for bothreview_threadandpull_request_review— auto-detects node kind)node ./scripts/resolve-review-thread.mjs --thread-node-id <threadNodeId>(only forreview_threadtargets)- comments must be posted as individual standalone comments/replies, never as part of a pending review
- after each action completion (Done + resolve when applicable), verify no new pending review was created by the acting user
- never use inline parser snippets (for example:
python -c,node -e,ruby -e, ad-hoc awk/sed JSON parsing) - only set
status: doneafter Done (and, forreview_threadtargets, resolve) succeeds - update
review-actions.json(status,done.doneAt,done.summary,done.commits) in the same completion step
6. Render latest action markdown:
node ../review-triage-phase/scripts/render-review-actions.mjs --in <output-dir>/review-actions.json --out <output-dir>/review-actions.mdOwnership
- This phase owns actual fixes plus posting Done and resolving completed threads.
- If GitHub thread reply/resolve cannot be performed, the phase is blocked and must not report completion.
- If comments were accidentally posted as a pending review, the phase is blocked until the pending review is explicitly submitted or dismissed and the action comments are re-posted as standalone comments.
Output to user
Return:
- commits created
- actions transitioned to done
- written artifacts (
review-actions.json,review-actions.md)
Suggest next steps:
/review-fetch-phase <PR_URL> [output-dir]/review-triage-phase <PR_URL> [output-dir]
You are a PR review implementer. Your job is to turn an action plan from review triage into code changes that get the PR merged.
Run commands from the review-implement-phase skill directory. Script paths below are relative to it.
Inputs you expect
- PR URL.
- Paths to
review-actions.json(canonical) andreview-actions.md(human summary). - Scope constraints (optional).
Workflow
1. Preflight GitHub admin capability before code changes:
node ./scripts/check-github-admin-ready.mjs --pr <url>- If this fails, stop immediately and report blocked state. Do not implement actions.
- This preflight enforces the required
gh(GitHub CLI) dependency. The implement-phase scripts no longer depend onjq.
2. Read review-actions.md and implement each action row.
- Treat
review-actions.jsonas the source of truth for what is pending/done.
3. For each action:
- Make the smallest coherent change.
- Run the smallest relevant checks (package test/typecheck/lint as appropriate).
- Create a focused commit (explicit staging; no
git add -A/git add .; no amend). - Reply on the associated GitHub thread when you begin work (short “On it” + 👍) using:
node ./scripts/post-review-thread-reply.mjs --repo <owner>/<repo> --pr <number> --comment-node-id <primaryCommentNodeId> --body "<text>"- For
pull_request_reviewtargets (review-body findings,PRR_…node ids), the helper auto-detects the kind and posts a top-level PR issue comment (responsekind: "issue_comment"). There is no inline thread, so skip `resolve-review-thread.mjs` for these targets and record the issue-comment id in the action'sdonerecord (done.githubAdmin.issueCommentId). - After the change lands (commit exists and checks pass), reply “Done” (or similar) and resolve the thread using:
node ./scripts/post-review-thread-reply.mjs --repo <owner>/<repo> --pr <number> --comment-node-id <primaryCommentNodeId> --body "<text>"node ./scripts/resolve-review-thread.mjs --thread-node-id <threadNodeId>- Never use inline parser snippets (
python -c,node -e,ruby -e, ad-hoc awk/sed JSON parsing). Use the helper scripts above. - Update
review-actions.jsonin-place: - set
status: in_progresswhen starting - set
status: donewhen finished - set
donerecord with: doneAt(ISO-8601 timestamp)summary(what changed)commits(list of commit SHAs for this action)- optional
githubAdminapply metadata if available
4. After all actions:
- Re-render the action summary:
node ../review-triage-phase/scripts/render-review-actions.mjs --in <review-actions.json> --out <review-actions.md>- Re-fetch + derive view:
node ../review-fetch-phase/scripts/fetch-review-state.mjs --pr <url> --out-json <review-state.json>node ../review-fetch-phase/scripts/render-review-state.mjs --in <review-state.json> --out <review-state.md>- Confirm there are no unresolved actionable items.
Action targeting rules
- Use
target.kind+target.nodeIdfromreview-actions.jsonas canonical target identifiers. - Do not rely on numeric
databaseIdfields directly; derive fromsource.primaryCommentNodeIdvia helper scripts. - Preserve
actions[]ordering inreview-actions.json; only update status/completion fields in place. - Do not mark a
review_threadactiondoneunless its GitHub thread has received a Done reply and is resolved. Forpull_request_reviewactions, markdoneonce the top-level Done issue comment has been posted (no thread to resolve).
Git hygiene
- Keep commits reviewable and intent-driven.
- Stage explicit paths only.
- Never commit unrelated untracked files (e.g. local scripts, downloaded review snapshots, scratch dirs).
- Never commit anything under
wip/. - Keep artifacts under
wip/reviews/<owner>_<repo>_pr-<number>/.
{
"name": "@prisma-next/skill-review-implement-phase",
"private": true,
"type": "module",
"version": "0.0.0",
"description": "Implement phase package for review-framework skill",
"scripts": {
"check-github-admin-ready": "node scripts/check-github-admin-ready.mjs",
"post-review-thread-reply": "node scripts/post-review-thread-reply.mjs",
"resolve-review-thread": "node scripts/resolve-review-thread.mjs"
}
}
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
const SUBPROCESS_TIMEOUT_MS = 30_000;
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { prUrl: 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 !== '--pr') {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: 'error: --pr requires a value' };
}
result.prUrl = args[index];
}
if (!result.prUrl) {
throw { code: EXIT_CLI, message: 'error: --pr is required' };
}
return result;
}
function getHelpText() {
return [
'Usage:',
' check-github-admin-ready.mjs --pr <PR_URL>',
'',
'Purpose:',
' Verify gh authentication + repo scopes and PR API access before implement phase.',
].join('\n');
}
function parsePrUrl(url) {
const match = String(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 run(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8', timeout: SUBPROCESS_TIMEOUT_MS });
if (result.error?.code === 'ETIMEDOUT') {
throw new Error(`error: ${command} timed out after ${SUBPROCESS_TIMEOUT_MS / 1000} seconds`);
}
if (result.signal) {
throw new Error(`error: ${command} was terminated by signal ${result.signal}`);
}
return result;
}
function hasRepoScope(output) {
const scopeLine = output.split(/\r?\n/).find((line) => line.includes('Token scopes:'));
if (!scopeLine) {
return false;
}
return scopeLine
.replace(/^.*Token scopes:\s*/u, '')
.split(',')
.map((scope) => scope.trim().replace(/^['"]|['"]$/g, ''))
.includes('repo');
}
function assertCommandAvailable(command, installHint) {
const probe = run(command, ['--version']);
if (probe.error || probe.status !== 0) {
throw new Error(
`error: required dependency "${command}" is not available. Install ${installHint} and retry.`,
);
}
}
function assertGhAuthAndScopes() {
const auth = run('gh', ['auth', 'status']);
if (auth.error) {
throw new Error(`error: failed to execute gh: ${auth.error.message}`);
}
if (auth.status !== 0) {
throw new Error('error: gh is not authenticated; run `gh auth login`.');
}
const output = `${auth.stdout}\n${auth.stderr}`;
if (!hasRepoScope(output)) {
throw new Error('error: gh token is missing `repo` scope required for review thread admin.');
}
}
function assertPrApiAccess(owner, repo, number) {
const query =
'query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){id url state}}}';
const result = run('gh', [
'api',
'graphql',
'-f',
`query=${query}`,
'-F',
`owner=${owner}`,
'-F',
`repo=${repo}`,
'-F',
`number=${number}`,
]);
if (result.status !== 0) {
throw new Error(
`error: cannot access PR via gh api graphql: ${result.stderr || result.stdout}`.trim(),
);
}
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
const parsed = parsePrUrl(args.prUrl);
if (!parsed) {
throw {
code: EXIT_CLI,
message: 'error: invalid PR URL (expected https://github.com/OWNER/REPO/pull/123)',
};
}
assertCommandAvailable('gh', 'GitHub CLI (`gh`)');
assertGhAuthAndScopes();
assertPrApiAccess(parsed.owner, parsed.repo, parsed.number);
process.stdout.write('ok: github admin preflight passed\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 { spawnSync } from 'node:child_process';
import { readFileSync, realpathSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
const SUBPROCESS_TIMEOUT_MS = 30_000;
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = {
help: false,
repo: null,
prNumber: null,
commentNodeId: null,
body: null,
bodyFile: null,
};
if (args.includes('--help')) {
result.help = true;
return result;
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (
arg !== '--repo' &&
arg !== '--pr' &&
arg !== '--comment-node-id' &&
arg !== '--body' &&
arg !== '--body-file'
) {
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 === '--repo') result.repo = value;
if (arg === '--pr') result.prNumber = value;
if (arg === '--comment-node-id') result.commentNodeId = value;
if (arg === '--body') result.body = value;
if (arg === '--body-file') result.bodyFile = value;
}
if (!result.repo) {
throw { code: EXIT_CLI, message: 'error: --repo is required (OWNER/REPO)' };
}
if (!result.prNumber || !/^[1-9]\d*$/.test(result.prNumber)) {
throw {
code: EXIT_CLI,
message: 'error: --pr is required (positive integer pull request number; e.g. 123)',
};
}
if (!result.commentNodeId) {
throw { code: EXIT_CLI, message: 'error: --comment-node-id is required' };
}
if (result.body === null && result.bodyFile === null) {
throw { code: EXIT_CLI, message: 'error: provide exactly one of --body or --body-file' };
}
if (result.body !== null && result.bodyFile !== null) {
throw { code: EXIT_CLI, message: 'error: provide only one of --body or --body-file' };
}
return result;
}
function getHelpText() {
return [
'Usage:',
' post-review-thread-reply.mjs --repo <OWNER/REPO> --pr <NUMBER> --comment-node-id <NODE_ID> (--body <TEXT> | --body-file <PATH>)',
'',
'Purpose:',
' Post acknowledgement to a review-target node and exit with a JSON result.',
'',
' Behaviour by node type (auto-detected via GraphQL):',
' * PullRequestReviewComment (inline thread comment, PRRC_…): post an inline',
' reply via repos/{repo}/pulls/{pr}/comments with in_reply_to.',
' * PullRequestReview (review body, PRR_…): review bodies do not accept inline',
' replies, so post a top-level PR issue comment via',
' repos/{repo}/issues/{pr}/comments. The response kind is "issue_comment".',
'',
' Anything else exits with a clear "unsupported node kind" error.',
].join('\n');
}
function run(command, args, input = null) {
const result = spawnSync(command, args, {
encoding: 'utf8',
input: input ?? undefined,
timeout: SUBPROCESS_TIMEOUT_MS,
});
if (result.error) {
if (result.error.code === 'ETIMEDOUT') {
throw new Error(`error: ${command} timed out after ${SUBPROCESS_TIMEOUT_MS / 1000} seconds`);
}
throw new Error(`error: failed to execute ${command}: ${result.error.message}`);
}
if (result.signal) {
throw new Error(`error: ${command} was terminated by signal ${result.signal}`);
}
if (result.status !== 0) {
throw new Error(
`error: ${command} ${args.join(' ')} failed: ${result.stderr || result.stdout}`.trim(),
);
}
return result.stdout;
}
function assertCommandAvailable(command, installHint) {
const probe = spawnSync(command, ['--version'], {
encoding: 'utf8',
timeout: SUBPROCESS_TIMEOUT_MS,
});
if (probe.error?.code === 'ETIMEDOUT') {
throw new Error(
`error: required dependency "${command}" timed out after ${SUBPROCESS_TIMEOUT_MS / 1000} seconds.`,
);
}
if (probe.signal) {
throw new Error(
`error: required dependency "${command}" was terminated by signal ${probe.signal}.`,
);
}
if (probe.error || probe.status !== 0) {
throw new Error(
`error: required dependency "${command}" is not available. Install ${installHint} and retry.`,
);
}
}
function parseApiResponse(jsonText, contextDescription) {
let parsed;
try {
parsed = JSON.parse(jsonText);
} catch (parseError) {
throw new Error(`error: failed to parse ${contextDescription}: ${parseError.message}`);
}
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('; ');
throw new Error(`error: ${messages}`);
}
return parsed;
}
function resolveTargetNode(commentNodeId) {
const query = [
'query($id:ID!){',
' node(id:$id){',
' __typename',
' ... on PullRequestReviewComment {',
' databaseId',
' pullRequest { number repository { nameWithOwner } }',
' }',
' ... on PullRequestReview {',
' databaseId',
' pullRequest { number repository { nameWithOwner } }',
' }',
' }',
'}',
].join('\n');
const response = run('gh', [
'api',
'graphql',
'-f',
`query=${query}`,
'-F',
`id=${commentNodeId}`,
]);
const parsed = parseApiResponse(response, 'GraphQL node lookup response');
const node = parsed?.data?.node;
if (!node || typeof node !== 'object') {
throw new Error(`error: GraphQL node lookup returned no node for id ${commentNodeId}`);
}
const typename = node.__typename;
const databaseId =
typeof node.databaseId === 'number'
? node.databaseId
: typeof node.databaseId === 'string' && node.databaseId.length > 0
? Number.parseInt(node.databaseId, 10)
: null;
if (typename !== 'PullRequestReviewComment' && typename !== 'PullRequestReview') {
throw new Error(
`error: unsupported node kind "${typename ?? 'unknown'}" for ${commentNodeId} (expected PullRequestReviewComment or PullRequestReview)`,
);
}
if (databaseId === null || Number.isNaN(databaseId)) {
throw new Error(`error: failed to resolve databaseId for ${typename} node ${commentNodeId}`);
}
const repo =
typeof node.pullRequest?.repository?.nameWithOwner === 'string'
? node.pullRequest.repository.nameWithOwner
: null;
const prNumber = typeof node.pullRequest?.number === 'number' ? node.pullRequest.number : null;
if (!repo || prNumber === null) {
throw new Error(
`error: GraphQL node lookup did not return owning repo and PR for ${commentNodeId}`,
);
}
return { kind: typename, databaseId, repo, prNumber };
}
function assertNodeBelongsTo(target, expectedRepo, expectedPrNumber, commentNodeId) {
if (target.repo.toLowerCase() !== expectedRepo.toLowerCase()) {
throw new Error(
`error: ${commentNodeId} belongs to ${target.repo}#${target.prNumber}, not ${expectedRepo}#${expectedPrNumber}`,
);
}
if (target.prNumber !== expectedPrNumber) {
throw new Error(
`error: ${commentNodeId} belongs to ${target.repo}#${target.prNumber}, not ${expectedRepo}#${expectedPrNumber}`,
);
}
}
function readBody(body, bodyFile) {
if (body !== null) {
return body;
}
return readFileSync(resolve(bodyFile), 'utf8');
}
function postInlineReply(repo, prNumber, body, inReplyToDatabaseId) {
const response = run('gh', [
'api',
`repos/${repo}/pulls/${prNumber}/comments`,
'--method',
'POST',
'-f',
`body=${body}`,
'-F',
`in_reply_to=${inReplyToDatabaseId}`,
]);
const parsed = parseApiResponse(response, 'inline-reply REST response');
if (typeof parsed?.id !== 'number') {
throw new Error('error: reply was posted but response did not include a numeric comment id');
}
return parsed.id;
}
function postIssueComment(repo, prNumber, body) {
const response = run('gh', [
'api',
`repos/${repo}/issues/${prNumber}/comments`,
'--method',
'POST',
'-f',
`body=${body}`,
]);
const parsed = parseApiResponse(response, 'issue-comment REST response');
if (typeof parsed?.id !== 'number') {
throw new Error(
'error: top-level PR comment was posted but response did not include a numeric comment id',
);
}
return parsed.id;
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
assertCommandAvailable('gh', 'GitHub CLI (`gh`)');
const target = resolveTargetNode(args.commentNodeId);
assertNodeBelongsTo(target, args.repo, Number.parseInt(args.prNumber, 10), args.commentNodeId);
const body = readBody(args.body, args.bodyFile);
if (target.kind === 'PullRequestReviewComment') {
const replyId = postInlineReply(args.repo, args.prNumber, body, target.databaseId);
process.stdout.write(
`${JSON.stringify({
ok: true,
kind: 'review_thread_reply',
replyCommentId: replyId,
inReplyTo: target.databaseId,
commentNodeId: args.commentNodeId,
})}\n`,
);
return;
}
const issueCommentId = postIssueComment(args.repo, args.prNumber, body);
process.stdout.write(
`${JSON.stringify({
ok: true,
kind: 'issue_comment',
issueCommentId,
reviewDatabaseId: target.databaseId,
commentNodeId: args.commentNodeId,
note: 'PullRequestReview targets do not accept inline replies; posted a top-level PR issue comment instead.',
})}\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 { spawnSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
const SUBPROCESS_TIMEOUT_MS = 30_000;
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { help: false, threadNodeId: null };
if (args.includes('--help')) {
result.help = true;
return result;
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg !== '--thread-node-id') {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: 'error: --thread-node-id requires a value' };
}
result.threadNodeId = args[index];
}
if (!result.threadNodeId) {
throw { code: EXIT_CLI, message: 'error: --thread-node-id is required' };
}
return result;
}
function getHelpText() {
return [
'Usage:',
' resolve-review-thread.mjs --thread-node-id <NODE_ID>',
'',
'Purpose:',
' Resolve a pull request review thread by node ID via GitHub GraphQL API.',
].join('\n');
}
function run(command, args, input = null) {
const result = spawnSync(command, args, {
encoding: 'utf8',
input: input ?? undefined,
timeout: SUBPROCESS_TIMEOUT_MS,
});
if (result.error) {
if (result.error.code === 'ETIMEDOUT') {
throw new Error(`error: ${command} timed out after ${SUBPROCESS_TIMEOUT_MS / 1000} seconds`);
}
throw new Error(`error: failed to execute ${command}: ${result.error.message}`);
}
if (result.signal) {
throw new Error(`error: ${command} was terminated by signal ${result.signal}`);
}
if (result.status !== 0) {
throw new Error(
`error: ${command} ${args.join(' ')} failed: ${result.stderr || result.stdout}`.trim(),
);
}
return result.stdout;
}
function assertCommandAvailable(command, installHint) {
const probe = spawnSync(command, ['--version'], {
encoding: 'utf8',
timeout: SUBPROCESS_TIMEOUT_MS,
});
if (probe.error || probe.status !== 0) {
if (probe.error?.code === 'ETIMEDOUT') {
throw new Error(
`error: required dependency "${command}" timed out after ${SUBPROCESS_TIMEOUT_MS / 1000} seconds.`,
);
}
if (probe.signal) {
throw new Error(
`error: required dependency "${command}" was terminated by signal ${probe.signal}.`,
);
}
throw new Error(
`error: required dependency "${command}" is not available. Install ${installHint} and retry.`,
);
}
}
function resolveThread(threadNodeId) {
const mutation = [
'mutation($threadId:ID!){',
' resolveReviewThread(input:{threadId:$threadId}){',
' thread {',
' id',
' isResolved',
' }',
' }',
'}',
].join('\n');
const response = run('gh', [
'api',
'graphql',
'-f',
`query=${mutation}`,
'-F',
`threadId=${threadNodeId}`,
]);
let parsed;
try {
parsed = JSON.parse(response);
} catch (parseError) {
throw new Error(`error: failed to parse GraphQL response: ${parseError.message}`);
}
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('; ');
throw new Error(`error: ${messages}`);
}
const thread = parsed?.data?.resolveReviewThread?.thread;
if (thread?.isResolved !== true) {
throw new Error(
`error: thread was not resolved successfully (isResolved=${thread?.isResolved === undefined ? 'null' : String(thread.isResolved)})`,
);
}
return { resolvedThreadId: thread.id, isResolved: true };
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
assertCommandAvailable('gh', 'GitHub CLI (`gh`)');
const result = resolveThread(args.threadNodeId);
process.stdout.write(
`${JSON.stringify({
ok: true,
threadNodeId: args.threadNodeId,
resolvedThreadId: result.resolvedThreadId,
isResolved: true,
})}\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);
});
}
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmod, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { delimiter, dirname, join, resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const scriptPath = resolve(dirname(fileURLToPath(import.meta.url)), 'resolve-review-thread.mjs');
async function createFakeGh() {
const directory = await mkdtemp(join(tmpdir(), 'resolve-review-thread-test-'));
const ghPath = join(directory, 'gh');
await writeFile(
ghPath,
[
'#!/usr/bin/env node',
"if (process.argv[2] === '--version') {",
" process.stdout.write('gh version fake\\n');",
' process.exit(0);',
'}',
"process.stdout.write(process.env.FAKE_GH_STDOUT ?? '{}');",
'',
].join('\n'),
'utf8',
);
await chmod(ghPath, 0o755);
return directory;
}
async function runResolve(args, fakeStdout) {
const fakeGhDirectory = await createFakeGh();
return spawnSync(process.execPath, [scriptPath, ...args], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${fakeGhDirectory}${delimiter}${process.env.PATH ?? ''}`,
FAKE_GH_STDOUT: fakeStdout,
},
});
}
test('rejects unknown CLI flags', () => {
const result = spawnSync(process.execPath, [scriptPath, '--bogus'], { encoding: 'utf8' });
assert.equal(result.status, 2);
assert.match(result.stderr, /unknown flag "--bogus"/);
});
test('rejects missing thread id', () => {
const result = spawnSync(process.execPath, [scriptPath], { encoding: 'utf8' });
assert.equal(result.status, 2);
assert.match(result.stderr, /--thread-node-id is required/);
});
test('reports malformed JSON responses', async () => {
const result = await runResolve(['--thread-node-id', 'THREAD_1'], 'not json');
assert.equal(result.status, 1);
assert.match(result.stderr, /failed to parse GraphQL response/);
});
test('reports GraphQL errors', async () => {
const result = await runResolve(
['--thread-node-id', 'THREAD_1'],
JSON.stringify({ errors: [{ message: 'Could not resolve thread' }] }),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /Could not resolve thread/);
});
test('reports unresolved mutation payloads', async () => {
const result = await runResolve(
['--thread-node-id', 'THREAD_1'],
JSON.stringify({
data: {
resolveReviewThread: {
thread: { id: 'THREAD_1', isResolved: false },
},
},
}),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /thread was not resolved successfully/);
});
test('prints resolution payloads', async () => {
const result = await runResolve(
['--thread-node-id', 'THREAD_1'],
JSON.stringify({
data: {
resolveReviewThread: {
thread: { id: 'THREAD_1', isResolved: true },
},
},
}),
);
assert.equal(result.status, 0);
assert.deepEqual(JSON.parse(result.stdout), {
ok: true,
threadNodeId: 'THREAD_1',
resolvedThreadId: 'THREAD_1',
isResolved: true,
});
});
Related skills
FAQ
What does review-implement-phase do?
Implements triaged review actions, commits focused fixes, and posts Done plus resolves threads. Use when the user wants only the implementation phase of the review-framework workflow.
When should I use review-implement-phase?
User asks about review-implement-phase, implements triaged review actions, commits focused fixes, and posts done plus resolves thr.
Is this skill safe to install?
Review the Security Audits panel on this page before installing in production.