Files
2026-07-13 13:22:34 +08:00

231 lines
8.5 KiB
JavaScript

// Auto-close PRs based on linked-issue policy:
// 1. PRs that attempt to close an issue without the "ready" label.
// 2. PRs that don't link to any issue and change more than LOC_THRESHOLD
// lines.
// Skips PRs that reference multiple issues (ambiguous intent).
// Only enforces on issues/PRs created on or after 2026-03-10.
const fs = require("fs");
const path = require("path");
const READY_LABEL = "ready";
const PR_TEMPLATE_PATH = ".github/pull_request_template.md";
// The date we introduced the "ready" label policy; skip older issues/PRs.
const CUTOFF_DATE = new Date("2026-03-10T00:00:00Z");
// PRs with more than this many LOC changed must link to an issue.
const LOC_THRESHOLD = 100;
const QUERY = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 10) {
nodes {
number
createdAt
labels(first: 50) {
nodes { name }
}
assignees(first: 10) {
nodes { login }
}
}
}
}
}
}
`;
function getTemplateHeadings() {
const templatePath = path.join(process.env.GITHUB_WORKSPACE, PR_TEMPLATE_PATH);
try {
return fs
.readFileSync(templatePath, "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => /^#+\s/.test(line));
} catch (err) {
throw new Error(`Failed to read PR template at ${templatePath}: ${err.message}`);
}
}
function hasIssueReference(body) {
if (!body) return false;
// Strip fenced and inline code blocks so references mentioned inside code
// samples don't count.
const stripped = body
.replace(/```[\s\S]*?```/g, "")
.replace(/~~~[\s\S]*?~~~/g, "")
.replace(/`[^`\n]*`/g, "");
// Match `#123`, `owner/repo#123`, or an issue/PR URL.
const shortRef = /(?:[\w.-]+\/[\w.-]+)?#\d+/;
const urlRef = /https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/;
return shortRef.test(stripped) || urlRef.test(stripped);
}
function getMissingHeadings(body, headings) {
if (!body) return headings;
const bodyLines = new Set(body.split("\n").map((line) => line.trim()));
return headings.filter((h) => !bodyLines.has(h));
}
async function isDatabricksAuthor({ github, context }) {
const prAuthor = context.payload.pull_request.user.login;
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
// Check user profile for Databricks affiliation
const { data: user } = await github.rest.users.getByUsername({ username: prAuthor });
if ([user.company, user.email].some((v) => /databricks/i.test(v || ""))) return true;
// Check commit author emails for @databricks.com
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
return commits.some((c) => /@databricks\.com$/i.test(c.commit.author.email || ""));
}
async function getCloseReason({ github, context }) {
const association = context.payload.pull_request.author_association;
if (["OWNER", "MEMBER", "COLLABORATOR"].includes(association)) return undefined;
if (context.payload.pull_request.user.type === "Bot") return undefined;
if (await isDatabricksAuthor({ github, context })) {
const prAuthor = context.payload.pull_request.user.login;
console.log(`PR author @${prAuthor} has Databricks affiliation. Skipping.`);
return undefined;
}
const prNumber = context.payload.pull_request.number;
const prAuthor = context.payload.pull_request.user.login;
const { owner, repo } = context.repo;
// Check that the PR body follows the PR template
const templateHeadings = getTemplateHeadings();
const prBody = context.payload.pull_request.body;
const missingHeadings = getMissingHeadings(prBody, templateHeadings);
const missingRatio = missingHeadings.length / templateHeadings.length;
console.log(
`PR #${prNumber} is missing ${missingHeadings.length}/${templateHeadings.length} template section(s).`
);
if (missingRatio > 0.5) {
const missingList = missingHeadings.map((h) => `- ${h.replace(/^#+\s*/, "")}`).join("\n");
return [
"This PR was automatically closed because it does not follow the PR template.",
`<details>\n<summary>Missing sections</summary>\n\n${missingList}\n</details>`,
`Please update your PR body to include all sections from the [PR template](https://github.com/${owner}/${repo}/blob/master/${PR_TEMPLATE_PATH}) and reopen this PR.`,
].join("\n\n");
}
const response = await github.graphql(QUERY, { owner, repo, number: prNumber });
const issues = response.repository.pullRequest.closingIssuesReferences.nodes;
if (issues.length === 0) {
// closingIssuesReferences only catches closing keywords (Fixes/Closes/Resolves).
// Also accept `#123`, `owner/repo#123`, or an issue/PR URL in the PR body.
if (hasIssueReference(prBody)) {
console.log(`PR #${prNumber} body contains an issue reference. Skipping.`);
return undefined;
}
const prCreatedAt = new Date(context.payload.pull_request.created_at);
if (prCreatedAt < CUTOFF_DATE) {
console.log(`PR #${prNumber} was created before ${CUTOFF_DATE.toISOString()}. Skipping.`);
return undefined;
}
const { additions, deletions } = context.payload.pull_request;
const totalChanges = additions + deletions;
if (totalChanges <= LOC_THRESHOLD) {
console.log(
`PR #${prNumber} has no linked issue but only ${totalChanges} LOC changed (<= ${LOC_THRESHOLD}). Skipping.`
);
return undefined;
}
console.log(
`PR #${prNumber} has no linked issue and ${totalChanges} LOC changed (> ${LOC_THRESHOLD}). Closing.`
);
return [
"This PR was automatically closed because it does not link to an issue.",
"Please open an issue describing the bug or feature first, wait for a maintainer to triage it, then link it from your PR description (e.g. `Fixes #123`).",
"Please do not force-push to or delete the PR branch so this PR can be reopened.",
].join(" ");
}
if (issues.length > 1) {
console.log(
`Multiple issues referenced (${issues.map((i) => `#${i.number}`).join(", ")}). Skipping.`
);
return undefined;
}
const issue = issues[0];
console.log(`PR #${prNumber} references issue #${issue.number}`);
// Skip issues created before the cutoff date
if (new Date(issue.createdAt) < CUTOFF_DATE) {
console.log(
`Issue #${issue.number} was created before ${CUTOFF_DATE.toISOString()}. Skipping.`
);
return undefined;
}
const hasReadyLabel = issue.labels.nodes.some((label) => label.name === READY_LABEL);
if (!hasReadyLabel) {
console.log(
`Issue #${issue.number} is missing the "${READY_LABEL}" label. Closing PR #${prNumber}.`
);
return [
`This PR was automatically closed because #${issue.number} is missing the \`${READY_LABEL}\` label.`,
"Once a maintainer triages the issue and applies the label, feel free to reopen this PR.",
"Please do not force-push to or delete the PR branch so this PR can be reopened.",
].join(" ");
}
const assigneeLogins = issue.assignees.nodes.map((a) => a.login);
if (assigneeLogins.length > 0 && !assigneeLogins.includes(prAuthor)) {
const assigneeList = assigneeLogins.map((login) => `@${login}`).join(", ");
console.log(
`Issue #${issue.number} is assigned to ${assigneeList} but PR author is @${prAuthor}. Closing PR #${prNumber}.`
);
return [
`This PR was automatically closed because #${issue.number} is assigned to ${assigneeList}.`,
"If you believe this was done in error, please reach out to a maintainer.",
"Please do not force-push to or delete the PR branch so this PR can be reopened.",
].join(" ");
}
console.log(`Issue #${issue.number} has the "${READY_LABEL}" label. No action needed.`);
return undefined;
}
async function main({ context, github }) {
const commentBody = await getCloseReason({ github, context });
if (commentBody !== undefined) {
const prNumber = context.payload.pull_request.number;
const { owner, repo } = context.repo;
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody,
});
await github.rest.pulls.update({
owner,
repo,
pull_number: prNumber,
state: "closed",
});
console.log(`PR #${prNumber} closed.`);
}
}
module.exports = { main, getCloseReason, isDatabricksAuthor };