mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-01 10:31:41 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54a931d94d | |||
| 5229534d8f | |||
| 78b3996f47 | |||
| d9f7bf7fdd | |||
| 45c410dd4c | |||
| 80e56281b2 | |||
| 125eae052b | |||
| 6f52bb7533 | |||
| 8724260eb4 | |||
| f32e9f7704 | |||
| 83c8371e94 | |||
| 5f14d90ccd | |||
| ff0d8c03b0 | |||
| 1dd7e728ff |
@@ -4,13 +4,14 @@
|
|||||||
|
|
||||||
## Submission
|
## Submission
|
||||||
|
|
||||||
- [ ] This PR is a bug fix or small-scope improvement.
|
- [ ] This PR is a bug fix.
|
||||||
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
|
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
|
||||||
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||||
- [ ] I tested this change locally.
|
- [ ] I tested this change locally.
|
||||||
- [ ] I added or updated tests when reasonable.
|
- [ ] I added or updated tests when reasonable.
|
||||||
|
- [ ] I added screenshots or recordings for UI changes when reasonable.
|
||||||
|
|
||||||
Approved feedback item (required if not a bug fix or small-scope improvement):
|
Explicit permission feedback item (required if not a bug fix):
|
||||||
|
|
||||||
<!-- https://yaak.app/feedback/... -->
|
<!-- https://yaak.app/feedback/... -->
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,819 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
inScope: {
|
||||||
|
name: "contribution: in scope",
|
||||||
|
color: "0E8A16",
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
explicitPermission: {
|
||||||
|
name: "contribution: explicit permission",
|
||||||
|
color: "5319E7",
|
||||||
|
description:
|
||||||
|
"Community PR links feedback where @gschier explicitly allowed the work.",
|
||||||
|
},
|
||||||
|
missingTemplate: {
|
||||||
|
name: "contribution: missing template",
|
||||||
|
color: "D93F0B",
|
||||||
|
description:
|
||||||
|
"Community PR is missing enough of the pull request template to review.",
|
||||||
|
},
|
||||||
|
policyUnmet: {
|
||||||
|
name: "contribution: policy unmet",
|
||||||
|
color: "B60205",
|
||||||
|
description:
|
||||||
|
"Community PR does not currently satisfy the contribution policy.",
|
||||||
|
},
|
||||||
|
needsScopeReview: {
|
||||||
|
name: "contribution: needs scope review",
|
||||||
|
color: "FBCA04",
|
||||||
|
description:
|
||||||
|
"Community PR may be broader than Yaak's bug-fix contribution policy.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MANAGED_LABEL_NAMES = [
|
||||||
|
...new Set(Object.values(LABELS).map((label) => label.name)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHECKBOXES = {
|
||||||
|
bugFix: "This PR is a bug fix.",
|
||||||
|
explicitPermission:
|
||||||
|
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
|
||||||
|
readContributing:
|
||||||
|
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
|
||||||
|
testedLocally: "I tested this change locally.",
|
||||||
|
testsUpdated: "I added or updated tests when reasonable.",
|
||||||
|
screenshotsAdded:
|
||||||
|
"I added screenshots or recordings for UI changes when reasonable.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBody(body) {
|
||||||
|
return (body || "").replace(/\r\n/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripComments(value) {
|
||||||
|
return value.replace(/<!--[\s\S]*?-->/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSection(body, heading) {
|
||||||
|
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
|
||||||
|
const match = pattern.exec(body);
|
||||||
|
|
||||||
|
if (match == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = body.slice(match.index + match[0].length);
|
||||||
|
const nextHeadingIndex = rest.search(/^##\s+/m);
|
||||||
|
return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulText(value) {
|
||||||
|
return stripComments(value || "").length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCheckboxLabel(label) {
|
||||||
|
return label
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||||
|
.replace(/`/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkboxState(body, label) {
|
||||||
|
const expectedLabel = normalizeCheckboxLabel(label);
|
||||||
|
|
||||||
|
for (const line of body.split("\n")) {
|
||||||
|
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
|
||||||
|
|
||||||
|
if (match == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
|
||||||
|
return match[1].toLowerCase() === "x";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFeedbackUrl(body) {
|
||||||
|
return (
|
||||||
|
body.match(
|
||||||
|
/https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
|
||||||
|
)?.[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
checkboxState(body, label),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const sectionCount = ["Summary", "Submission", "Related"].filter(
|
||||||
|
(heading) => getSection(body, heading) != null,
|
||||||
|
).length;
|
||||||
|
const checkboxCount = Object.values(states).filter(
|
||||||
|
(state) => state != null,
|
||||||
|
).length;
|
||||||
|
const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
|
||||||
|
const blockers = [];
|
||||||
|
const totalChangedLines =
|
||||||
|
Number(pr.additions || 0) + Number(pr.deletions || 0);
|
||||||
|
const changedFiles = Number(pr.changed_files || 0);
|
||||||
|
const largeDiff =
|
||||||
|
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)) {
|
||||||
|
return {
|
||||||
|
blockers: [],
|
||||||
|
changedFiles,
|
||||||
|
desiredLabels: [LABELS.inScope.name],
|
||||||
|
largeDiff,
|
||||||
|
status: "in_scope",
|
||||||
|
templateUsed,
|
||||||
|
totalChangedLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!templateUsed) {
|
||||||
|
blockers.push({
|
||||||
|
label: LABELS.missingTemplate.name,
|
||||||
|
message:
|
||||||
|
"Update the PR description with the repository pull request template.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const summary = getSection(body, "Summary");
|
||||||
|
const hasSummary = hasMeaningfulText(summary);
|
||||||
|
const feedbackUrl = findFeedbackUrl(body);
|
||||||
|
const bugFix = states.bugFix === true;
|
||||||
|
const explicitPermission = states.explicitPermission === true;
|
||||||
|
|
||||||
|
if (!hasSummary) {
|
||||||
|
blockers.push({
|
||||||
|
label: LABELS.policyUnmet.name,
|
||||||
|
message:
|
||||||
|
"Add a short summary describing the bug fix or permitted change.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bugFix && explicitPermission) {
|
||||||
|
blockers.push({
|
||||||
|
label: LABELS.policyUnmet.name,
|
||||||
|
message:
|
||||||
|
"Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
|
||||||
|
});
|
||||||
|
} else if (!bugFix && !explicitPermission) {
|
||||||
|
blockers.push({
|
||||||
|
label: LABELS.policyUnmet.name,
|
||||||
|
message:
|
||||||
|
"Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
|
||||||
|
});
|
||||||
|
} else if (explicitPermission && feedbackUrl == null) {
|
||||||
|
blockers.push({
|
||||||
|
label: LABELS.policyUnmet.name,
|
||||||
|
message:
|
||||||
|
"Link the feedback item where @gschier explicitly gave you permission to work on this.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (states.readContributing !== true) {
|
||||||
|
blockers.push({
|
||||||
|
label: LABELS.policyUnmet.name,
|
||||||
|
message: "Confirm that `CONTRIBUTING.md` was read and followed.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (states.testedLocally !== true) {
|
||||||
|
blockers.push({
|
||||||
|
label: LABELS.policyUnmet.name,
|
||||||
|
message: "Confirm that the change was tested locally.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (states.testsUpdated !== true) {
|
||||||
|
blockers.push({
|
||||||
|
label: LABELS.policyUnmet.name,
|
||||||
|
message: "Confirm that tests were added or updated when reasonable.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (states.screenshotsAdded !== true) {
|
||||||
|
blockers.push({
|
||||||
|
label: LABELS.policyUnmet.name,
|
||||||
|
message:
|
||||||
|
"Confirm that screenshots or recordings were added for UI changes when reasonable.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredLabels = new Set();
|
||||||
|
|
||||||
|
if (blockers.length === 0) {
|
||||||
|
desiredLabels.add(
|
||||||
|
largeDiff
|
||||||
|
? LABELS.needsScopeReview.name
|
||||||
|
: states.explicitPermission
|
||||||
|
? LABELS.explicitPermission.name
|
||||||
|
: LABELS.inScope.name,
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
|
||||||
|
) {
|
||||||
|
desiredLabels.add(LABELS.missingTemplate.name);
|
||||||
|
} else {
|
||||||
|
desiredLabels.add(LABELS.policyUnmet.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blockers,
|
||||||
|
changedFiles,
|
||||||
|
desiredLabels: [...desiredLabels],
|
||||||
|
largeDiff,
|
||||||
|
status: blockers.length === 0 ? "in_scope" : "blocked",
|
||||||
|
templateUsed,
|
||||||
|
totalChangedLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBlockingComment(analysis) {
|
||||||
|
const lines = [
|
||||||
|
COMMENT_MARKER,
|
||||||
|
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.",
|
||||||
|
"",
|
||||||
|
"This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
|
||||||
|
"",
|
||||||
|
...analysis.blockers.map((blocker) => `- ${blocker.message}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!analysis.templateUsed) {
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
"You can copy this template into the PR description and keep any existing context that is still useful.",
|
||||||
|
"",
|
||||||
|
"<details>",
|
||||||
|
"<summary>PR description template</summary>",
|
||||||
|
"",
|
||||||
|
"```md",
|
||||||
|
getPullRequestTemplate(),
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"</details>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analysis.largeDiff) {
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPullRequestTemplate() {
|
||||||
|
return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
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, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.",
|
||||||
|
"",
|
||||||
|
"If this PR is tied to a feedback item where @gschier explicitly gave permission, 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateTitle(title) {
|
||||||
|
if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeTableText(value) {
|
||||||
|
return escapeHtml(value).replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeResult({ pr, analysis, skipped, skipReason }) {
|
||||||
|
const comment =
|
||||||
|
analysis == null
|
||||||
|
? "None"
|
||||||
|
: buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
|
||||||
|
const summary = {
|
||||||
|
blocked: analysis?.blockers.length > 0,
|
||||||
|
comment,
|
||||||
|
details: "None",
|
||||||
|
labels:
|
||||||
|
analysis?.desiredLabels.length > 0
|
||||||
|
? analysis.desiredLabels.join(", ")
|
||||||
|
: "None",
|
||||||
|
number: pr.number,
|
||||||
|
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
|
||||||
|
status: "In scope",
|
||||||
|
title: escapeHtml(truncateTitle(pr.title)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (skipped) {
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
blocked: false,
|
||||||
|
comment: "None",
|
||||||
|
details: escapeHtml(skipReason),
|
||||||
|
labels: "None",
|
||||||
|
status: "Skipped",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.blocked) {
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
comment: escapeTableText(summary.comment),
|
||||||
|
details: escapeHtml(
|
||||||
|
analysis.blockers.map((blocker) => blocker.message).join("; "),
|
||||||
|
),
|
||||||
|
labels: escapeHtml(summary.labels),
|
||||||
|
status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
comment: escapeTableText(summary.comment),
|
||||||
|
labels: escapeHtml(summary.labels),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isOfficialMaintainer({ github, owner, repo, pr }) {
|
||||||
|
if (MAINTAINER_LOGINS.has(pr.user.login)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
username: pr.user.login,
|
||||||
|
});
|
||||||
|
|
||||||
|
return MAINTAINER_PERMISSIONS.has(response.data.permission);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureManagedLabels({ github, owner, repo }) {
|
||||||
|
for (const label of Object.values(LABELS)) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.getLabel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
name: label.name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
name: label.name,
|
||||||
|
color: label.color,
|
||||||
|
description: label.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
|
||||||
|
const desired = new Set(desiredLabels);
|
||||||
|
|
||||||
|
await ensureManagedLabels({ github, owner, repo });
|
||||||
|
|
||||||
|
for (const labelName of MANAGED_LABEL_NAMES) {
|
||||||
|
if (desired.has(labelName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
name: labelName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desired.size > 0) {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
labels: [...desired],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPolicyComment({ github, owner, repo, issueNumber }) {
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return comments.find(
|
||||||
|
(comment) =>
|
||||||
|
comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
|
||||||
|
const existingComment = await findPolicyComment({
|
||||||
|
github,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issueNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingComment == null) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: existingComment.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePolicyComment({ github, owner, repo, issueNumber }) {
|
||||||
|
const existingComment = await findPolicyComment({
|
||||||
|
github,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issueNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingComment == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.deleteComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: existingComment.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pullNumber,
|
||||||
|
dryRun,
|
||||||
|
}) {
|
||||||
|
const response = await github.rest.pulls.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: pullNumber,
|
||||||
|
});
|
||||||
|
const pr = response.data;
|
||||||
|
const issueNumber = pr.number;
|
||||||
|
|
||||||
|
if (pr.draft) {
|
||||||
|
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
|
||||||
|
return {
|
||||||
|
blocked: false,
|
||||||
|
number: pr.number,
|
||||||
|
summary: summarizeResult({
|
||||||
|
pr,
|
||||||
|
skipped: true,
|
||||||
|
skipReason: "draft",
|
||||||
|
}),
|
||||||
|
skipped: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
|
||||||
|
core.notice(
|
||||||
|
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
|
||||||
|
);
|
||||||
|
if (!dryRun) {
|
||||||
|
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
|
||||||
|
await deletePolicyComment({ github, owner, repo, issueNumber });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
blocked: false,
|
||||||
|
number: pr.number,
|
||||||
|
summary: summarizeResult({
|
||||||
|
pr,
|
||||||
|
skipped: true,
|
||||||
|
skipReason: `maintainer @${pr.user.login}`,
|
||||||
|
}),
|
||||||
|
skipped: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysis = analyzePullRequest(pr);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
const summary = summarizeResult({ pr, analysis });
|
||||||
|
core.notice(
|
||||||
|
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
blocked: analysis.blockers.length > 0,
|
||||||
|
number: pr.number,
|
||||||
|
summary,
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncLabels({
|
||||||
|
github,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issueNumber,
|
||||||
|
desiredLabels: analysis.desiredLabels,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (analysis.blockers.length > 0) {
|
||||||
|
await upsertPolicyComment({
|
||||||
|
github,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issueNumber,
|
||||||
|
body: buildPolicyComment(analysis),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
blocked: true,
|
||||||
|
number: pr.number,
|
||||||
|
summary: summarizeResult({ pr, analysis }),
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
number: pr.number,
|
||||||
|
summary: summarizeResult({ pr, analysis }),
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listOpenPullRequests({ github, owner, repo }) {
|
||||||
|
return github.paginate(github.rest.pulls.list, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
state: "open",
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManualPullRequestNumbers({ context, core }) {
|
||||||
|
const value = String(context.payload.inputs?.pr || "all").trim();
|
||||||
|
|
||||||
|
if (value.toLowerCase() === "all") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pullNumber = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
|
||||||
|
core.setFailed('The "pr" input must be "all" or a positive PR number.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pullNumber];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run({ github, context, core }) {
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const payloadPr = context.payload.pull_request;
|
||||||
|
const dryRunInput = context.payload.inputs?.dry_run;
|
||||||
|
const dryRun =
|
||||||
|
context.eventName === "workflow_dispatch" &&
|
||||||
|
dryRunInput !== false &&
|
||||||
|
dryRunInput !== "false";
|
||||||
|
let pullNumbers;
|
||||||
|
|
||||||
|
if (payloadPr != null) {
|
||||||
|
pullNumbers = [payloadPr.number];
|
||||||
|
} else {
|
||||||
|
pullNumbers = getManualPullRequestNumbers({ context, core });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pullNumbers?.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pullRequests =
|
||||||
|
pullNumbers == null
|
||||||
|
? await listOpenPullRequests({ github, owner, repo })
|
||||||
|
: pullNumbers.map((number) => ({ number }));
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
core.notice(
|
||||||
|
`Running contribution policy in dry-run mode for ${
|
||||||
|
pullNumbers == null
|
||||||
|
? "all open PRs"
|
||||||
|
: pullNumbers.map((number) => `#${number}`).join(", ")
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pr of pullRequests) {
|
||||||
|
results.push(
|
||||||
|
await checkPullRequest({
|
||||||
|
github,
|
||||||
|
core,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pullNumber: pr.number,
|
||||||
|
dryRun,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await core.summary
|
||||||
|
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
|
||||||
|
.addTable([
|
||||||
|
[
|
||||||
|
{ data: "PR", header: true },
|
||||||
|
{ data: "Title", header: true },
|
||||||
|
{ data: "Status", header: true },
|
||||||
|
{ data: "Labels", header: true },
|
||||||
|
{ data: "Details", header: true },
|
||||||
|
{ data: "Comment", header: true },
|
||||||
|
],
|
||||||
|
...results.map((result) => [
|
||||||
|
result.summary.prLink,
|
||||||
|
result.summary.title,
|
||||||
|
result.summary.status,
|
||||||
|
result.summary.labels,
|
||||||
|
result.summary.details,
|
||||||
|
result.summary.comment,
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
.write();
|
||||||
|
|
||||||
|
const blockedPullRequests = results.filter((result) => result.blocked);
|
||||||
|
|
||||||
|
if (blockedPullRequests.length > 0) {
|
||||||
|
if (dryRun) {
|
||||||
|
core.warning(
|
||||||
|
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
|
||||||
|
.map((result) => `#${result.number}`)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setFailed(
|
||||||
|
`Contribution policy failed for PR(s): ${blockedPullRequests
|
||||||
|
.map((result) => `#${result.number}`)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
analyzePullRequest,
|
||||||
|
run,
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Contribution Policy
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
pr:
|
||||||
|
description: PR number or all
|
||||||
|
required: true
|
||||||
|
default: all
|
||||||
|
type: string
|
||||||
|
dry_run:
|
||||||
|
description: Dry run
|
||||||
|
required: true
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
pull_request_target:
|
||||||
|
types: [labeled, unlabeled]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Check contribution policy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- 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
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { run } = require("./.github/scripts/check-contribution-policy.js");
|
||||||
|
await run({ github, context, core });
|
||||||
+1
-2
@@ -3,13 +3,12 @@
|
|||||||
Yaak accepts community pull requests for:
|
Yaak accepts community pull requests for:
|
||||||
|
|
||||||
- Bug fixes
|
- Bug fixes
|
||||||
- Small-scope improvements directly tied to existing behavior
|
|
||||||
|
|
||||||
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
|
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
|
||||||
|
|
||||||
## Approval for Non-Bugfix Changes
|
## Approval for Non-Bugfix Changes
|
||||||
|
|
||||||
If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated.
|
If your PR is not a bug fix, include a link to the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
|||||||
## Contribution Policy
|
## Contribution Policy
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Community PRs are currently limited to bug fixes and small-scope improvements.
|
> Community PRs are currently limited to bug fixes.
|
||||||
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
|
> If your PR is not a bug fix, link the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
|
||||||
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
|
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
|
||||||
|
|
||||||
## Useful Resources
|
## Useful Resources
|
||||||
|
|||||||
Reference in New Issue
Block a user