Last active
September 20, 2025 00:10
-
-
Save pedronauck/beefd9e55e564e8ce6212e7137450d25 to your computer and use it in GitHub Desktop.
CodeRabbit Reviews generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bun | |
/** | |
* PR Review Exporter (fixed) | |
* | |
* What changed: | |
* - Correctly detects resolved vs unresolved by mapping REST review comments to | |
* GraphQL review thread comments via IDs (databaseId / node_id). | |
* - Removed brittle body+author matching. | |
* - Summary language corrected (review threads can be resolved; general PR comments cannot). | |
* | |
* Usage: | |
* GITHUB_TOKEN=ghp_... bun pr-review.ts <PR_NUMBER> | |
*/ | |
import { graphql } from "@octokit/graphql"; | |
import { Octokit } from "@octokit/rest"; | |
import { promises as fs } from "fs"; | |
import { execSync } from "node:child_process"; | |
import { join } from "path"; | |
// ---------- Types ---------- | |
interface BaseUser { | |
login: string; | |
} | |
interface Comment { | |
body: string; | |
user: BaseUser; | |
created_at: string; | |
// Present only for review (inline) comments: | |
path?: string; | |
line?: number; | |
// Present only for review (inline) comments from REST: | |
id?: number; // REST numeric id | |
node_id?: string; // REST relay/global ID (matches GraphQL id) | |
} | |
interface ReviewComment extends Comment { | |
path: string; | |
line: number; | |
id: number; | |
node_id: string; | |
} | |
interface IssueComment extends Comment { | |
// General PR comments; no path/line/id resolution | |
} | |
interface SimpleReviewComment { | |
// Pull Request Review (summary) comments, e.g., Approve/Comment with body | |
id: number; // review id (used by GitHub anchors: pullrequestreview-<id>) | |
body: string; | |
user: BaseUser; | |
created_at: string; // submitted_at from API | |
state: string; // APPROVED | COMMENTED | CHANGES_REQUESTED | DISMISSED | |
} | |
interface ReviewThread { | |
id: string; | |
isResolved: boolean; | |
comments: { | |
nodes: Array<{ | |
id: string; // GraphQL relay/global ID | |
databaseId: number | null; // GraphQL numeric DB id | |
body: string; | |
author: { login: string | null }; | |
createdAt: string; | |
}>; | |
}; | |
} | |
interface GraphQLResponse { | |
repository: { | |
pullRequest: { | |
reviewThreads: { | |
nodes: ReviewThread[]; | |
}; | |
}; | |
}; | |
} | |
// ---------- Main ---------- | |
async function main() { | |
const args = process.argv.slice(2); | |
if (args.length === 0) { | |
console.error("Usage: bun pr-review.ts <pr_number>"); | |
process.exit(1); | |
} | |
const prNumber = Number(args[0]); | |
if (!Number.isInteger(prNumber)) { | |
console.error("Error: PR number must be a valid integer"); | |
process.exit(1); | |
} | |
const token = process.env.GITHUB_TOKEN; | |
if (!token) { | |
console.error("Error: GITHUB_TOKEN environment variable is not set."); | |
process.exit(1); | |
} | |
const { owner, repo } = await getRepoInfo(); | |
console.log(`Fetching PR #${prNumber} from ${owner}/${repo} ...`); | |
const octokit = new Octokit({ auth: token }); | |
// Fetch data | |
console.log(" → review comments (REST) ..."); | |
const allReviewComments = await fetchAllReviewComments(octokit, owner, repo, prNumber); | |
console.log(" → issue comments (REST) ..."); | |
const allIssueComments = await fetchAllIssueComments(octokit, owner, repo, prNumber); | |
console.log(" → review threads (GraphQL) ..."); | |
const reviewThreads = await fetchReviewThreads(token, owner, repo, prNumber); | |
console.log(" → pull request reviews (REST) ..."); | |
const allSimpleReviews = await fetchAllPullRequestReviews(octokit, owner, repo, prNumber); | |
// Filter to CodeRabbit bot comments only | |
const coderabbitReviewComments = allReviewComments.filter( | |
c => c.user?.login === "coderabbitai[bot]" | |
); | |
const coderabbitIssueComments = allIssueComments.filter( | |
c => c.user?.login === "coderabbitai[bot]" | |
); | |
const coderabbitSimpleReviews = allSimpleReviews.filter( | |
r => r.user?.login === "coderabbitai[bot]" && (r.body?.trim()?.length ?? 0) > 0 | |
); | |
if ( | |
coderabbitReviewComments.length + | |
coderabbitIssueComments.length + | |
coderabbitSimpleReviews.length === | |
0 | |
) { | |
console.log(`No CodeRabbit AI comments found for PR #${prNumber}.`); | |
return; | |
} | |
const outputDir = `./ai-docs/reviews-pr-${prNumber}`; | |
const commentsDir = join(outputDir, "comments"); | |
const issuesDir = join(outputDir, "issues"); | |
const nitpicksDir = join(outputDir, "nitpicks"); | |
const outsideDir = join(outputDir, "outside"); | |
const duplicatedDir = join(outputDir, "duplicated"); | |
const summaryFile = join(outputDir, "_summary.md"); | |
await fs.mkdir(outputDir, { recursive: true }); | |
await fs.mkdir(commentsDir, { recursive: true }); | |
await fs.mkdir(issuesDir, { recursive: true }); | |
await fs.mkdir(nitpicksDir, { recursive: true }); | |
await fs.mkdir(outsideDir, { recursive: true }); | |
await fs.mkdir(duplicatedDir, { recursive: true }); | |
// Categories: | |
// - issues: resolvable review comments (inline threads) | |
// - comments: simple comments (general PR issue comments + PR review bodies) | |
const reviewComments = coderabbitReviewComments.slice(); | |
const issueComments = coderabbitIssueComments.slice(); | |
const simpleReviewComments = coderabbitSimpleReviews.slice(); | |
// Sort each category chronologically by creation time | |
reviewComments.sort((a, b) => a.created_at.localeCompare(b.created_at)); | |
issueComments.sort((a, b) => a.created_at.localeCompare(b.created_at)); | |
simpleReviewComments.sort((a, b) => a.created_at.localeCompare(b.created_at)); | |
// Count resolution by policy: thread resolved AND contains "✅ Addressed in commit" | |
const resolvedCount = reviewComments.filter(c => isCommentResolvedByPolicy(c, reviewThreads)).length; | |
const unresolvedCount = reviewComments.length - resolvedCount; | |
console.log("Creating issue files (resolvable review threads) in issues/ ..."); | |
for (let i = 0; i < reviewComments.length; i++) { | |
await createIssueFile(issuesDir, i + 1, reviewComments[i], reviewThreads); | |
} | |
console.log("Creating comment files (simple comments) in comments/ ..."); | |
// Merge general PR comments and simple PR review bodies into one sequence | |
type SimpleItem = | |
| { kind: "issue_comment"; data: IssueComment } | |
| { kind: "review"; data: SimpleReviewComment }; | |
const simpleItems: SimpleItem[] = [ | |
...issueComments.map(c => ({ kind: "issue_comment" as const, data: c })), | |
...simpleReviewComments.map(r => ({ kind: "review" as const, data: r })), | |
].sort((a, b) => a.data.created_at.localeCompare(b.data.created_at)); | |
type ExtractedInfo = { file: string; resolved: boolean; summaryPath: string; section: "nitpick" | "outside" | "duplicate" }; | |
const allExtracted: ExtractedInfo[] = []; | |
for (let i = 0; i < simpleItems.length; i++) { | |
const created = await createSimpleCommentFile( | |
commentsDir, | |
i + 1, | |
simpleItems[i], | |
{ nitpicksDir, outsideDir, duplicatedDir } | |
); | |
allExtracted.push(...created); | |
} | |
await createSummaryFile( | |
summaryFile, | |
prNumber, | |
reviewComments, | |
simpleItems, | |
resolvedCount, | |
unresolvedCount, | |
reviewThreads, | |
allExtracted | |
); | |
const totalGenerated = reviewComments.length + simpleItems.length; | |
console.log(`\n✅ Done. ${totalGenerated} files in ${outputDir}`); | |
console.log(`ℹ️ Threads resolved: ${resolvedCount} • unresolved: ${unresolvedCount}`); | |
} | |
// ---------- Helpers ---------- | |
async function getRepoInfo(): Promise<{ owner: string; repo: string }> { | |
try { | |
const remoteUrl = execSync("git config --get remote.origin.url", { encoding: "utf8" }).trim(); | |
const match = remoteUrl.match(/github\.com[\/:]([^\/]+)\/([^\/\.]+)/); | |
if (match) return { owner: match[1], repo: match[2] }; | |
throw new Error("Could not parse repository information from git remote"); | |
} catch (error) { | |
console.error( | |
"Error getting repository info. Ensure you're in a git repository with a GitHub remote." | |
); | |
throw error; | |
} | |
} | |
async function fetchAllReviewComments( | |
octokit: Octokit, | |
owner: string, | |
repo: string, | |
prNumber: number | |
): Promise<ReviewComment[]> { | |
try { | |
const comments = await octokit.paginate(octokit.rest.pulls.listReviewComments, { | |
owner, | |
repo, | |
pull_number: prNumber, | |
per_page: 100, | |
}); | |
// Normalize to the fields we use (and ensure id/node_id present) | |
return comments.map((c: any) => ({ | |
id: c.id, | |
node_id: c.node_id, | |
body: c.body || "", | |
user: { login: c.user?.login || "" }, | |
created_at: c.created_at, | |
path: c.path, | |
line: c.line, | |
})) as ReviewComment[]; | |
} catch (error) { | |
console.warn("Warning: Could not fetch review comments:", error); | |
return []; | |
} | |
} | |
async function fetchAllIssueComments( | |
octokit: Octokit, | |
owner: string, | |
repo: string, | |
prNumber: number | |
): Promise<IssueComment[]> { | |
try { | |
const comments = await octokit.paginate(octokit.rest.issues.listComments, { | |
owner, | |
repo, | |
issue_number: prNumber, | |
per_page: 100, | |
}); | |
return comments.map((c: any) => ({ | |
body: c.body || "", | |
user: { login: c.user?.login || "" }, | |
created_at: c.created_at, | |
})) as IssueComment[]; | |
} catch (error) { | |
console.warn("Warning: Could not fetch issue comments:", error); | |
return []; | |
} | |
} | |
async function fetchAllPullRequestReviews( | |
octokit: Octokit, | |
owner: string, | |
repo: string, | |
prNumber: number | |
): Promise<SimpleReviewComment[]> { | |
try { | |
const reviews = await octokit.paginate(octokit.rest.pulls.listReviews, { | |
owner, | |
repo, | |
pull_number: prNumber, | |
per_page: 100, | |
}); | |
return reviews.map((r: any) => ({ | |
id: r.id, | |
body: r.body || "", | |
user: { login: r.user?.login || "" }, | |
created_at: r.submitted_at || r.created_at, | |
state: r.state, | |
})) as SimpleReviewComment[]; | |
} catch (error) { | |
console.warn("Warning: Could not fetch pull request reviews:", error); | |
return []; | |
} | |
} | |
async function fetchReviewThreads( | |
token: string, | |
owner: string, | |
repo: string, | |
prNumber: number | |
): Promise<ReviewThread[]> { | |
try { | |
const query = ` | |
query($owner: String!, $repo: String!, $number: Int!) { | |
repository(owner: $owner, name: $repo) { | |
pullRequest(number: $number) { | |
reviewThreads(first: 100) { | |
nodes { | |
id | |
isResolved | |
comments(first: 100) { | |
nodes { | |
id | |
databaseId | |
body | |
author { login } | |
createdAt | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
`; | |
const result = await graphql<GraphQLResponse>(query, { | |
owner, | |
repo, | |
number: prNumber, | |
headers: { authorization: `token ${token}` }, | |
}); | |
return result.repository.pullRequest.reviewThreads.nodes; | |
} catch (error) { | |
console.warn("Warning: Could not fetch review threads:", error); | |
return []; | |
} | |
} | |
/** | |
* Determine if a review (inline) comment belongs to a resolved thread. | |
* Uses robust ID matching: | |
* REST.reviewComment.id ⇔ GraphQL.comment.databaseId | |
* REST.reviewComment.node_id ⇔ GraphQL.comment.id (fallback) | |
*/ | |
function isCommentResolved(comment: Comment, reviewThreads: ReviewThread[]): boolean { | |
// General PR (issue) comments cannot be resolved | |
if (!("path" in comment && "line" in comment)) return false; | |
const rc = comment as ReviewComment; | |
for (const thread of reviewThreads) { | |
const match = thread.comments.nodes.some( | |
tc => | |
(tc.databaseId != null && rc.id != null && tc.databaseId === rc.id) || | |
(!!rc.node_id && tc.id === rc.node_id) | |
); | |
if (match) return thread.isResolved; | |
} | |
return false; | |
} | |
// Policy-level resolution: the thread must be resolved AND contain | |
// a confirmation marker "✅ Addressed in commit" somewhere in the thread. | |
function isCommentResolvedByPolicy(comment: Comment, reviewThreads: ReviewThread[]): boolean { | |
if (!("path" in comment && "line" in comment)) return false; | |
const rc = comment as ReviewComment; | |
for (const thread of reviewThreads) { | |
const match = thread.comments.nodes.some( | |
tc => | |
(tc.databaseId != null && rc.id != null && tc.databaseId === rc.id) || | |
(!!rc.node_id && tc.id === rc.node_id) | |
); | |
if (match) { | |
const hasAddressed = thread.comments.nodes.some(tc => (tc.body || "").includes("✅ Addressed in commit")); | |
return Boolean(thread.isResolved && hasAddressed); | |
} | |
} | |
return false; | |
} | |
async function createIssueFile( | |
outputDir: string, | |
issueNumber: number, | |
comment: ReviewComment, | |
reviewThreads: ReviewThread[] | |
): Promise<void> { | |
const file = join(outputDir, `issue_${issueNumber.toString().padStart(3, "0")}.md`); | |
const formattedDate = formatDate(comment.created_at); | |
const resolvedStatus = isCommentResolvedByPolicy(comment, reviewThreads) | |
? "- [x] RESOLVED ✓" | |
: "- [ ] UNRESOLVED"; | |
const thread = findThreadForReviewComment(comment, reviewThreads); | |
const threadId = thread?.id ?? ""; | |
const content = `# Issue ${issueNumber} - Review Thread Comment | |
**File:** \`${comment.path}:${comment.line}\` | |
**Date:** ${formattedDate} | |
**Status:** ${resolvedStatus} | |
## Body | |
${comment.body} | |
## How To Resolve This Issue | |
This comment belongs to a GitHub review thread. To mark it as resolved programmatically, call GitHub's GraphQL API using your \`GITHUB_TOKEN\` (scope: \`repo\`). | |
- Thread ID: ${threadId ? `\`${threadId}\`` : "(not found)"} | |
- Endpoint: \`POST https://api.github.com/graphql\` | |
GitHub CLI example: | |
\`\`\`bash | |
gh api graphql \\ | |
-f query='mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } }' \\ | |
-F threadId='${threadId || "<THREAD_ID>"}' | |
\`\`\` | |
curl example: | |
\`\`\`bash | |
curl -sS -H "Authorization: bearer $GITHUB_TOKEN" \\ | |
-H "Content-Type: application/json" \\ | |
--data '{ | |
"query": "mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } }", | |
"variables": { "threadId": "${threadId || "<THREAD_ID>"}" } | |
}' \\ | |
https://api.github.com/graphql | |
\`\`\` | |
To unresolve the thread, use: | |
\`\`\`bash | |
gh api graphql \\ | |
-f query='mutation($threadId: ID!) { unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } }' \\ | |
-F threadId='${threadId || "<THREAD_ID>"}' | |
\`\`\` | |
--- | |
*Generated from PR review - CodeRabbit AI* | |
`; | |
await fs.writeFile(file, content, "utf8"); | |
console.log(` Created ${file}`); | |
} | |
// Maps a REST review comment to its GraphQL review thread, if available. | |
function findThreadForReviewComment( | |
comment: ReviewComment, | |
reviewThreads: ReviewThread[] | |
): ReviewThread | undefined { | |
for (const thread of reviewThreads) { | |
const match = thread.comments.nodes.some( | |
tc => | |
(tc.databaseId != null && comment.id != null && tc.databaseId === comment.id) || | |
(!!comment.node_id && tc.id === comment.node_id) | |
); | |
if (match) return thread; | |
} | |
return undefined; | |
} | |
async function createSimpleCommentFile( | |
outputDir: string, | |
commentNumber: number, | |
item: | |
| { kind: "issue_comment"; data: IssueComment } | |
| { kind: "review"; data: SimpleReviewComment }, | |
dirs: { nitpicksDir: string; outsideDir: string; duplicatedDir: string } | |
): Promise<{ file: string; resolved: boolean; summaryPath: string; section: "nitpick" | "outside" | "duplicate" }[]> { | |
const file = join(outputDir, `comment_${commentNumber.toString().padStart(3, "0")}.md`); | |
const d = item.data; | |
const formattedDate = formatDate(d.created_at); | |
const typeLabel = | |
item.kind === "review" | |
? `PR Review (${(d as SimpleReviewComment).state})` | |
: "General PR Comment"; | |
const content = `# Comment ${commentNumber} - ${typeLabel} | |
**Date:** ${formattedDate} | |
**Status:** N/A (not resolvable) | |
## Body | |
${d.body} | |
--- | |
*Generated from PR review - CodeRabbit AI* | |
`; | |
await fs.writeFile(file, content, "utf8"); | |
console.log(` Created ${file}`); | |
// Parse and extract nitpicks <details> blocks into separate files | |
const perFile = extractPerFileDetailsFromMarkdown(d.body); | |
const createdFiles: { file: string; resolved: boolean; summaryPath: string; section: "nitpick" | "outside" | "duplicate" }[] = []; | |
for (let i = 0; i < perFile.length; i++) { | |
const { detailsHtml, summaryPath, section } = perFile[i]; | |
const resolved = isNitpickResolved(detailsHtml); | |
const base = sanitizePath(summaryPath); | |
const prefix = section === "outside" ? "outside" : section === "duplicate" ? "duplicate" : "nitpick"; | |
const targetDir = section === "outside" ? dirs.outsideDir : section === "duplicate" ? dirs.duplicatedDir : dirs.nitpicksDir; | |
const nitFile = join(targetDir, `${prefix}_${commentNumber.toString().padStart(3, "0")}_${(i + 1) | |
.toString() | |
.padStart(2, "0")}_${base}.md`); | |
const status = resolved ? "- [x] RESOLVED ✓" : "- [ ] UNRESOLVED"; | |
const title = section === "outside" ? "Outside-of-diff" : section === "duplicate" ? "Duplicate" : "Nitpick"; | |
const nitContent = `# ${title} from Comment ${commentNumber}\n\n**File:** \`${summaryPath}\`\n**Date:** ${formattedDate}\n**Status:** ${status}\n\n## Details\n\n${detailsHtml}\n`; | |
await fs.writeFile(nitFile, nitContent, "utf8"); | |
createdFiles.push({ file: nitFile, resolved, summaryPath, section }); | |
console.log(` ↳ ${title} ${i + 1}: ${nitFile}`); | |
} | |
return createdFiles; | |
} | |
async function createSummaryFile( | |
summaryFile: string, | |
prNumber: number, | |
reviewComments: ReviewComment[], | |
simpleItems: ( | |
| { kind: "issue_comment"; data: IssueComment } | |
| { kind: "review"; data: SimpleReviewComment } | |
)[], | |
resolvedCount: number, | |
unresolvedCount: number, | |
reviewThreads: ReviewThread[], | |
extracted: { file: string; resolved: boolean; summaryPath: string; section: "nitpick" | "outside" | "duplicate" }[] | |
): Promise<void> { | |
const now = new Date().toISOString(); | |
let content = `# PR Review #${prNumber} - CodeRabbit AI Export | |
This folder contains exported issues (resolvable review threads) and simple comments for PR #${prNumber}. | |
## Summary | |
- **Issues (resolvable review comments):** ${reviewComments.length} | |
- **Comments (simple, not resolvable):** ${simpleItems.length} | |
- **Nitpicks:** ${extracted.filter(e => e.section === "nitpick").length} | |
- **Outside-of-diff:** ${extracted.filter(e => e.section === "outside").length} | |
- **Duplicate comments:** ${extracted.filter(e => e.section === "duplicate").length} | |
- **Resolved issues:** ${resolvedCount} ✓ | |
- **Unresolved issues:** ${unresolvedCount} | |
**Generated on:** ${formatDate(now)} | |
## Issues | |
`; | |
for (let i = 0; i < reviewComments.length; i++) { | |
const checked = isCommentResolvedByPolicy(reviewComments[i], reviewThreads) ? "x" : " "; | |
const issueFile = `issues/issue_${(i + 1).toString().padStart(3, "0")}.md`; | |
const loc = ` ${reviewComments[i].path}:${reviewComments[i].line}`; | |
content += `- [${checked}] [Issue ${i + 1}](${issueFile}) -${loc}\n`; | |
} | |
content += `\n## Comments (not resolvable)\n\n`; | |
for (let i = 0; i < simpleItems.length; i++) { | |
const commentFile = `comments/comment_${(i + 1).toString().padStart(3, "0")}.md`; | |
const label = simpleItems[i].kind === "review" ? "review" : "general"; | |
content += `- [ ] [Comment ${i + 1}](${commentFile}) (${label})\n`; | |
} | |
const bySection = (s: "nitpick" | "outside" | "duplicate") => extracted.filter(e => e.section === s); | |
const sectionSpec: Array<{ key: "nitpick" | "outside" | "duplicate"; title: string; folder: string }> = [ | |
{ key: "nitpick", title: "Nitpicks", folder: "nitpicks" }, | |
{ key: "outside", title: "Outside-of-diff", folder: "outside" }, | |
{ key: "duplicate", title: "Duplicate comments", folder: "duplicated" }, | |
]; | |
for (const s of sectionSpec) { | |
const items = bySection(s.key); | |
if (items.length === 0) continue; | |
const resolvedCnt = items.filter(n => n.resolved).length; | |
const unresolvedCnt = items.length - resolvedCnt; | |
content += `\n## ${s.title}\n\n`; | |
content += `- Resolved: ${resolvedCnt} ✓\n`; | |
content += `- Unresolved: ${unresolvedCnt}\n\n`; | |
for (const n of items) { | |
const rel = `${s.folder}/${n.file.split(`${s.folder}/`).pop()}`; | |
const checked = n.resolved ? "x" : " "; | |
content += `- [${checked}] [${n.summaryPath}](${rel})\n`; | |
} | |
} | |
await fs.writeFile(summaryFile, content, "utf8"); | |
console.log(` Created summary file: ${summaryFile}`); | |
} | |
// ---- Nitpicks extraction ---- | |
function extractPerFileDetailsFromMarkdown( | |
body: string | |
): { detailsHtml: string; summaryPath: string; section: "nitpick" | "outside" | "duplicate" }[] { | |
if (!body) return []; | |
const out: { detailsHtml: string; summaryPath: string; section: "nitpick" | "outside" | "duplicate" }[] = []; | |
const summaryRe = /<summary[^>]*>([\s\S]*?)<\/summary>/gi; | |
let m: RegExpExecArray | null; | |
while ((m = summaryRe.exec(body)) !== null) { | |
const rawSummary = m[1] || ""; | |
const cleanSummary = cleanupHtmlText(rawSummary); | |
const pathMatch = cleanSummary.match(/(.+?)\s*\((\d+)\)\s*$/); | |
if (!pathMatch) continue; // Not a per-file summary | |
const pathLike = (pathMatch[1] || "").trim(); | |
if (!pathLike.includes("/")) continue; | |
// Find nearest preceding <details ...> before this summary | |
const sumIdx = m.index; | |
const detailsOpenIdx = body.lastIndexOf("<details", sumIdx); | |
if (detailsOpenIdx < 0) continue; | |
// Find the first closing </details> after the summary end | |
const afterSummaryIdx = summaryRe.lastIndex; | |
const detailsCloseIdx = body.indexOf("</details>", afterSummaryIdx); | |
if (detailsCloseIdx < 0) continue; | |
const block = body.slice(detailsOpenIdx, detailsCloseIdx + "</details>".length); | |
const section = inferSection(body, detailsOpenIdx); | |
out.push({ detailsHtml: block.trim(), summaryPath: pathLike, section }); | |
} | |
return dedupeByContent(out); | |
} | |
function dedupeByContent<T extends { detailsHtml: string; summaryPath: string }>(items: T[]) { | |
const seen = new Set<string>(); | |
const out: T[] = []; | |
for (const it of items) { | |
const key = it.summaryPath + "\n" + it.detailsHtml; | |
if (seen.has(key)) continue; | |
seen.add(key); | |
out.push(it); | |
} | |
return out; | |
} | |
function inferSection(body: string, beforeIndex: number): "nitpick" | "outside" | "duplicate" { | |
const prefix = body.slice(0, beforeIndex).toLowerCase(); | |
const idxOutside = Math.max(prefix.lastIndexOf("outside diff range comments"), prefix.lastIndexOf("outside of diff")); | |
const idxNitpick = prefix.lastIndexOf("nitpick comments"); | |
const idxDuplicate = prefix.lastIndexOf("duplicate comments"); | |
const maxIdx = Math.max(idxOutside, idxNitpick, idxDuplicate); | |
if (maxIdx === idxOutside) return "outside"; | |
if (maxIdx === idxDuplicate) return "duplicate"; | |
if (maxIdx === idxNitpick) return "nitpick"; | |
return "nitpick"; // default bucket | |
} | |
function isNitpickResolved(detailsHtml: string): boolean { | |
if (!detailsHtml) return false; | |
const lower = detailsHtml.toLowerCase(); | |
// Heuristic: consider resolved if the details block contains this explicit marker. | |
return lower.includes("✅ addressed in commit"); | |
} | |
function sanitizePath(p: string): string { | |
return p.replace(/[^a-zA-Z0-9._-]+/g, "_"); | |
} | |
function cleanupHtmlText(s: string): string { | |
// Remove any nested tags and collapse whitespace | |
const noTags = s.replace(/<[^>]+>/g, ""); | |
return noTags.replace(/\s+/g, " ").trim(); | |
} | |
function getConfiguredTimeZone(): string { | |
const env = process.env.PR_REVIEW_TZ; | |
if (!env || env.toLowerCase() === "local") { | |
const sys = Intl.DateTimeFormat().resolvedOptions().timeZone; | |
return sys || "UTC"; | |
} | |
return env; | |
} | |
function formatDate(dateString: string): string { | |
try { | |
const d = new Date(dateString); | |
if (isNaN(d.getTime())) return dateString; | |
const tz = getConfiguredTimeZone(); | |
const parts = new Intl.DateTimeFormat("en-US", { | |
year: "numeric", | |
month: "2-digit", | |
day: "2-digit", | |
hour: "2-digit", | |
minute: "2-digit", | |
second: "2-digit", | |
hour12: false, | |
timeZone: tz, | |
}) | |
.formatToParts(d) | |
.reduce( | |
(acc: Record<string, string>, p) => { | |
acc[p.type] = p.value; | |
return acc; | |
}, | |
{} as Record<string, string> | |
); | |
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second} ${tz}`; | |
} catch { | |
return dateString; // fallback to original format | |
} | |
} | |
main().catch(console.error); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment