"use strict"; // OpenCodeReview PR review comment poster. // // Extracted from the inline actions/github-script step that used to live in // examples/github_actions/ocr-review.yml and .github/workflows/ocr-review.yml, // so that the reusable composite action (action/action.yml) and the in-repo // workflows share a single source of truth. // // Dependencies are injected by the caller (actions/github-script provides // `github`/`context`/`core`; `fs` is required by the caller). The module has // no external (npm) requires — only the Node.js built-in `crypto` — which // keeps it runnable inside actions/github-script without bundling. const crypto = require("crypto"); const SUMMARY_MARKER = ""; // Reason attached to comments that have no valid line range and therefore can // never be posted as inline comments. Surfaced in the summary via the same // `⚠️ GitHub could not post this as an inline comment: ` line as // posting failures, so every summary-only comment explains why it is here. const NO_LINE_REASON = "No line information provided"; // Default IoU threshold for the incremental multi-line overlap test. Two // multi-line comments are considered the same when their line-range // intersection-over-union exceeds this value. Exposed for tuning via the // incrementalOverlapThreshold option / incremental_overlap_threshold input. const DEFAULT_OVERLAP_THRESHOLD = 0.6; async function runPostReviewComments({ github, context, core, fs, resultPath = "/tmp/ocr-result.json", stderrPath = "/tmp/ocr-stderr.log", stickySummary = true, incremental = false, incrementalOverlapThreshold = DEFAULT_OVERLAP_THRESHOLD, }) { const log = (msg) => { if (core && typeof core.info === "function") core.info(msg); else console.log(msg); }; const out = (name, value) => { if (core && typeof core.setOutput === "function") core.setOutput(name, value); }; const owner = context.repo.owner; const repo = context.repo.repo; const prNumber = context.issue.number; // Per-run idempotency tags. context.runId / context.runAttempt come from // @actions/github's Context (parsed from GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT). // Number.isFinite guards against NaN when the env vars are missing, falling // back to safe defaults. The tags are embedded in review/comment bodies as // HTML comments so the idempotency check can detect whether a batch // createReview actually landed on the server before retrying, which prevents // duplicate review posts on retry. const runId = Number.isFinite(context.runId) ? context.runId : 0; const runAttempt = Number.isFinite(context.runAttempt) ? context.runAttempt : 1; const RUN_TAG = `${runId}-${runAttempt}`; const REVIEW_TAG = ``; const SUMMARY_TAG = ``; const stats = { total: 0, inline: 0, skipped: 0, failed: 0, summaryUrl: "", }; // Read OCR output. let result; try { const raw = fs.readFileSync(resultPath, "utf8"); result = JSON.parse(raw); } catch (e) { log(`Failed to parse OCR output: ${e.message}`); const stderr = safeRead(fs, stderrPath).trim(); if (stderr) { const body = `${SUMMARY_MARKER}\n⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}`; const posted = await postSummary({ github, owner, repo, prNumber, body, sticky: stickySummary, log }); stats.summaryUrl = posted.url; } setStatsOutputs(out, stats); return; } const comments = result.comments || []; const warnings = result.warnings || []; stats.total = comments.length; // No comments: post a "looks good" summary. if (comments.length === 0) { const message = result.message || "No comments generated. Looks good to me."; const body = `${SUMMARY_MARKER}\n✅ **OpenCodeReview**: ${message}`; const posted = await postSummary({ github, owner, repo, prNumber, body, sticky: stickySummary, log }); stats.summaryUrl = posted.url; setStatsOutputs(out, stats); return; } // Resolve the PR head commit sha to attach the review to. let commitSha; if (context.eventName === "pull_request_target") { commitSha = context.payload.pull_request.head.sha; } else { const { data: pullRequest } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber, }); commitSha = pullRequest.head.sha; } // Partition: inline (with valid line info) vs summary (without). // Each inline comment gets a random per-comment ID (assigned once) embedded // in its body as an HTML comment, so the retry/idempotency logic can detect // whether a comment already landed on the server and avoid posting a // duplicate. Random (not content-derived) so two distinct comments that // share path/line/content still get different IDs. const reviewComments = []; const commentsWithoutLine = []; for (const comment of comments) { const hasValidLine = comment.start_line >= 1 || comment.end_line >= 1; if (!hasValidLine) { commentsWithoutLine.push({ comment, body: formatComment(comment), reason: NO_LINE_REASON }); continue; } const id = newCommentId(RUN_TAG); const reviewComment = { path: comment.path, body: formatComment(comment, id) }; if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) { reviewComment.start_line = comment.start_line; reviewComment.line = comment.end_line; reviewComment.start_side = "RIGHT"; reviewComment.side = "RIGHT"; } else if (comment.end_line >= 1) { reviewComment.line = comment.end_line; reviewComment.side = "RIGHT"; } else if (comment.start_line >= 1) { reviewComment.line = comment.start_line; reviewComment.side = "RIGHT"; } reviewComments.push({ comment, reviewComment, id }); } // Incremental filtering (non-destructive): drop current inline comments // whose (path, line range) overlaps an existing bot review comment, so we // only append comments on lines not yet covered. History is never deleted. let toSend = reviewComments; if (incremental && reviewComments.length > 0) { const existing = await listExistingReviewComments(github, owner, repo, prNumber, log); const botLogin = await getAuthenticatedLogin(github, log); const hist = existing.filter((c) => isBotComment(c, botLogin)); toSend = reviewComments.filter( ({ reviewComment }) => !overlapsHistory(reviewComment, hist, incrementalOverlapThreshold) ); stats.skipped = reviewComments.length - toSend.length; if (stats.skipped > 0) { log(`[incremental] skipped ${stats.skipped} overlapping comment(s); ${toSend.length} to post.`); } } // ---- Summary anchor (before the review) ---- // Create the summary issue comment BEFORE posting the review so that on a // cold start (the first review on this PR) the summary's timeline position is // above the review. GitHub orders issue comments oldest-first, so creating // the summary first pins it at the top; later runs merely update it in place // (sticky) or post a fresh per-run comment (non-sticky), so the ordering // stays stable and the summary is never sandwiched between review blocks. // The anchor carries a pre-review body (issue count, warnings, and comments // without line info — all known upfront); final posting statistics are // written in the finalize phase below, once the review has landed. const wrapSummary = (content) => `${SUMMARY_MARKER}\n${SUMMARY_TAG}\n${content}`; const anchor = await ensureSummaryAnchor({ github, owner, repo, prNumber, sticky: stickySummary, tag: SUMMARY_TAG, body: wrapSummary(buildPreReviewSummaryBody(stats.total, commentsWithoutLine, warnings)), log, }); // Submit inline comments (the to-send set) as a single PR review. let successCount = 0; let failedCount = 0; const failedComments = []; if (toSend.length > 0) { // The summary lives in its own issue comment (anchored above), so the // review body carries only the per-run REVIEW_TAG. The tag lets the // idempotency check locate the batch review on retry (a batch createReview // may have landed on the server even though we received a 5xx response). const reviewBody = REVIEW_TAG; try { const batchRes = await github.rest.pulls.createReview({ owner, repo, pull_number: prNumber, commit_id: commitSha, body: reviewBody, event: "COMMENT", comments: toSend.map(({ reviewComment }) => reviewComment), }); successCount = toSend.length; log(`Successfully posted review with ${successCount} inline comment(s) (${commentsWithoutLine.length} in summary).`); logRateLimitQuota(batchRes, "after batch createReview", log); } catch (e) { log(`Failed to post review with inline comments: ${e.message}`); // Retry/pacing configuration (shared by write and read API calls). // parseNonNegInt guards against nonsensical env values (negative, NaN, // non-numeric) that `parseInt(...) || default` would let through for // negative numbers, since a negative parseInt result is truthy and would // bypass the `|| default` fallback. const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); const SUCCESS_DELAY = parseNonNegInt(process.env.OCR_SUCCESS_DELAY, 2000); const FAILURE_DELAY = parseNonNegInt(process.env.OCR_FAILURE_DELAY, 1000); const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3); const LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_LOW_REMAINING_SPACING, 10000); // Read APIs are cheaper and have higher thresholds; use shorter pacing. const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500); const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000); // Rate-limit cooldown: honor the batch error's retry/rate-limit headers // BEFORE any further API call — including the idempotency reads below. // Firing reads immediately after a rate-limit/5xx would further pressure // the already-struggling API; this is the same cool-down-before-read // discipline the per-comment loop applies before isCommentAlreadyPosted. const batchRetry = computeRetryDelayMs(e, 0); if (batchRetry != null) { const secs = (batchRetry.delayMs / 1000).toFixed(1); log( `Batch createReview failed (HTTP ${e.status}). ` + `Cooling down ${secs}s via '${batchRetry.source}' (${batchRetry.detail}) before any retry or read.` ); await sleep(batchRetry.delayMs); } // The idempotency read ("did the batch land?") is only meaningful when the // request MAY have reached the server: 5xx, 408 timeout, or a network // error with no status. For a pure rate-limit (429 / 403 abuse) or a // validation error (422), the request was rejected before the review was // created, so the batch definitely did not land — querying would be both // pointless AND an extra read fired during a rate-limit episode. Skip it // and retry all comments. This mirrors the per-comment maybeReachedServer // predicate so the two layers stay consistent. const batchStatus = e.status; const batchMaybeReachedServer = (typeof batchStatus === "number" && (batchStatus >= 500 || batchStatus === 408)) || batchStatus == null; // network errors (ECONNRESET, ETIMEDOUT, ...) let existingReview = null; if (batchMaybeReachedServer) { log("Checking whether the batch review actually landed on the server before retrying..."); try { existingReview = await findExistingBatchReview({ github, owner, repo, prNumber, tag: REVIEW_TAG, log }); } catch (checkErr) { log(`Idempotency check failed (${checkErr.message}). Degrading to original fallback (accepting duplicate risk).`); } } else { log(`Batch did not reach the server (HTTP ${batchStatus || "n/a"}); skipping idempotency check and retrying all comments.`); } // Compute the list of inline comments that still need to be posted. If // the batch review landed, only retry the missing ones; otherwise retry // all of them. let toRetry = toSend; if (existingReview && existingReview.found) { const postedIds = await getPostedCommentIds({ github, owner, repo, prNumber, log }); toRetry = toSend.filter((item) => !postedIds.has(item.id)); successCount = toSend.length - toRetry.length; log( `Batch review already exists (review_id=${existingReview.review.id}). ` + `${successCount}/${toSend.length} inline comments already posted. ` + `${toRetry.length} missing, will retry only those.` ); } else { log("Batch review not found on server. Falling back to per-comment posting..."); } for (const { comment, reviewComment, id } of toRetry) { let posted = false; for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) { try { const res = await github.rest.pulls.createReview({ owner, repo, pull_number: prNumber, commit_id: commitSha, body: "", event: "COMMENT", comments: [reviewComment], }); successCount++; posted = true; log(`Successfully posted comment for ${reviewComment.path}`); // Proactive throttle: if remaining quota is low, slow down to // avoid hitting the limit (GitHub best practice: watch the header). const remaining = logRateLimitQuota(res, `after ${reviewComment.path}`, log); const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; if (lowQuota) { log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`); await sleep(LOW_REMAINING_SPACING); } else { await sleep(SUCCESS_DELAY); } } catch (innerE) { // Decide whether to retry and how long to wait, based on GitHub's // rate-limit documentation (retry-after / x-ratelimit-* headers). const retryInfo = computeRetryDelayMs(innerE, attempt); const willRetry = retryInfo != null && attempt < MAX_RETRIES; // Any error whose request may have reached GitHub (5xx server // errors, 408 timeout, or network-layer errors with no status) can // mean the comment was actually created but the response was lost. // Before retrying (which would post a duplicate) or before giving // up (which would wrongly list it as failed in the summary), check // whether it already landed. // // IMPORTANT: do the check AFTER cooling down, not immediately. If // the error is rate-limit-related (5xx under load, or a network // blip), firing read requests right away further pressures the // already-struggling API. Honor the computed retry delay first, // then query. const status = innerE.status; const maybeReachedServer = (typeof status === "number" && (status >= 500 || status === 408)) || status == null; // network errors (ECONNRESET, ETIMEDOUT, ...) if (maybeReachedServer) { // Cool down first: even read requests count against rate limits, // and querying during an ongoing 5xx/rate-limit episode can // worsen the situation. Use the retry delay when available; for // non-retryable errors (retryInfo == null) there is no // header-derived wait, so use a short fixed cool down before the // read. const coolDownMs = retryInfo != null ? retryInfo.delayMs : FAILURE_DELAY; if (coolDownMs > 0) { const secs = (coolDownMs / 1000).toFixed(1); log( `Cooling down ${secs}s before idempotency check for ${reviewComment.path} ` + `(HTTP ${innerE.status || "n/a"}, attempt ${attempt + 1}/${MAX_RETRIES + 1}).` ); await sleep(coolDownMs); } const alreadyPosted = await isCommentAlreadyPosted({ github, owner, repo, prNumber, id, log }); if (alreadyPosted === true) { successCount++; posted = true; log(`Comment for ${reviewComment.path} already posted (id=${id}); treating as success.`); await sleep(SUCCESS_DELAY); continue; } // Unknown (null): the read API is unavailable, so we cannot tell // whether the comment landed. To avoid a duplicate, do NOT retry // posting; record as failed so the summary surfaces the // uncertainty rather than silently risking a duplicate. if (alreadyPosted === null) { failedCount++; const reason = "idempotency check unavailable (read API failed)"; failedComments.push({ comment, error: `${innerE.message} [${reason}]` }); log(`Cannot verify whether comment for ${reviewComment.path} was posted (${reason}, HTTP ${innerE.status || "n/a"}); skipping retry to avoid duplicate.`); await sleep(SUCCESS_DELAY); break; } // Not found on server. If retries are exhausted or the error is // non-retryable, this is a real failure. if (!willRetry) { failedCount++; failedComments.push({ comment, error: innerE.message }); const reason = retryInfo == null ? "non-retryable error" : "rate-limit retries exhausted"; log(`Failed to post comment for ${reviewComment.path} (${reason}, HTTP ${innerE.status || "n/a"}): ${innerE.message}`); await sleep(SUCCESS_DELAY); break; } // willRetry: cool down already consumed above, loop back. } else if (willRetry) { // Pure 429/403 rate-limit: the request never reached the server, // so no duplicate is possible and the idempotency check can be // skipped. Just honor the retry delay. const secs = (retryInfo.delayMs / 1000).toFixed(1); log( `Rate-limited on ${reviewComment.path} ` + `(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` + `Error: ${innerE.message}` ); await sleep(retryInfo.delayMs); } else { // Non-retryable error that definitely did not reach the server // (e.g. 4xx validation error): record as failed. failedCount++; failedComments.push({ comment, error: innerE.message }); log(`Failed to post comment for ${reviewComment.path} (non-retryable error, HTTP ${innerE.status || "n/a"}): ${innerE.message}`); await sleep(FAILURE_DELAY); break; } } } } } } else { log("No inline comments to post after filtering (all overlapping or none had line info)."); } stats.inline = successCount; stats.failed = failedCount; // ---- Finalize the summary with the complete body ---- // Now that the review has landed (or failed per-comment), write the final // summary body. Posting statistics are merged into the leading summary // header (see buildSummaryBody), so here we only append the per-comment // renderings: every comment that did not go out as inline — whether because // it had no line info or because posting failed — is rendered as one // continuous block, each carrying the reason it ended up in the summary (so // the reader always knows why it is here). let summaryBody = buildSummaryBody({ total: stats.total, inline: successCount, summary: commentsWithoutLine.length, skipped: stats.skipped, failed: failedCount, warnings, }); summaryBody += formatSummaryComments(commentsWithoutLine); for (const { comment, error } of failedComments) { summaryBody += "\n\n---\n\n"; summaryBody += formatCommentMarkdown(comment, error); } if (toSend.length === 0 && stats.skipped > 0) { summaryBody += "\n\n---\n\nℹ️ All inline comments overlapped with existing reviews; nothing new was posted."; } summaryBody += formatWarnings(warnings); // Update the anchored comment directly when its id is known (no extra read); // otherwise upsert (find-then-update-or-create), which also covers the case // where the anchor phase was skipped because the read API was unavailable. // Returns null only when the summary could not be written without risking a // duplicate; the review content remains available via inline comments. const finalized = await finalizeSummary({ github, owner, repo, prNumber, anchor, sticky: stickySummary, tag: SUMMARY_TAG, body: wrapSummary(summaryBody), log, }); if (finalized) stats.summaryUrl = finalized.url; setStatsOutputs(out, stats); } function setStatsOutputs(out, stats) { out("comments_total", String(stats.total)); out("comments_inline", String(stats.inline)); out("comments_skipped", String(stats.skipped)); out("comments_failed", String(stats.failed)); out("summary_comment_url", stats.summaryUrl || ""); } // ---- Summary posting (sticky vs new) ---- async function postSummary({ github, owner, repo, prNumber, body, sticky, log }) { const fullBody = body; if (sticky) { const existing = await findExistingSummaryComment({ github, owner, repo, prNumber, log }); if (existing) { const { data: updated } = await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: fullBody, }); return { id: updated.id, url: updated.html_url, updated: true }; } } const { data: created } = await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: fullBody, }); return { id: created.id, url: created.html_url, updated: false }; } async function findExistingSummaryComment({ github, owner, repo, prNumber, log }) { const comments = await readAllPages("listIssueComments", (page, per_page) => github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page, page }), log ); // Issue comments are returned oldest-first; pick the newest matching. for (let i = comments.length - 1; i >= 0; i--) { const body = comments[i].body; if (typeof body === "string" && body.includes(SUMMARY_MARKER)) { return comments[i]; } } return null; } // ---- Summary anchor + finalize (cold-start ordering) ---- // // The summary issue comment is created BEFORE the review so that on a cold // start (first review on the PR) it lands above the review in the timeline // (GitHub orders issue comments oldest-first). It is then updated in place // with the final body once the review has posted. This keeps the summary from // being sandwiched between review blocks on subsequent sticky runs. // Find the issue comment that should carry the summary, or null if none. // Sticky matches the persistent cross-run marker (SUMMARY_MARKER); non-sticky // matches this run's tag (SUMMARY_TAG) so each run gets its own comment while // retries within a run reuse it. Throws on read failure so callers can degrade. async function findSummaryIssueComment({ github, owner, repo, prNumber, sticky, tag, log }) { const comments = await readAllPages("listIssueComments", (page, per_page) => github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page, page }), log ); for (let i = comments.length - 1; i >= 0; i--) { const body = comments[i].body || ""; if (sticky ? body.includes(SUMMARY_MARKER) : body.includes(tag)) { return comments[i]; } } return null; } // Phase 1 (before review): create a summary comment only if none exists yet, so // its timeline position is pinned above the not-yet-posted review. Returns // { id, url } for the existing/created comment, or null when the existence // check fails (read API unavailable) — callers then defer to finalizeSummary. async function ensureSummaryAnchor({ github, owner, repo, prNumber, body, sticky, tag, log }) { let existing = null; try { existing = await findSummaryIssueComment({ github, owner, repo, prNumber, sticky, tag, log }); } catch (e) { log(`[summary] cannot check for existing summary before review (${e.message}); skipping anchor.`); return null; } if (existing) { return { id: existing.id, url: existing.html_url }; } const { data: created } = await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body, }); return { id: created.id, url: created.html_url }; } // Phase 2 (after review): write the final summary body. When the anchor's id is // known, update it directly (no extra read). Otherwise upsert: find then update // or create. Returns { id, url }, or null when the read API is unavailable and // the summary cannot be safely written without risking a duplicate. async function finalizeSummary({ github, owner, repo, prNumber, anchor, body, sticky, tag, log }) { if (anchor && anchor.id != null) { const { data: updated } = await github.rest.issues.updateComment({ owner, repo, comment_id: anchor.id, body, }); return { id: updated.id, url: updated.html_url }; } let existing = null; try { existing = await findSummaryIssueComment({ github, owner, repo, prNumber, sticky, tag, log }); } catch (e) { log(`[summary] cannot check for existing summary at finalize (${e.message}); skipping to avoid duplicate.`); return null; } if (existing) { const { data: updated } = await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body, }); return { id: updated.id, url: updated.html_url }; } const { data: created } = await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body, }); return { id: created.id, url: created.html_url }; } // ---- Incremental helpers ---- async function getAuthenticatedLogin(github, log) { try { const { data: user } = await github.rest.users.getAuthenticated(); return user && user.login ? user.login : null; } catch (e) { log(`[incremental] could not resolve authenticated user: ${e.message}`); return null; } } async function listExistingReviewComments(github, owner, repo, prNumber, log) { const all = []; let page = 1; // Cap pagination so a pathological PR cannot stall the job; 10 pages = 1000. const MAX_PAGES = 10; // Sort newest-first so the page cap keeps the most recent comments: the // incremental dedup cares about the latest coverage state, and on truncation // we'd rather drop ancient comments than the recent ones the bot just posted. // GitHub's default is ascending (oldest-first), which would keep the oldest // 1000 and silently drop the newest — the exact comments dedup needs most. try { while (page <= MAX_PAGES) { const res = await github.rest.pulls.listReviewComments({ owner, repo, pull_number: prNumber, sort: "created", direction: "desc", per_page: 100, page, }); const items = res.data || []; all.push(...items); if (items.length < 100) break; page++; } } catch (e) { log(`[incremental] listing review comments failed (${e.message}); degrading to no history.`); return []; } if (page > MAX_PAGES) { log(`[incremental] listing review comments reached max page limit (${MAX_PAGES}); results may be incomplete.`); } return all; } function isBotComment(comment, botLogin) { if (!comment || !comment.user) return false; if (botLogin && comment.user.login === botLogin) return true; // GITHUB_TOKEN posts as "github-actions[bot]"; GitHub Apps post as the app. const login = comment.user.login || ""; return /github-actions\[bot\]$/i.test(login) || (botLogin != null && login === botLogin); } // Incremental overlap test. The current comment is considered a duplicate of // an existing bot comment (and thus skipped) when they target the same path // and RIGHT side AND one of these holds: // 1. both are single-line comments on the same line; // 2. both are multi-line comments whose line-range IoU (intersection over // union) exceeds `threshold`. // A single-line comment is NEVER considered the same as a multi-line one, so // revisiting a line with a finer-grained single-line note is not suppressed by // a prior multi-line block (and vice versa). function overlapsHistory(reviewComment, history, threshold = DEFAULT_OVERLAP_THRESHOLD) { const t = resolveThreshold(threshold); const path = reviewComment.path; const cur = lineSpan(reviewComment); if (!cur) return false; for (const h of history) { if (h.path !== path) continue; if (h.side && h.side !== "RIGHT") continue; const other = lineSpan(h); if (!other) continue; if (sameCommentSpan(cur, other, t)) return true; } return false; } // Clamp/validate the caller-provided threshold to a sane (0, 1] number, // falling back to the default when it is missing, NaN, or out of range. This // keeps the public overlapsHistory API robust even when the value arrives from // an env var / action input as a malformed string. function resolveThreshold(threshold) { const n = Number(threshold); return Number.isFinite(n) && n > 0 && n <= 1 ? n : DEFAULT_OVERLAP_THRESHOLD; } // Resolve a comment into a line span tagged as single- or multi-line. // Returns { start, end, multiline } or null when no line can be resolved. // Handles both our own reviewComment shape ({start_line, line}) and GitHub's // historical comment shape ({start_line, line}; start_line null for // single-line). A comment is multi-line only when start_line and line are both // present and differ; start_line === line (or a missing start_line) is treated // as a single-line comment on that line. function lineSpan(c) { const start = num(c.start_line); const end = num(c.line != null ? c.line : c.end_line); if (start == null && end == null) return null; if (start != null && end != null && start !== end) { return { start: Math.min(start, end), end: Math.max(start, end), multiline: true }; } const single = end != null ? end : start; return { start: single, end: single, multiline: false }; } // Same-comment predicate implementing the incremental rules. The IoU // comparison is strict (>), so a span that exactly meets the threshold is NOT // treated as a duplicate. function sameCommentSpan(cur, other, threshold) { if (cur.multiline !== other.multiline) return false; if (!cur.multiline) return cur.start === other.start; const overlap = Math.min(cur.end, other.end) - Math.max(cur.start, other.start) + 1; if (overlap <= 0) return false; const union = cur.end - cur.start + 1 + (other.end - other.start + 1) - overlap; if (union <= 0) return false; return overlap / union > threshold; } function num(v) { if (v == null || v === "") return null; const n = Number(v); return Number.isFinite(n) && n >= 1 ? n : null; } // ---- Rate-limit / retry helpers (ported verbatim) ---- function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // Parse a non-negative integer env value, falling back to defaultVal when the // value is missing, NaN, or negative. Unlike `parseInt(...) || default`, this // guards against negative numbers: a negative parseInt result is truthy, so // `parseInt || default` would let a nonsensical negative value bypass the // fallback. function parseNonNegInt(val, defaultVal) { const n = parseInt(val, 10); return Number.isFinite(n) && n >= 0 ? n : defaultVal; } // Case-insensitive header lookup. Octokit normalizes response headers to // lowercase, but this defensive check also handles original casing so that // quota logging and retry delay computation never silently miss a header. function getHeader(headers, name) { const v = headers[name] != null ? headers[name] : headers[name.toLowerCase()]; return v != null ? String(v).trim() : undefined; } // Decide whether an error is worth retrying and, if so, how long to wait. // Implements GitHub's documented rate-limit retry strategy using the // response headers (retry-after, x-ratelimit-remaining, x-ratelimit-reset). // Returns { delayMs, source, detail } when retryable, or null otherwise. // See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api function computeRetryDelayMs(error, attempt) { if (!error) return null; const status = error.status; const message = String(error.message || ""); const isRateLimit = status === 429 || (status === 403 && /rate limit|abuse|secondary/i.test(message)); const isTransient = (status >= 500 && status < 600) || status === 408; if (!isRateLimit && !isTransient) return null; const headers = ((error.response || {}).headers) || {}; const header = (name) => getHeader(headers, name); const nowSec = Math.floor(Date.now() / 1000); const cap = parseNonNegInt(process.env.OCR_RETRY_MAX_DELAY, 300000); const base = parseNonNegInt(process.env.OCR_RETRY_BASE_DELAY, 60000); let info = null; if (isRateLimit) { // (1) Honor "retry-after" when present (seconds, or an HTTP-date). const retryAfter = header("retry-after"); if (retryAfter) { const secs = Number(retryAfter); if (!isNaN(secs) && secs >= 0) { info = { rawMs: secs * 1000, source: "retry-after", detail: `${secs}s (from header)` }; } else { const dateMs = Date.parse(retryAfter); if (!isNaN(dateMs)) { info = { rawMs: Math.max(0, dateMs - Date.now()), source: "retry-after (HTTP-date)", detail: retryAfter }; } } } // (2) Primary limit exhausted (x-ratelimit-remaining=0): wait until reset. if (!info) { const remaining = header("x-ratelimit-remaining"); const reset = header("x-ratelimit-reset"); if (reset != null && Number(remaining) === 0) { const rawMs = Math.max(0, Number(reset) - nowSec) * 1000; info = { rawMs, source: "x-ratelimit-reset", detail: `remaining=0, reset epoch=${reset} (in ${Math.ceil(rawMs / 1000)}s)` }; } } // (3) Secondary limit with no retry hint: docs say wait at least one // minute, then increase exponentially between retries. if (!info) { const backoff = Math.min(base * Math.pow(2, attempt), cap); const jitter = Math.floor(Math.random() * 1000); info = { rawMs: backoff + jitter, source: "exponential-backoff", detail: `base=${base}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter` }; } } else { // Transient server error (5xx / 408): back off without the 60s floor. const transientBase = 2000; const backoff = Math.min(transientBase * Math.pow(2, attempt), cap); const jitter = Math.floor(Math.random() * 1000); info = { rawMs: backoff + jitter, source: "transient-backoff", detail: `base=${transientBase}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter (HTTP ${status})` }; } const delayMs = Math.min(info.rawMs, cap); if (delayMs < info.rawMs) { info.detail += ` [CAPPED to ${cap}ms; GitHub recommended ${Math.ceil(info.rawMs / 1000)}s]`; } return { delayMs, source: info.source, detail: info.detail }; } // Best-effort logging of remaining rate-limit quota from a successful response. // Returns the parsed x-ratelimit-remaining value (or null) for proactive throttling. function logRateLimitQuota(response, tag, log) { try { const h = (response && response.headers) || {}; const header = (name) => getHeader(h, name); const remaining = header("x-ratelimit-remaining"); const limit = header("x-ratelimit-limit"); const reset = header("x-ratelimit-reset"); if (remaining != null) { log( `[rate-limit] ${tag}: remaining=${remaining}/${limit != null ? limit : "?"}` + (reset != null ? `, reset epoch=${reset}` : "") ); } return remaining != null ? Number(remaining) : null; } catch (_) { return null; } } // ---- Read API + idempotency helpers ---- // // The helpers below back the "prevent duplicate review posts on retry" // strategy: when a batch createReview fails with a 5xx, the request may still // have landed on the server. Before retrying, we query existing reviews and // review comments (each tagged with a per-run HTML comment) and only retry the // comments that are actually missing. Read calls are paced (shorter delays // than writes) and degrade to "unknown" (null) when the read API itself fails, // so the caller skips retrying rather than risking a duplicate. // Retry wrapper shared by write and read API calls. Reuses computeRetryDelayMs // so rate-limit headers (retry-after / x-ratelimit-*) are honored uniformly. // Throws on final failure so the caller can decide how to degrade. async function withRetry(tag, fn, log) { const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); let lastErr; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { return await fn(); } catch (e) { lastErr = e; const retryInfo = computeRetryDelayMs(e, attempt); const willRetry = retryInfo != null && attempt < MAX_RETRIES; if (willRetry) { const secs = (retryInfo.delayMs / 1000).toFixed(1); log( `[${tag}] transient/rate-limited (HTTP ${e.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ${e.message}` ); await sleep(retryInfo.delayMs); } else { log(`[${tag}] failed after ${attempt + 1} attempts: ${e.message}`); throw e; } } } throw lastErr != null ? lastErr : new Error(`withRetry(${tag}): exhausted retries with no error captured`); } // Read API wrapper with retry + proactive pacing. Read requests are cheaper // than writes but still consume the primary rate limit and can trigger the // secondary limit when issued in a tight loop. Use shorter delays than writes // (READ_SUCCESS_DELAY / READ_LOW_REMAINING_SPACING). async function readWithPacing(tag, fn, log) { const res = await withRetry(tag, fn, log); const remaining = logRateLimitQuota(res, tag, log); const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3); const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; if (lowQuota) { const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000); log(`[rate-limit] quota low after read (${remaining} <= ${LOW_REMAINING_THRESHOLD}); spacing ${READ_LOW_REMAINING_SPACING}ms.`); await sleep(READ_LOW_REMAINING_SPACING); } else { const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500); await sleep(READ_SUCCESS_DELAY); } return res; } // Paginated helper that walks all pages of a list endpoint with retry and // pacing. Returns the concatenated array of items. async function readAllPages(tag, pageFn, log, maxPages = 50) { if (!Number.isFinite(maxPages) || maxPages < 1) { throw new Error(`readAllPages: maxPages must be a positive integer, got ${maxPages}`); } const all = []; let page = 1; const PER_PAGE = 100; while (page <= maxPages) { const res = await readWithPacing(`${tag} (page ${page})`, () => pageFn(page, PER_PAGE), log); const items = res.data || []; all.push(...items); if (items.length < PER_PAGE) break; page++; } // NOTE: Truncation here is intentional and acts as a safety valve against // unbounded loops (e.g. a bug or malicious activity), not as a normal // operating mode. A PR accumulating >5000 review comments is far outside // expected usage; in that rare case we log a warning and proceed with // partial data rather than failing the whole review. // // Caveat: this is NOT the same as a read failure. When the read API throws // (rate limit, 5xx), isCommentAlreadyPosted catches it and returns null // (unknown), so the caller skips retrying and creates no duplicate. A // truncated walk does not throw; it returns a partial set silently, so // isCommentAlreadyPosted returns false (definitively "not posted") for any // comment beyond the cap, and the retry loop will repost it, producing a // duplicate. This tradeoff is accepted because the trigger is far outside // expected usage; if that ceiling ever needs to rise, make maxPages // configurable. if (page > maxPages) { log(`[${tag}] reached max page limit (${maxPages}); results may be incomplete.`); } return all; } // Idempotency check: find whether a batch review with this run tag already // exists on the PR. Returns { found, review } or throws on final failure // (caller degrades to the original fallback). async function findExistingBatchReview({ github, owner, repo, prNumber, tag, log }) { const reviews = await readAllPages("listReviews", (page, per_page) => github.rest.pulls.listReviews({ owner, repo, pull_number: prNumber, per_page, page }), log ); for (const r of reviews) { if ((r.body || "").includes(tag)) { return { found: true, review: r }; } } return { found: false }; } // Collect the set of comment-level IDs already posted on the PR (across all // reviews). Uses listReviewComments (PR-level, cross-review) so a single // paginated walk covers everything, avoiding the O(missing) amplification of // per-comment lookups. async function getPostedCommentIds({ github, owner, repo, prNumber, log }) { const comments = await readAllPages("listReviewComments", (page, per_page) => github.rest.pulls.listReviewComments({ owner, repo, pull_number: prNumber, per_page, page }), log ); const ids = new Set(); // Anchor the regex to the HTML comment wrapper () so // user-generated content or code suggestions cannot trigger false positives // in the idempotency check. The ID format is `ocr--` where // RUN_TAG is `-` and is a per-comment random // hex token. Capture group 1 holds the bare ID, so we can add it directly // without stripping comment markers. const ID_RE = //g; for (const c of comments) { const body = c.body || ""; let m; while ((m = ID_RE.exec(body)) !== null) { ids.add(m[1]); } } return ids; } // Check whether a specific comment-level ID has already landed on the server. // Used by the per-comment retry loop: when a createReview call fails with a // transient 5xx/408, the request may have reached GitHub and succeeded even // though the response was lost. Querying before retrying prevents posting a // duplicate inline comment. // Returns true/false when the check succeeds, or null when the read API is // unavailable (rate limit, 5xx, etc.). Returning null (rather than defaulting // to false) prevents the caller from assuming the comment was not posted and // risking a duplicate on retry. // // Each call walks listReviewComments fresh — no cached snapshot. A snapshot // reused across retries would go stale as comments land during the loop, and a // stale miss for a 5xx-landed comment would trigger a retry that posts a // duplicate. Read calls are paced via readAllPages/readWithPacing and degrade // to null (skip retry) if the read API itself fails, so the extra walks cannot // produce duplicates. async function isCommentAlreadyPosted({ github, owner, repo, prNumber, id, log }) { try { const posted = await getPostedCommentIds({ github, owner, repo, prNumber, log }); return posted.has(id); } catch (e) { log(`[isCommentAlreadyPosted] check failed for ${id} (${e.message}); treating as unknown to avoid duplicates.`); return null; } } // Random per-comment ID, assigned once when the inline-comment item is built // and carried on the item struct. Random (rather than content-derived) so two // distinct comments that share the same path/line/content still get different // IDs and the idempotency check never mistakes one for the other (which would // silently drop the second). Embedded in the comment body as an HTML comment // so getPostedCommentIds can match it back on retry. function newCommentId(runTag) { return `ocr-${runTag}-${crypto.randomBytes(8).toString("hex")}`; } // ---- Formatting helpers (ported verbatim) ---- // Assemble the visible comment body. When `id` is provided (inline comments), // the per-comment ID tag is prepended as an HTML comment (invisible when // rendered) so getPostedCommentIds can match it back on retry for the // idempotency check. The code suggestion block is then appended if present. function formatComment(comment, id) { let body = id ? `\n` : ""; body += comment.content || ""; if (comment.suggestion_code && comment.existing_code) { body += "\n\n**Suggestion:**\n"; body += fencedBlock(comment.suggestion_code, "suggestion"); } return body; } function formatCommentMarkdown(comment, error) { let md = `### 📄 \`${comment.path}\``; if (comment.start_line && comment.end_line) { md += ` (L${comment.start_line}-L${comment.end_line})`; } md += "\n\n"; if (error) { md += `⚠️ GitHub could not post this as an inline comment: ${error}\n\n`; } md += comment.content || ""; if (comment.suggestion_code && comment.existing_code) { md += "\n\n
💡 Suggested Change\n\n"; md += "**Before:**\n" + fencedBlock(comment.existing_code) + "\n\n"; md += "**After:**\n" + fencedBlock(comment.suggestion_code) + "\n\n"; md += "
"; } return md; } // Merged summary header. All posting-outcome counts are surfaced here (and // ONLY here) so the numbers add up to the total and the reader no longer has // to reconcile two separately presented breakdowns (the old "posted as // inline / posted as summary" header vs. the trailing "Posting Statistics" // block, whose overlapping definitions made the summary hard to interpret). // // The four counts are mutually exclusive and, together with `inline`, sum to // `total`: // inline — comments that landed as review inline comments // summary — comments without line info, rendered in the summary body below // skipped — comments suppressed by incremental overlap filtering // failed — comments that had line info but could not be posted (also // rendered in the body below, each tagged with its failure reason) function buildSummaryBody({ total, inline, summary, skipped, failed, warnings }) { let body = `🔍 **OpenCodeReview** found **${total}** issue(s) in this PR.`; if (total > 0) { body += `\n- ✅ Successfully posted inline: ${inline} comment(s)`; if (summary > 0) { body += `\n- 📝 In summary (no line info): ${summary} comment(s)`; } if (skipped > 0) { body += `\n- ⏭️ Skipped (overlap with history): ${skipped} comment(s)`; } if (failed > 0) { body += `\n- ❌ Failed to post inline: ${failed} comment(s)`; } } if (warnings && warnings.length > 0) { body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`; } return body; } // Pre-review summary body: shown in the anchor comment while inline comments // are being posted. Includes only what is known before the review lands (issue // count, warnings, comments without line info) — final posting statistics are // added by the finalize phase. Kept informative (not an empty placeholder) so // the summary is useful even if the run is interrupted before finalize. function buildPreReviewSummaryBody(totalCount, summaryComments, warnings) { let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`; if (totalCount > 0) { body += `\n- ⏳ _Posting review comments…_`; } if (warnings.length > 0) { body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`; } body += formatSummaryComments(summaryComments); body += formatWarnings(warnings); return body; } function formatSummaryComments(summaryComments) { let body = ""; for (const { comment, reason } of summaryComments) { body += "\n\n---\n\n"; body += formatCommentMarkdown(comment, reason); } return body; } // Render the warning contents as a bulleted list under a "⚠️ Warnings" heading. // Returns "" when there are no warnings, so callers can append unconditionally. // OCR warning objects carry `file`, `message`, and `type`; each present field is // surfaced so the summary shows where/why the warning happened. Plain-string // warnings (and any unknown shape) degrade gracefully to their textual form. function formatWarnings(warnings) { if (!warnings || warnings.length === 0) return ""; let body = "\n\n---\n\n⚠️ **Warnings:**"; for (const w of warnings) { body += `\n- ${formatWarningEntry(w)}`; } return body; } // Format a single warning as a compact bullet. Builds a `file (type): message` // prefix from whichever of file/type are present, then appends the message. // Missing fields are skipped so a partial warning still reads naturally. function formatWarningEntry(w) { if (w == null) return ""; if (typeof w === "string") return w; if (typeof w === "object") { const prefixParts = []; if (w.file != null && String(w.file) !== "") prefixParts.push(`\`${w.file}\``); if (w.type != null && String(w.type) !== "") prefixParts.push(`(\`${w.type}\`)`); const prefix = prefixParts.join(" "); const msg = w.message != null ? String(w.message) : ""; if (prefix && msg) return `${prefix}: ${msg}`; if (msg) return msg; if (prefix) return prefix; try { return JSON.stringify(w); } catch (_) { return String(w); } } return String(w); } function fencedBlock(content, language = "") { const text = String(content || ""); const fence = safeFence(text); let block = fence + language + "\n" + text; if (!text.endsWith("\n")) block += "\n"; return block + fence; } function safeFence(content) { const matches = String(content || "").match(/`+/g) || []; const maxTicks = matches.reduce((max, ticks) => Math.max(max, ticks.length), 0); return "`".repeat(Math.max(3, maxTicks + 1)); } function safeRead(fs, p) { try { return fs.readFileSync(p, "utf8"); } catch (_) { return ""; } } module.exports = { runPostReviewComments, postSummary, findExistingSummaryComment, findSummaryIssueComment, ensureSummaryAnchor, finalizeSummary, listExistingReviewComments, getAuthenticatedLogin, isBotComment, overlapsHistory, lineSpan, sameCommentSpan, resolveThreshold, DEFAULT_OVERLAP_THRESHOLD, computeRetryDelayMs, getHeader, logRateLimitQuota, parseNonNegInt, withRetry, readWithPacing, readAllPages, findExistingBatchReview, getPostedCommentIds, isCommentAlreadyPosted, newCommentId, formatComment, formatCommentMarkdown, buildSummaryBody, buildPreReviewSummaryBody, formatSummaryComments, formatWarnings, fencedBlock, safeFence, SUMMARY_MARKER, NO_LINE_REASON, };