From 83c8371e94680b41bf5dc0947195e422ec2a7df8 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 30 Jun 2026 14:15:37 -0700 Subject: [PATCH] Support contribution policy label overrides --- .github/scripts/check-contribution-policy.js | 139 +++++++++++++++++-- .github/workflows/contribution-policy.yml | 5 +- 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/.github/scripts/check-contribution-policy.js b/.github/scripts/check-contribution-policy.js index b7ec63f8..b3f69f73 100644 --- a/.github/scripts/check-contribution-policy.js +++ b/.github/scripts/check-contribution-policy.js @@ -3,16 +3,22 @@ const COMMENT_MARKER = ""; const MAINTAINER_LOGINS = new Set(["gschier"]); const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]); +const REVIEWER_LOGIN = "gschier"; const LARGE_DIFF_CHANGED_FILES = 20; const LARGE_DIFF_CHANGED_LINES = 800; const SUMMARY_TITLE_MAX_LENGTH = 80; const LABELS = { - accepted: { - name: "contribution: accepted", + inScope: { + name: "contribution: in scope", 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: { 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 = { 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) { const body = normalizeBody(pr.body); + const labelNames = getLabelNames(pr); const states = Object.fromEntries( Object.entries(CHECKBOXES).map(([key, label]) => [ key, @@ -135,6 +152,41 @@ function analyzePullRequest(pr) { changedFiles > LARGE_DIFF_CHANGED_FILES || 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) { blockers.push({ label: LABELS.needsTemplate.name, @@ -203,7 +255,7 @@ function analyzePullRequest(pr) { desiredLabels.add( states.approvedFeedback ? LABELS.approvedFeedback.name - : LABELS.accepted.name, + : LABELS.inScope.name, ); } @@ -216,6 +268,7 @@ function analyzePullRequest(pr) { changedFiles, desiredLabels: [...desiredLabels], largeDiff, + status: blockers.length === 0 ? "in_scope" : "blocked", templateUsed, totalChangedLines, }; @@ -246,6 +299,38 @@ function buildBlockingComment(analysis) { 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) { return String(value) .replace(/&/g, "&") @@ -269,9 +354,9 @@ function escapeTableText(value) { function summarizeResult({ pr, analysis, skipped, skipReason }) { const comment = - analysis?.blockers.length > 0 - ? buildBlockingComment(analysis).replace(COMMENT_MARKER, "").trim() - : "None"; + analysis == null + ? "None" + : buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim(); const summary = { blocked: analysis?.blockers.length > 0, comment, @@ -282,7 +367,7 @@ function summarizeResult({ pr, analysis, skipped, skipReason }) { : "None", number: pr.number, prLink: `#${pr.number}`, - status: "Accepted", + status: "In scope", title: escapeHtml(truncateTitle(pr.title)), }; @@ -305,13 +390,13 @@ function summarizeResult({ pr, analysis, skipped, skipReason }) { analysis.blockers.map((blocker) => blocker.message).join("; "), ), labels: escapeHtml(summary.labels), - status: "Blocked", + status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked", }; } return { ...summary, - comment: "None", + comment: escapeTableText(summary.comment), 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({ github, core, @@ -538,7 +644,7 @@ async function checkPullRequest({ owner, repo, issueNumber, - body: buildBlockingComment(analysis), + body: buildPolicyComment(analysis), }); return { 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}.`); return { blocked: false, diff --git a/.github/workflows/contribution-policy.yml b/.github/workflows/contribution-policy.yml index d3179740..d68a43c5 100644 --- a/.github/workflows/contribution-policy.yml +++ b/.github/workflows/contribution-policy.yml @@ -8,11 +8,13 @@ on: required: true default: true type: boolean + pull_request_target: + types: [labeled, unlabeled] permissions: contents: read issues: write - pull-requests: read + pull-requests: write jobs: check: @@ -22,6 +24,7 @@ jobs: - name: Checkout policy script uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.base.sha || github.ref }} fetch-depth: 1 - name: Check contribution policy