
Create Pr Review From Comments
- 1 installs
- 6 repo stars
- Updated July 31, 2026
- dimfeld/dotfiles
Converts AI-tagged comments in local files into GitHub PR review comments mapped to the correct lines, then posts them as a single PR review.
About
Finds AI comment markers in modified files, maps each to its original line, and submits them as a GitHub pull-request review with a status and body. A developer uses it to turn in-code review notes into a posted PR review.
- Extracts AI-tagged comments from local files into structured review comments
- Posts them as a GitHub PR review with a chosen status and body
Create Pr Review From Comments by the numbers
- 1 all-time installs (skills.sh)
- Ranked #524 of 733 Git & Pull Requests skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dimfeld/dotfiles --skill create-pr-review-from-commentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 31, 2026 |
| Repository | dimfeld/dotfiles ↗ |
What it does
Converts AI-tagged comments in local files into GitHub PR review comments mapped to the correct lines, then posts them as a single PR review.
Files
Find the current jj branch name using: jj log -r 'latest(heads(ancestors(@) & remote_bookmarks()), 1)' --limit 1 --no-graph -T bookmarks | tr -d '*' | sed 's/@origin$//'
Then use gh pr list --head <branch-name> to get the number of the PR.
IMPORTANT NOTE: ALL SCRIPTS ARE RELATIVE TO THE SKILL.md file path but you must run them from the repository directory.
Run the extract_comments.ts script to find all AI comments in modified files:
bun scripts/extract_comments.tsThis script will output each comment with:
- File path
- Comment type (single, block_start, block_end)
- The comment line number in the edited file
- The comment text
- The adjacent original line number and content (the line the comment refers to)
Use the "Adjacent Original Line" number as the line field in the Comment structure. For block comments (AI_COMMENT_START/AI_COMMENT_END pairs), use the block_start's adjacent line as startLine and the block_end's adjacent line as line.
interface Comment {
file: string;
startLine?: number;
line: number
text: string;
}
interface Input {
pr: number;
status?: 'comment' | 'requestChanges' | 'approve';
body: string;
comments: Comment[];
}Once you have that, read the files and relevant surrounding lines to ensure you have the full context of each comment.
After extracting comments, display them and ask if the user wants to:
- set a particular status value
- modify the comments
Finally, separately ask for a review body (main message) to be included with the review.
Generate an object of type Input, save it as JSON to a temporary file, and pass it to scripts/create_review.ts.
You may add extra context to a comment if needed to better explain it, but if you do this you MUST do it in a separate paragraph that starts with "AI says: " so that it's very clear what the original reviewer wrote and what you are adding.
GITHUB_TOKEN=$(gh auth token) bun scripts/create_review.ts <tempfile># dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "scripts",
"dependencies": {
"@octokit/rest": "^22.0.1",
"zod": "^4.3.5",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
"@octokit/endpoint": ["@octokit/endpoint@11.0.2", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="],
"@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="],
"@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="],
"@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
"@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="],
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
}
}
#!/usr/bin/env bun
import { Octokit } from "@octokit/rest";
import { z } from "zod/v4";
const CommentSchema = z.object({
file: z.string(),
startLine: z.number().optional(),
line: z.number(),
text: z.string(),
});
const InputSchema = z.object({
owner: z.string().optional(),
repo: z.string().optional(),
pr: z.number(),
status: z.enum(["comment", "requestChanges", "approve"]).optional(),
body: z.string(),
comments: z.array(CommentSchema),
});
type Input = z.infer<typeof InputSchema>;
async function getGitRemoteInfo(): Promise<{ owner: string; repo: string }> {
const proc = Bun.spawn(["git", "remote", "get-url", "origin"], {
stdout: "pipe",
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error("Failed to get git remote origin");
}
const remoteUrl = output.trim();
// Parse HTTPS URL: https://github.com/owner/repo.git
const httpsMatch = remoteUrl.match(
/^https:\/\/github\.com\/([^/]+)\/(.+?)(\.git)?$/
);
if (httpsMatch && httpsMatch[1] && httpsMatch[2]) {
return { owner: httpsMatch[1], repo: httpsMatch[2] };
}
// Parse SSH URL: git@github.com:owner/repo.git
const sshMatch = remoteUrl.match(/^git@github\.com:([^/]+)\/(.+?)(\.git)?$/);
if (sshMatch && sshMatch[1] && sshMatch[2]) {
return { owner: sshMatch[1], repo: sshMatch[2] };
}
throw new Error(`Unable to parse GitHub owner/repo from remote URL: ${remoteUrl}`);
}
async function main() {
const filePath = process.argv[2];
if (!filePath) {
console.error("Usage: create_review.ts <input-file.json>");
process.exit(1);
}
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.error("GITHUB_TOKEN environment variable is required");
process.exit(1);
}
const fileContent = await Bun.file(filePath).text();
const parseResult = InputSchema.safeParse(JSON.parse(fileContent));
if (!parseResult.success) {
console.error("Invalid input:", z.treeifyError(parseResult.error));
process.exit(1);
}
const input = parseResult.data;
// Get owner and repo from git remote if not provided
let owner: string;
let repo: string;
if (!input.owner || !input.repo) {
const gitInfo = await getGitRemoteInfo();
owner = input.owner || gitInfo.owner;
repo = input.repo || gitInfo.repo;
} else {
owner = input.owner;
repo = input.repo;
}
const octokit = new Octokit({ auth: token });
const eventMap = {
comment: "COMMENT",
requestChanges: "REQUEST_CHANGES",
approve: "APPROVE",
} as const;
const event = input.status ? eventMap[input.status] : "COMMENT";
const comments = input.comments.map((c) => ({
path: c.file,
start_line: c.startLine,
line: c.line,
body: c.text,
}));
const response = await octokit.pulls.createReview({
owner,
repo,
pull_number: input.pr,
body: input.body,
event,
comments,
});
console.log(`Review created: ${response.data.html_url}`);
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});
#!/usr/bin/env bun
interface DiffLine {
originalLine: number | null;
updatedLine: number | null;
content: string;
}
interface ExtractedComment {
file: string;
type: "single" | "block_start" | "block_end";
commentText: string;
updatedLineNumber: number;
originalLineNumber: number | null;
adjacentOriginalLine: string | null;
adjacentOriginalLineNumber: number | null;
}
function escapeFilename(filename: string): string {
// Escape brackets to prevent glob pattern interpretation
return filename.replace(/\[/g, "\\[").replace(/\]/g, "\\]");
}
async function runCommand(args: string[]): Promise<string> {
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new Error(`Command failed: ${args.join(" ")}\n${stderr}`);
}
return output;
}
async function getModifiedFiles(): Promise<string[]> {
const output = await runCommand(["jj", "status"]);
const files: string[] = [];
for (const line of output.split("\n")) {
const match = line.match(/^[MA]\s+(.+)$/);
if (match && match[1]) {
files.push(match[1]);
}
}
return files;
}
async function parseDiff(file: string): Promise<DiffLine[]> {
const output = await runCommand(["jj", "diff", "--color-words", escapeFilename(file)]);
const lines: DiffLine[] = [];
for (const line of output.split("\n")) {
// jj diff --color-words format:
// First column is original line number, second is updated line number, followed by colon
// Format: " 123 456: content" or " 456: content" (new line) or " 123 : content" (deleted)
const match = line.match(/^\s*(\d+)?\s+(\d+)?:\s?(.*)$/);
if (match) {
lines.push({
originalLine: match[1] ? parseInt(match[1], 10) : null,
updatedLine: match[2] ? parseInt(match[2], 10) : null,
content: match[3] || "",
});
}
}
return lines;
}
function findCommentPattern(content: string): { type: "single" | "block_start" | "block_end"; text: string } | null {
// Look for AI_COMMENT_END first (before AI_COMMENT_START to avoid partial match)
if (content.includes("AI_COMMENT_END")) {
return { type: "block_end", text: "AI_COMMENT_END" };
}
// Look for AI_COMMENT_START with optional text after it
const blockStartMatch = content.match(/AI_COMMENT_START\s*(.*)/);
if (blockStartMatch) {
return { type: "block_start", text: blockStartMatch[1]?.trim() || "" };
}
// Look for AI: with text after it
const singleMatch = content.match(/\bAI:\s*(.*)/);
if (singleMatch) {
return { type: "single", text: singleMatch[1]?.trim() || "" };
}
return null;
}
async function extractCommentsFromFile(file: string): Promise<ExtractedComment[]> {
const diffLines = await parseDiff(file);
const comments: ExtractedComment[] = [];
for (let i = 0; i < diffLines.length; i++) {
const line = diffLines[i]!;
if (line.updatedLine === null) continue; // Skip deleted lines
const commentMatch = findCommentPattern(line.content);
if (!commentMatch) {
continue;
}
if (commentMatch.type === "block_start") {
// Collect all consecutive added lines as part of the block_start comment
const commentLines: string[] = [commentMatch.text || ""];
let j = i + 1;
// Skip additional added lines that are part of the comment
while (j < diffLines.length) {
const nextLine = diffLines[j]!;
if (nextLine.updatedLine === null) {
// Deleted line, skip
j++;
continue;
}
if (nextLine.originalLine !== null) {
// Found original line, this is the adjacent line
break;
}
// Check if this added line ends the block (AI_COMMENT_END or another AI pattern)
const nextMatch = findCommentPattern(nextLine.content);
if (nextMatch) {
break; // Stop before this line - it will be processed separately
}
// Add this line to the comment
commentLines.push(nextLine.content);
j++;
}
// Find the adjacent original line
let adjacentLine: string | null = null;
let adjacentLineNum: number | null = null;
for (let k = j; k < diffLines.length; k++) {
const diffLine = diffLines[k]!;
if (diffLine.originalLine !== null) {
adjacentLine = diffLine.content;
adjacentLineNum = diffLine.originalLine;
break;
}
}
comments.push({
file,
type: "block_start",
commentText: commentLines.join("\n").trim() || "AI_COMMENT_START",
updatedLineNumber: line.updatedLine,
originalLineNumber: line.originalLine,
adjacentOriginalLine: adjacentLine,
adjacentOriginalLineNumber: adjacentLineNum,
});
// Skip the lines we consumed
i = j - 1;
} else if (commentMatch.type === "block_end") {
// Find the previous original line before this comment
let adjacentLine: string | null = null;
let adjacentLineNum: number | null = null;
for (let j = i - 1; j >= 0; j--) {
const diffLine = diffLines[j]!;
if (diffLine.originalLine !== null) {
adjacentLine = diffLine.content;
adjacentLineNum = diffLine.originalLine;
break;
}
}
comments.push({
file,
type: "block_end",
commentText: "AI_COMMENT_END",
updatedLineNumber: line.updatedLine,
originalLineNumber: line.originalLine,
adjacentOriginalLine: adjacentLine,
adjacentOriginalLineNumber: adjacentLineNum,
});
} else if (commentMatch.type === "single") {
// Collect all consecutive added lines as part of the comment
const commentLines: string[] = [commentMatch.text];
let j = i + 1;
// Check if this is an added line (no original line number)
// If so, look for more added lines that continue the comment
if (line.originalLine === null) {
while (j < diffLines.length) {
const nextLine = diffLines[j]!;
if (nextLine.updatedLine === null) {
// Deleted line, skip
j++;
continue;
}
if (nextLine.originalLine !== null) {
// Found original line, this is the adjacent line
break;
}
// Check if this is another AI pattern
const nextMatch = findCommentPattern(nextLine.content);
if (nextMatch) {
break; // Stop before this line - it will be processed separately
}
// Add this line to the comment
commentLines.push(nextLine.content);
j++;
}
}
// Find the adjacent original line
let adjacentLine: string | null = null;
let adjacentLineNum: number | null = null;
for (let k = j; k < diffLines.length; k++) {
const diffLine = diffLines[k]!;
if (diffLine.originalLine !== null) {
adjacentLine = diffLine.content;
adjacentLineNum = diffLine.originalLine;
break;
}
}
comments.push({
file,
type: "single",
commentText: commentLines.join("\n").trim(),
updatedLineNumber: line.updatedLine,
originalLineNumber: line.originalLine,
adjacentOriginalLine: adjacentLine,
adjacentOriginalLineNumber: adjacentLineNum,
});
// Skip the lines we consumed
if (line.originalLine === null) {
i = j - 1;
}
}
}
return comments;
}
async function main() {
const files = await getModifiedFiles();
if (files.length === 0) {
console.log("No modified files found.");
return;
}
console.log("Modified files:", files);
console.log("");
const allComments: ExtractedComment[] = [];
for (const file of files) {
try {
const comments = await extractCommentsFromFile(file);
allComments.push(...comments);
} catch (err) {
console.error(`Error processing ${file}:`, (err as Error).message);
}
}
if (allComments.length === 0) {
console.log("No AI comments found.");
return;
}
console.log("Extracted AI Comments:");
console.log("=".repeat(60));
for (const comment of allComments) {
console.log(`\nFile: ${comment.file}`);
console.log(`Type: ${comment.type}`);
console.log(`Comment Line in Edited File: ${comment.updatedLineNumber}`);
console.log(`Comment Text: ${comment.commentText}`);
if (comment.adjacentOriginalLineNumber !== null) {
console.log(`Adjacent Original Line ${comment.adjacentOriginalLineNumber}: ${comment.adjacentOriginalLine}`);
} else {
console.log(`Adjacent Original Line: (none found)`);
}
console.log("-".repeat(40));
}
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});
{
"name": "scripts",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@octokit/rest": "^22.0.1",
"zod": "^4.3.5"
}
}
scripts
To install dependencies:
bun installTo run:
bun run This project was created using bun init in bun v1.3.6. Bun is a fast all-in-one JavaScript runtime.
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}