Support contribution policy label overrides

This commit is contained in:
Gregory Schier
2026-06-30 14:15:37 -07:00
parent 5f14d90ccd
commit 83c8371e94
2 changed files with 130 additions and 14 deletions
+126 -13
View File
@@ -3,16 +3,22 @@ const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
const MAINTAINER_LOGINS = new Set(["gschier"]); const MAINTAINER_LOGINS = new Set(["gschier"]);
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]); const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
const REVIEWER_LOGIN = "gschier";
const LARGE_DIFF_CHANGED_FILES = 20; const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800; const LARGE_DIFF_CHANGED_LINES = 800;
const SUMMARY_TITLE_MAX_LENGTH = 80; const SUMMARY_TITLE_MAX_LENGTH = 80;
const LABELS = { const LABELS = {
accepted: { inScope: {
name: "contribution: accepted", name: "contribution: in scope",
color: "0E8A16", color: "0E8A16",
description: "Community PR appears to match Yaak's contribution policy.", description: "Community PR appears to be in scope for maintainer review.",
},
outOfScope: {
name: "contribution: out of scope",
color: "B60205",
description: "Community PR does not match Yaak's contribution policy.",
}, },
approvedFeedback: { approvedFeedback: {
name: "contribution: approved feedback", name: "contribution: approved feedback",
@@ -37,7 +43,13 @@ const LABELS = {
}, },
}; };
const MANAGED_LABEL_NAMES = Object.values(LABELS).map((label) => label.name); const LEGACY_MANAGED_LABEL_NAMES = ["contribution: accepted"];
const MANAGED_LABEL_NAMES = [
...new Set([
...Object.values(LABELS).map((label) => label.name),
...LEGACY_MANAGED_LABEL_NAMES,
]),
];
const CHECKBOXES = { const CHECKBOXES = {
smallScope: "This PR is a bug fix or small-scope improvement.", smallScope: "This PR is a bug fix or small-scope improvement.",
@@ -112,8 +124,13 @@ function findFeedbackUrl(body) {
); );
} }
function getLabelNames(pr) {
return new Set((pr.labels || []).map((label) => label.name));
}
function analyzePullRequest(pr) { function analyzePullRequest(pr) {
const body = normalizeBody(pr.body); const body = normalizeBody(pr.body);
const labelNames = getLabelNames(pr);
const states = Object.fromEntries( const states = Object.fromEntries(
Object.entries(CHECKBOXES).map(([key, label]) => [ Object.entries(CHECKBOXES).map(([key, label]) => [
key, key,
@@ -135,6 +152,41 @@ function analyzePullRequest(pr) {
changedFiles > LARGE_DIFF_CHANGED_FILES || changedFiles > LARGE_DIFF_CHANGED_FILES ||
totalChangedLines > LARGE_DIFF_CHANGED_LINES; totalChangedLines > LARGE_DIFF_CHANGED_LINES;
if (labelNames.has(LABELS.outOfScope.name)) {
return {
blockers: [
{
label: LABELS.outOfScope.name,
message: "Marked out of scope by maintainer label.",
},
],
changedFiles,
desiredLabels: [LABELS.outOfScope.name],
largeDiff,
status: "out_of_scope",
templateUsed,
totalChangedLines,
};
}
if (labelNames.has(LABELS.inScope.name)) {
const desiredLabels = [LABELS.inScope.name];
if (largeDiff) {
desiredLabels.push(LABELS.largeDiff.name);
}
return {
blockers: [],
changedFiles,
desiredLabels,
largeDiff,
status: "in_scope",
templateUsed,
totalChangedLines,
};
}
if (!templateUsed) { if (!templateUsed) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.needsTemplate.name,
@@ -203,7 +255,7 @@ function analyzePullRequest(pr) {
desiredLabels.add( desiredLabels.add(
states.approvedFeedback states.approvedFeedback
? LABELS.approvedFeedback.name ? LABELS.approvedFeedback.name
: LABELS.accepted.name, : LABELS.inScope.name,
); );
} }
@@ -216,6 +268,7 @@ function analyzePullRequest(pr) {
changedFiles, changedFiles,
desiredLabels: [...desiredLabels], desiredLabels: [...desiredLabels],
largeDiff, largeDiff,
status: blockers.length === 0 ? "in_scope" : "blocked",
templateUsed, templateUsed,
totalChangedLines, totalChangedLines,
}; };
@@ -246,6 +299,38 @@ function buildBlockingComment(analysis) {
return lines.join("\n"); return lines.join("\n");
} }
function buildInScopeComment() {
return [
COMMENT_MARKER,
"Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.",
"",
"This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.",
].join("\n");
}
function buildOutOfScopeComment() {
return [
COMMENT_MARKER,
"Thanks for the PR. This does not appear to match Yaak's current contribution policy.",
"",
"Yaak currently accepts community PRs for bug fixes, small-scope improvements, or changes tied to a maintainer-reviewed feedback item from https://yaak.app/feedback.",
"",
"If this PR is tied to a feedback item where contribution approval was explicitly stated, please link it in the PR description.",
].join("\n");
}
function buildPolicyComment(analysis) {
if (analysis.status === "out_of_scope") {
return buildOutOfScopeComment();
}
if (analysis.blockers.length > 0) {
return buildBlockingComment(analysis);
}
return buildInScopeComment();
}
function escapeHtml(value) { function escapeHtml(value) {
return String(value) return String(value)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -269,9 +354,9 @@ function escapeTableText(value) {
function summarizeResult({ pr, analysis, skipped, skipReason }) { function summarizeResult({ pr, analysis, skipped, skipReason }) {
const comment = const comment =
analysis?.blockers.length > 0 analysis == null
? buildBlockingComment(analysis).replace(COMMENT_MARKER, "").trim() ? "None"
: "None"; : buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
const summary = { const summary = {
blocked: analysis?.blockers.length > 0, blocked: analysis?.blockers.length > 0,
comment, comment,
@@ -282,7 +367,7 @@ function summarizeResult({ pr, analysis, skipped, skipReason }) {
: "None", : "None",
number: pr.number, number: pr.number,
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`, prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
status: "Accepted", status: "In scope",
title: escapeHtml(truncateTitle(pr.title)), title: escapeHtml(truncateTitle(pr.title)),
}; };
@@ -305,13 +390,13 @@ function summarizeResult({ pr, analysis, skipped, skipReason }) {
analysis.blockers.map((blocker) => blocker.message).join("; "), analysis.blockers.map((blocker) => blocker.message).join("; "),
), ),
labels: escapeHtml(summary.labels), labels: escapeHtml(summary.labels),
status: "Blocked", status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
}; };
} }
return { return {
...summary, ...summary,
comment: "None", comment: escapeTableText(summary.comment),
labels: escapeHtml(summary.labels), labels: escapeHtml(summary.labels),
}; };
} }
@@ -459,6 +544,27 @@ async function deletePolicyComment({ github, owner, repo, issueNumber }) {
}); });
} }
async function requestMaintainerReview({ github, owner, repo, pr }) {
if (pr.user.login === REVIEWER_LOGIN) {
return;
}
try {
await github.rest.pulls.requestReviewers({
owner,
repo,
pull_number: pr.number,
reviewers: [REVIEWER_LOGIN],
});
} catch (error) {
if (error.status === 422) {
return;
}
throw error;
}
}
async function checkPullRequest({ async function checkPullRequest({
github, github,
core, core,
@@ -538,7 +644,7 @@ async function checkPullRequest({
owner, owner,
repo, repo,
issueNumber, issueNumber,
body: buildBlockingComment(analysis), body: buildPolicyComment(analysis),
}); });
return { return {
blocked: true, blocked: true,
@@ -548,7 +654,14 @@ async function checkPullRequest({
}; };
} }
await deletePolicyComment({ github, owner, repo, issueNumber }); await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildPolicyComment(analysis),
});
await requestMaintainerReview({ github, owner, repo, pr });
core.notice(`Contribution policy check passed for PR #${pr.number}.`); core.notice(`Contribution policy check passed for PR #${pr.number}.`);
return { return {
blocked: false, blocked: false,
+4 -1
View File
@@ -8,11 +8,13 @@ on:
required: true required: true
default: true default: true
type: boolean type: boolean
pull_request_target:
types: [labeled, unlabeled]
permissions: permissions:
contents: read contents: read
issues: write issues: write
pull-requests: read pull-requests: write
jobs: jobs:
check: check:
@@ -22,6 +24,7 @@ jobs:
- name: Checkout policy script - name: Checkout policy script
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.base.sha || github.ref }}
fetch-depth: 1 fetch-depth: 1
- name: Check contribution policy - name: Check contribution policy