Last active
July 3, 2026 17:17
-
-
Save mahmoudimus/79e9aec183602e18045d64c5678465a4 to your computer and use it in GitHub Desktop.
Post alibaba's [open-code-review](https://github.com/alibaba/open-code-review) to a github PR
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 node | |
| const fs = require("fs"); | |
| const crypto = require("crypto"); | |
| const { spawnSync } = require("child_process"); | |
| const opts = parseArgs(process.argv.slice(2)); | |
| if (opts.help) { | |
| usage(0); | |
| } | |
| const resultPath = opts.result || process.env.OCR_RESULT_PATH || "/tmp/ocr-result.json"; | |
| const stderrPath = opts.stderr || process.env.OCR_STDERR_PATH || "/tmp/ocr-stderr.log"; | |
| const repo = opts.repo || process.env.GITHUB_REPOSITORY || inferRepo(); | |
| const prNumber = Number(opts.pr || process.env.PR_NUMBER || process.env.GITHUB_REF_NAME); | |
| if (!repo || !/^[^/]+\/[^/]+$/.test(repo)) { | |
| fail("Missing --repo owner/name and unable to infer it from git remote."); | |
| } | |
| if (!Number.isFinite(prNumber) || prNumber <= 0) { | |
| fail("Missing --pr <number>."); | |
| } | |
| const [owner, repoName] = repo.split("/"); | |
| const runId = cleanRunPart(opts.runId || process.env.GITHUB_RUN_ID || "local"); | |
| const runAttempt = cleanRunPart(opts.runAttempt || process.env.GITHUB_RUN_ATTEMPT || "1"); | |
| const RUN_TAG = `${runId}-${runAttempt}`; | |
| const REVIEW_TAG = `<!-- ocr-review-run:${RUN_TAG} -->`; | |
| const SUMMARY_TAG = `<!-- ocr-summary-run:${RUN_TAG} -->`; | |
| 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); | |
| 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); | |
| main().catch((e) => { | |
| console.error(e && e.stack ? e.stack : String(e)); | |
| process.exit(1); | |
| }); | |
| async function main() { | |
| let result; | |
| try { | |
| result = JSON.parse(fs.readFileSync(resultPath, "utf8")); | |
| } catch (e) { | |
| console.log("Failed to parse OCR output:", e.message); | |
| const stderr = readIfExists(stderrPath).trim(); | |
| if (stderr) { | |
| await createIssueComment(`OpenCodeReview encountered an error:\n${fencedBlock(stderr)}`); | |
| } | |
| return; | |
| } | |
| const comments = result.comments || []; | |
| const warnings = result.warnings || []; | |
| if (comments.length === 0) { | |
| const message = result.message || "No comments generated. Looks good to me."; | |
| await createIssueComment(`OpenCodeReview: ${message}`); | |
| return; | |
| } | |
| const pullRequest = getPullRequest(); | |
| const commitSha = opts.commit || process.env.HEAD_SHA || pullRequest.head.sha; | |
| const reviewComments = []; | |
| const commentsWithoutLine = []; | |
| for (const comment of comments) { | |
| const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1); | |
| if (!hasValidLine) { | |
| commentsWithoutLine.push({ comment }); | |
| continue; | |
| } | |
| reviewComments.push({ | |
| comment, | |
| id: newCommentId(), | |
| lines: resolveLines(comment), | |
| }); | |
| } | |
| const totalCount = comments.length; | |
| let summaryBody = buildSummaryBody(totalCount, reviewComments.length, commentsWithoutLine.length, warnings); | |
| summaryBody += formatSummaryComments(commentsWithoutLine); | |
| summaryBody = REVIEW_TAG + "\n" + summaryBody; | |
| let successCount = 0; | |
| let failedCount = 0; | |
| const failedComments = []; | |
| try { | |
| const batchRes = await createReview({ | |
| commit_id: commitSha, | |
| body: summaryBody, | |
| event: "COMMENT", | |
| comments: reviewComments.map(toReviewPayload), | |
| }); | |
| successCount = reviewComments.length; | |
| console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`); | |
| logRateLimitQuota(batchRes, "after batch createReview"); | |
| } catch (e) { | |
| console.log("Failed to post review with inline comments:", e.message); | |
| console.log("Checking whether the batch review actually landed on the server before retrying..."); | |
| let existingReview = null; | |
| try { | |
| existingReview = await findExistingBatchReview({ tag: REVIEW_TAG }); | |
| } catch (checkErr) { | |
| console.log(`Idempotency check failed (${checkErr.message}). Degrading to fallback.`); | |
| } | |
| let toRetry = reviewComments; | |
| if (existingReview && existingReview.found) { | |
| const postedIds = await getPostedCommentIds(); | |
| toRetry = reviewComments.filter((item) => !postedIds.has(item.id)); | |
| successCount = reviewComments.length - toRetry.length; | |
| console.log(`Batch review already exists (review_id=${existingReview.review.id}). ${successCount}/${reviewComments.length} inline comments already posted. ${toRetry.length} missing.`); | |
| } else { | |
| console.log("Batch review not found on server. Falling back to per-comment posting..."); | |
| } | |
| const batchRetry = computeRetryDelayMs(e, 0); | |
| if (batchRetry != null) { | |
| console.log(`Batch createReview was rate-limited/transient. Cooling down ${(batchRetry.delayMs / 1000).toFixed(1)}s via ${batchRetry.source} (${batchRetry.detail}).`); | |
| await sleep(batchRetry.delayMs); | |
| } | |
| for (const item of toRetry) { | |
| const { comment, id } = item; | |
| let posted = false; | |
| for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) { | |
| try { | |
| const res = await createReview({ | |
| commit_id: commitSha, | |
| body: "", | |
| event: "COMMENT", | |
| comments: [toReviewPayload(item)], | |
| }); | |
| successCount++; | |
| posted = true; | |
| console.log(`Successfully posted comment for ${comment.path}`); | |
| const remaining = logRateLimitQuota(res, `after ${comment.path}`); | |
| if (remaining != null && remaining <= LOW_REMAINING_THRESHOLD) { | |
| await sleep(LOW_REMAINING_SPACING); | |
| } else { | |
| await sleep(SUCCESS_DELAY); | |
| } | |
| } catch (innerE) { | |
| const retryInfo = computeRetryDelayMs(innerE, attempt); | |
| const willRetry = retryInfo != null && attempt < MAX_RETRIES; | |
| const status = innerE.status; | |
| const maybeReachedServer = | |
| (typeof status === "number" && (status >= 500 || status === 408)) || status == null; | |
| if (maybeReachedServer) { | |
| const coolDownMs = retryInfo != null ? retryInfo.delayMs : FAILURE_DELAY; | |
| if (coolDownMs > 0) { | |
| console.log(`Cooling down ${(coolDownMs / 1000).toFixed(1)}s before idempotency check for ${comment.path}.`); | |
| await sleep(coolDownMs); | |
| } | |
| const alreadyPosted = await isCommentAlreadyPosted({ id }); | |
| if (alreadyPosted === true) { | |
| successCount++; | |
| posted = true; | |
| console.log(`Comment for ${comment.path} already posted (id=${id}); treating as success.`); | |
| await sleep(SUCCESS_DELAY); | |
| continue; | |
| } | |
| if (alreadyPosted === null) { | |
| failedCount++; | |
| failedComments.push({ comment, error: `${innerE.message} [idempotency check unavailable]` }); | |
| console.log(`Cannot verify whether comment for ${comment.path} was posted; skipping retry to avoid duplicate.`); | |
| await sleep(SUCCESS_DELAY); | |
| break; | |
| } | |
| if (!willRetry) { | |
| failedCount++; | |
| failedComments.push({ comment, error: innerE.message }); | |
| console.log(`Failed to post comment for ${comment.path}: ${innerE.message}`); | |
| await sleep(SUCCESS_DELAY); | |
| break; | |
| } | |
| } else if (willRetry) { | |
| console.log(`Rate-limited/transient on ${comment.path}. Waiting ${(retryInfo.delayMs / 1000).toFixed(1)}s via ${retryInfo.source}.`); | |
| await sleep(retryInfo.delayMs); | |
| } else { | |
| failedCount++; | |
| failedComments.push({ comment, error: innerE.message }); | |
| console.log(`Failed to post comment for ${comment.path}: ${innerE.message}`); | |
| await sleep(FAILURE_DELAY); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| let finalBody = buildSummaryBody(totalCount, successCount, commentsWithoutLine.length + failedComments.length, warnings); | |
| finalBody += formatSummaryComments(commentsWithoutLine); | |
| finalBody += "\n\n---\n\nPosting statistics:"; | |
| finalBody += `\n- Successfully posted: ${successCount} comment(s)`; | |
| if (failedCount > 0) { | |
| finalBody += `\n- Failed to post: ${failedCount} comment(s)`; | |
| } | |
| if (failedComments.length > 0) { | |
| finalBody += "\n\n---\n\n### Inline comments shown in summary"; | |
| for (const { comment, error } of failedComments) { | |
| finalBody += "\n\n---\n\n"; | |
| finalBody += formatCommentMarkdown(comment, error); | |
| } | |
| } | |
| finalBody = SUMMARY_TAG + "\n" + finalBody; | |
| const summaryAlreadyPosted = await hasIssueCommentWithId({ id: SUMMARY_TAG }); | |
| if (summaryAlreadyPosted === true) { | |
| console.log("Summary comment with this run tag already exists; skipping."); | |
| } else if (summaryAlreadyPosted === null) { | |
| console.log("Cannot verify whether summary comment already exists; skipping to avoid duplicate."); | |
| } else { | |
| await createIssueComment(finalBody); | |
| } | |
| } | |
| } | |
| function parseArgs(args) { | |
| const out = {}; | |
| for (let i = 0; i < args.length; i++) { | |
| const arg = args[i]; | |
| if (arg === "-h" || arg === "--help") out.help = true; | |
| else if (arg === "--repo") out.repo = args[++i]; | |
| else if (arg === "--pr") out.pr = args[++i]; | |
| else if (arg === "--result") out.result = args[++i]; | |
| else if (arg === "--stderr") out.stderr = args[++i]; | |
| else if (arg === "--commit") out.commit = args[++i]; | |
| else if (arg === "--run-id") out.runId = args[++i]; | |
| else if (arg === "--run-attempt") out.runAttempt = args[++i]; | |
| else fail(`Unknown argument: ${arg}`); | |
| } | |
| return out; | |
| } | |
| function usage(code) { | |
| const text = [ | |
| "Usage:", | |
| " opencodereview-post-to-pr --repo owner/repo --pr 123 [--result /tmp/ocr-result.json] [--stderr /tmp/ocr-stderr.log]", | |
| "", | |
| "Typical local flow:", | |
| " git fetch origin 'refs/pull/123/head:pr-123'", | |
| " opencodereview review --from origin/main --to pr-123 --format json > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true", | |
| " GITHUB_TOKEN= opencodereview-post-to-pr --repo _ORG_/_REPO_ --pr 123", | |
| ].join("\n"); | |
| console.log(text); | |
| process.exit(code); | |
| } | |
| function inferRepo() { | |
| const res = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }); | |
| if (res.status !== 0) return null; | |
| const url = res.stdout.trim(); | |
| let m = url.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/); | |
| return m ? m[1] : null; | |
| } | |
| function getPullRequest() { | |
| return ghJson(["api", `repos/${owner}/${repoName}/pulls/${prNumber}`]).data; | |
| } | |
| async function createReview(payload) { | |
| return ghJson([ | |
| "api", | |
| "--method", | |
| "POST", | |
| `repos/${owner}/${repoName}/pulls/${prNumber}/reviews`, | |
| "--input", | |
| "-", | |
| ], payload); | |
| } | |
| async function createIssueComment(body) { | |
| return ghJson([ | |
| "api", | |
| "--method", | |
| "POST", | |
| `repos/${owner}/${repoName}/issues/${prNumber}/comments`, | |
| "--input", | |
| "-", | |
| ], { body }); | |
| } | |
| async function listReviews(page, perPage) { | |
| return ghJson(["api", "--method", "GET", `repos/${owner}/${repoName}/pulls/${prNumber}/reviews`, "-F", `per_page=${perPage}`, "-F", `page=${page}`]); | |
| } | |
| async function listReviewComments(page, perPage) { | |
| return ghJson(["api", "--method", "GET", `repos/${owner}/${repoName}/pulls/${prNumber}/comments`, "-F", `per_page=${perPage}`, "-F", `page=${page}`]); | |
| } | |
| async function listIssueComments(page, perPage) { | |
| return ghJson(["api", "--method", "GET", `repos/${owner}/${repoName}/issues/${prNumber}/comments`, "-F", `per_page=${perPage}`, "-F", `page=${page}`]); | |
| } | |
| function ghJson(args, inputObj) { | |
| const env = { ...process.env }; | |
| if (process.env.OCR_CLEAR_GH_TOKEN === "1") { | |
| delete env.GITHUB_TOKEN; | |
| delete env.GH_TOKEN; | |
| } | |
| const res = spawnSync("gh", args, { | |
| input: inputObj ? JSON.stringify(inputObj) : undefined, | |
| encoding: "utf8", | |
| env, | |
| }); | |
| const stdout = res.stdout || ""; | |
| const stderr = res.stderr || ""; | |
| if (res.status !== 0) { | |
| const err = new Error((stderr || stdout || `gh exited ${res.status}`).trim()); | |
| err.status = parseHttpStatus(stderr + "\n" + stdout); | |
| err.response = { headers: parseGhHeaders(stderr) }; | |
| throw err; | |
| } | |
| return { data: stdout.trim() ? JSON.parse(stdout) : null, headers: parseGhHeaders(stderr) }; | |
| } | |
| function parseHttpStatus(text) { | |
| const m = String(text || "").match(/\bHTTP\s+(\d{3})\b|status code:\s*(\d{3})/i); | |
| return m ? Number(m[1] || m[2]) : undefined; | |
| } | |
| function parseGhHeaders(_) { | |
| return {}; | |
| } | |
| function sleep(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| async function withRetry(tag, fn) { | |
| for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { | |
| try { | |
| return await fn(); | |
| } catch (e) { | |
| const retryInfo = computeRetryDelayMs(e, attempt); | |
| const willRetry = retryInfo != null && attempt < MAX_RETRIES; | |
| if (willRetry) { | |
| console.log(`[${tag}] transient/rate-limited. Waiting ${(retryInfo.delayMs / 1000).toFixed(1)}s via ${retryInfo.source}. ${e.message}`); | |
| await sleep(retryInfo.delayMs); | |
| } else { | |
| throw e; | |
| } | |
| } | |
| } | |
| } | |
| async function readWithPacing(tag, fn) { | |
| const res = await withRetry(tag, fn); | |
| const remaining = logRateLimitQuota(res, tag); | |
| if (remaining != null && remaining <= LOW_REMAINING_THRESHOLD) { | |
| await sleep(READ_LOW_REMAINING_SPACING); | |
| } else { | |
| await sleep(READ_SUCCESS_DELAY); | |
| } | |
| return res; | |
| } | |
| async function readAllPages(tag, pageFn, maxPages = 50) { | |
| const all = []; | |
| let page = 1; | |
| const perPage = 100; | |
| while (page <= maxPages) { | |
| const res = await readWithPacing(`${tag} page ${page}`, () => pageFn(page, perPage)); | |
| const items = res.data || []; | |
| all.push(...items); | |
| if (items.length < perPage) break; | |
| page++; | |
| } | |
| if (page > maxPages) { | |
| console.log(`[${tag}] reached max page limit (${maxPages}); results may be incomplete.`); | |
| } | |
| return all; | |
| } | |
| async function findExistingBatchReview({ tag }) { | |
| const reviews = await readAllPages("listReviews", listReviews); | |
| for (const r of reviews) { | |
| if ((r.body || "").includes(tag)) { | |
| return { found: true, review: r }; | |
| } | |
| } | |
| return { found: false }; | |
| } | |
| async function getPostedCommentIds() { | |
| const comments = await readAllPages("listReviewComments", listReviewComments); | |
| const ids = new Set(); | |
| const idRe = /<!--\s*(ocr-[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+-[a-f0-9]+)\s*-->/g; | |
| for (const c of comments) { | |
| const body = c.body || ""; | |
| let m; | |
| while ((m = idRe.exec(body)) !== null) ids.add(m[1]); | |
| } | |
| return ids; | |
| } | |
| async function isCommentAlreadyPosted({ id }) { | |
| try { | |
| const posted = await getPostedCommentIds(); | |
| return posted.has(id); | |
| } catch (e) { | |
| console.log(`[isCommentAlreadyPosted] check failed for ${id}: ${e.message}`); | |
| return null; | |
| } | |
| } | |
| async function hasIssueCommentWithId({ id }) { | |
| try { | |
| const comments = await readAllPages("listIssueComments", listIssueComments); | |
| return comments.some((c) => (c.body || "").includes(id)); | |
| } catch (e) { | |
| console.log(`[listIssueComments] check failed: ${e.message}`); | |
| return null; | |
| } | |
| } | |
| function parseNonNegInt(val, defaultVal) { | |
| const n = parseInt(val, 10); | |
| return Number.isFinite(n) && n >= 0 ? n : defaultVal; | |
| } | |
| function getHeader(headers, name) { | |
| const v = headers[name] != null ? headers[name] : headers[name.toLowerCase()]; | |
| return v != null ? String(v).trim() : undefined; | |
| } | |
| 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 = parseInt(process.env.OCR_RETRY_MAX_DELAY, 10) || 300000; | |
| const base = parseInt(process.env.OCR_RETRY_BASE_DELAY, 10) || 60000; | |
| let info = null; | |
| if (isRateLimit) { | |
| 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` }; | |
| } else { | |
| const dateMs = Date.parse(retryAfter); | |
| if (!isNaN(dateMs)) { | |
| info = { rawMs: Math.max(0, dateMs - Date.now()), source: "retry-after-date", detail: retryAfter }; | |
| } | |
| } | |
| } | |
| 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: `reset epoch=${reset}` }; | |
| } | |
| } | |
| if (!info) { | |
| const jitter = Math.floor(Math.random() * 1000); | |
| info = { rawMs: Math.min(base * Math.pow(2, attempt), cap) + jitter, source: "exponential-backoff", detail: `attempt=${attempt}` }; | |
| } | |
| } else { | |
| const transientBase = 2000; | |
| const jitter = Math.floor(Math.random() * 1000); | |
| info = { rawMs: Math.min(transientBase * Math.pow(2, attempt), cap) + jitter, source: "transient-backoff", detail: `HTTP ${status}` }; | |
| } | |
| const delayMs = Math.min(info.rawMs, cap); | |
| return { delayMs, source: info.source, detail: info.detail }; | |
| } | |
| function logRateLimitQuota(response, tag) { | |
| try { | |
| const h = (response && response.headers) || {}; | |
| const remaining = getHeader(h, "x-ratelimit-remaining"); | |
| const limit = getHeader(h, "x-ratelimit-limit"); | |
| const reset = getHeader(h, "x-ratelimit-reset"); | |
| if (remaining != null) { | |
| console.log(`[rate-limit] ${tag}: remaining=${remaining}/${limit || "?"}${reset ? `, reset epoch=${reset}` : ""}`); | |
| } | |
| return remaining != null ? Number(remaining) : null; | |
| } catch (_) { | |
| return null; | |
| } | |
| } | |
| function newCommentId() { | |
| return `ocr-${RUN_TAG}-${crypto.randomBytes(8).toString("hex")}`; | |
| } | |
| function resolveLines(comment) { | |
| const start = comment.start_line; | |
| const end = comment.end_line; | |
| if (start >= 1 && end >= 1 && start !== end) { | |
| return { start_line: start, line: end, start_side: "RIGHT", side: "RIGHT" }; | |
| } else if (end >= 1) { | |
| return { line: end, side: "RIGHT" }; | |
| } else if (start >= 1) { | |
| return { line: start, side: "RIGHT" }; | |
| } | |
| return {}; | |
| } | |
| function toReviewPayload(item) { | |
| return { | |
| path: item.comment.path, | |
| body: buildBody(item.comment, item.id), | |
| ...item.lines, | |
| }; | |
| } | |
| function buildBody(comment, id) { | |
| let body = `<!-- ${id} -->\n`; | |
| body += comment.content || ""; | |
| if (comment.suggestion_code && comment.existing_code) { | |
| body += "\n\nSuggestion:\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<details><summary>Suggested Change</summary>\n\n"; | |
| md += "Before:\n" + fencedBlock(comment.existing_code) + "\n\n"; | |
| md += "After:\n" + fencedBlock(comment.suggestion_code) + "\n\n"; | |
| md += "</details>"; | |
| } | |
| return md; | |
| } | |
| function buildSummaryBody(totalCount, inlineCount, summaryCount, warnings) { | |
| let body = `OpenCodeReview found ${totalCount} issue(s) in this PR.`; | |
| if (totalCount > 0) { | |
| body += `\n- ${inlineCount} posted as inline comment(s)`; | |
| body += `\n- ${summaryCount} posted as summary`; | |
| } | |
| if (warnings.length > 0) { | |
| body += `\n\n${warnings.length} warning(s) occurred during review.`; | |
| } | |
| return body; | |
| } | |
| function formatSummaryComments(summaryComments) { | |
| let body = ""; | |
| for (const { comment } of summaryComments) { | |
| body += "\n\n---\n\n"; | |
| body += formatCommentMarkdown(comment); | |
| } | |
| return body; | |
| } | |
| 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 readIfExists(path) { | |
| try { | |
| return fs.readFileSync(path, "utf8"); | |
| } catch (_) { | |
| return ""; | |
| } | |
| } | |
| function cleanRunPart(value) { | |
| return String(value || "local").replace(/[^A-Za-z0-9_.-]/g, "_"); | |
| } | |
| function fail(message) { | |
| console.error(`error: ${message}`); | |
| console.error("Run with --help for usage."); | |
| process.exit(2); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment