@twilio-labs/languagetool-cli
Version:
Run LanguageTool for linting Markdown files during CI
201 lines (200 loc) • 7.2 kB
JavaScript
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