UNPKG

@twilio-labs/languagetool-cli

Version:

Run LanguageTool for linting Markdown files during CI

201 lines (200 loc) 7.2 kB
import { Octokit } from "octokit"; import { snooze } from "./snooze.js"; import { markdownReporter, MARKDOWN_ITEM_COUNTER } from "./markdownReporter.js"; import { getChangedLineNumbersFromPatch } from "./parseGitPatch.js"; let snoozeFunc = snooze; export const PR_COMMENT_COUNTER = "PR Comments"; export const MAGIC_MARKER = "<!-- languagetool-cli -->"; function parsePrUrl(prUrlString) { const urlParse = /^https:\/\/(?<host>.*?)\/(?<owner>.*?)\/(?<repo>.*?)\/pull\/(?<pull_number>\d*)$/.exec(prUrlString); return { host: urlParse?.groups?.host, owner: urlParse?.groups?.owner, repo: urlParse?.groups?.repo, pull_number: parseInt(urlParse?.groups?.pull_number), }; } function getOctokit(host) { if (!process.env.GITHUB_TOKEN) { throw new Error("No GITHUB_TOKEN environment variable specified. Cannot report to GitHub."); } const octokitOptions = { auth: process.env.GITHUB_TOKEN }; if (host !== "github.com") { octokitOptions.baseUrl = "https://" + host + "/api/v3"; } return new Octokit(octokitOptions); } let pr; let octokit; let prSha; let prGeneralComments = []; let reviewCommentApiCounter = 0; export async function initializeOctokit(prUrlString, overrideSnooze) { pr = parsePrUrl(prUrlString); const { owner, repo, pull_number } = pr; octokit = getOctokit(pr.host); const prResponse = await octokit.rest.pulls.get({ owner, repo, pull_number, }); prSha = prResponse.data.head.sha; prGeneralComments = []; reviewCommentApiCounter = 0; snoozeFunc = overrideSnooze ?? snooze; } export async function getFilesFromPr(prUrlString) { await initializeOctokit(prUrlString); const { owner, repo, pull_number } = pr; // Remove old comments const deletePromises = []; const generalComments = await octokit.rest.issues.listComments({ owner, repo, issue_number: pull_number, }); for (const comment of generalComments.data.filter((c) => c.body?.includes(MAGIC_MARKER))) { deletePromises.push(octokit.rest.issues.deleteComment({ owner, repo, comment_id: comment.id })); } const reviewComments = await octokit.rest.pulls.listReviewComments({ owner, repo, pull_number, }); for (const comment of reviewComments.data.filter((c) => c.body?.includes(MAGIC_MARKER))) { deletePromises.push(octokit.rest.pulls.deleteReviewComment({ owner, repo, comment_id: comment.id, })); } await Promise.all(deletePromises); const response = await octokit.rest.pulls.listFiles({ owner, repo, pull_number, }); return response.data .filter((f) => !["renamed", "removed", "unchanged"].includes(f.status) && (f.filename.toLowerCase().endsWith("md") || f.filename.toLowerCase().endsWith("mdx"))) .map((f) => ({ filename: f.filename, changedLines: getChangedLineNumbersFromPatch(f.patch), })); } async function addCommentToPr(item, options, stats) { const counterPassed = stats.getCounter(PR_COMMENT_COUNTER) >= options["max-pr-suggestions"]; const changeIsInDiff = item.result.changedLines?.includes(item.line); if (counterPassed || !changeIsInDiff) { if (changeIsInDiff || !options["pr-diff-only"]) { prGeneralComments.push(markdownReporter.issue(item, options, stats)); } return; } reviewCommentApiCounter++; if (reviewCommentApiCounter > 1) { // GitHub recommends 1s between review comment calls await snoozeFunc(1000); } const md = []; md.push(MAGIC_MARKER); md.push(`${item.message} \`${item.contextHighlighted}\``); md.push(""); if (item.suggestedLine) { md.push("```suggestion"); md.push(item.suggestedLine); md.push("```"); md.push(""); if (item.replacements.length > 1) { md.push("\n**Other suggestion(s):** " + item.replacements .slice(1) .map((r) => "`" + r + "`") .join(", ")); } } else if (item.match.rule.issueType === "misspelling") { md.push("If this is code (like a variable name), try surrounding it with \\`backticks\\`."); } try { await octokit.rest.pulls.createReviewComment({ owner: pr.owner, repo: pr.repo, pull_number: pr.pull_number, path: item.result.path, side: "RIGHT", line: item.line, commit_id: prSha, body: md.join("\n"), }); stats.incrementCounter(PR_COMMENT_COUNTER); } catch (err) { if (err.message.includes("pull_request_review_thread.line must be part of the diff")) { if (!options["pr-diff-only"]) { prGeneralComments.push(markdownReporter.issue(item, options, stats)); } } else throw err; } } function fillCommentTemplate(vars) { const templateString = `${MAGIC_MARKER} <details> <summary><h3>\${this.numItems} \${this.additional}grammar issues found (click to expand)</h3></summary> \${this.comment} </details>`; return new Function("return `" + templateString + "`;").call(vars); } export const githubReporter = { noIssues: (result, options, stats) => { prGeneralComments.push(markdownReporter.noIssues(result, options, stats)); return ""; }, issue: addCommentToPr, complete: async (options, stats) => { if (!prGeneralComments.length) return; const totalMarkdownItems = stats.getCounter(MARKDOWN_ITEM_COUNTER); const additional = totalMarkdownItems < stats.sumAllCounters() ? "additional " : ""; await attemptCreateComment(totalMarkdownItems, additional, prGeneralComments); }, }; async function attemptCreateComment(numItems, additional, comments) { const result = await createComment(numItems, additional, comments.join("\n")); if (result === "too_long") { if (comments.length < 2) { throw new Error("The comment is too long for GitHub."); } const half = Math.ceil(comments.length / 2); const firstHalf = comments.slice(0, half); const secondHalf = comments.slice(-half); await attemptCreateComment(firstHalf.length, additional, firstHalf); await attemptCreateComment(secondHalf.length, "additional ", secondHalf); } } async function createComment(numItems, additional, comment) { try { await octokit.rest.issues.createComment({ owner: pr.owner, repo: pr.repo, issue_number: pr.pull_number, body: fillCommentTemplate({ numItems, additional, comment, }), }); } catch (err) { const message = err?.response?.data?.errors?.[0]?.message; if (message.startsWith("Body is too long")) { return "too_long"; } throw err; } return "ok"; } //# sourceMappingURL=githubReporter.js.map