mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-25 14:01:44 -04:00
feat(github): add workflow for PR contribution guidelines (#66380)
This commit is contained in:
24
.github/scripts/pr-guidelines/check-allow-list.js
vendored
Normal file
24
.github/scripts/pr-guidelines/check-allow-list.js
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
|
||||
const teamSlugs = ['dev-team', 'curriculum', 'staff', 'moderators'];
|
||||
const membershipChecks = teamSlugs.map(team_slug =>
|
||||
github.rest.teams
|
||||
.getMembershipForUserInOrg({
|
||||
org: 'freeCodeCamp',
|
||||
team_slug,
|
||||
username: prAuthor
|
||||
})
|
||||
.then(({ data }) => data.state === 'active')
|
||||
.catch(() => false)
|
||||
);
|
||||
const results = await Promise.all(membershipChecks);
|
||||
const isOrgTeamMember = results.some(Boolean);
|
||||
|
||||
const isAllowListed =
|
||||
isOrgTeamMember || ['camperbot', 'renovate[bot]'].includes(prAuthor);
|
||||
|
||||
core.setOutput('is_allow_listed', isAllowListed);
|
||||
};
|
||||
108
.github/scripts/pr-guidelines/check-linked-issue.js
vendored
Normal file
108
.github/scripts/pr-guidelines/check-linked-issue.js
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
'use strict';
|
||||
|
||||
const FOOTER =
|
||||
'\n\n---\nJoin us in our [chat room](https://discord.gg/PRyKn3Vbay) or our [forum](https://forum.freecodecamp.org/c/contributors/3) if you have any questions or need help with contributing.';
|
||||
|
||||
async function addDeprioritizedLabel(github, context) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ['deprioritized']
|
||||
});
|
||||
}
|
||||
|
||||
async function addComment(github, context, body) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = async ({ github, context, isAllowListed }) => {
|
||||
if (isAllowListed === 'true') return;
|
||||
|
||||
const result = await github.graphql(
|
||||
`query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
labels(first: 10) { nodes { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
number: context.payload.pull_request.number
|
||||
}
|
||||
);
|
||||
|
||||
const pr = result.repository?.pullRequest;
|
||||
if (!pr) return;
|
||||
|
||||
const linkedIssues = pr.closingIssuesReferences.nodes;
|
||||
|
||||
if (linkedIssues.length === 0) {
|
||||
await addDeprioritizedLabel(github, context);
|
||||
await addComment(
|
||||
github,
|
||||
context,
|
||||
[
|
||||
'Hi there,',
|
||||
'',
|
||||
'Thanks for opening this pull request.',
|
||||
'',
|
||||
'We kindly ask that contributors open an issue before submitting a PR so the change can be discussed and approved before work begins. This helps avoid situations where significant effort goes into something we ultimately cannot merge.',
|
||||
'',
|
||||
'Please open an issue first and allow it to be triaged. Once the issue is open for contribution, you are welcome to update this pull request to reflect the issue consensus. Until then, we will not be able to review your pull request.'
|
||||
].join('\n') + FOOTER
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasWaitingTriage = linkedIssues.some(issue =>
|
||||
issue.labels.nodes.some(l => l.name === 'status: waiting triage')
|
||||
);
|
||||
if (hasWaitingTriage) {
|
||||
await addDeprioritizedLabel(github, context);
|
||||
await addComment(
|
||||
github,
|
||||
context,
|
||||
[
|
||||
'Hi there,',
|
||||
'',
|
||||
'Thanks for opening this pull request.',
|
||||
'',
|
||||
'The linked issue has not been triaged yet, and a solution has not been agreed upon. Once the issue is open for contribution, you are welcome to update this pull request to reflect the issue consensus. Until then, we will not be able to review your pull request.'
|
||||
].join('\n') + FOOTER
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isOpenForContribution = linkedIssues.some(issue =>
|
||||
issue.labels.nodes.some(
|
||||
l => l.name === 'help wanted' || l.name === 'first timers only'
|
||||
)
|
||||
);
|
||||
if (!isOpenForContribution) {
|
||||
await addDeprioritizedLabel(github, context);
|
||||
await addComment(
|
||||
github,
|
||||
context,
|
||||
[
|
||||
'Hi there,',
|
||||
'',
|
||||
'Thanks for opening this pull request.',
|
||||
'',
|
||||
'The linked issue is not open for contribution. If you are looking for issues to contribute to, please check out issues labeled [`help wanted`](https://github.com/freeCodeCamp/freeCodeCamp/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) or [`first timers only`](https://github.com/freeCodeCamp/freeCodeCamp/issues?q=is%3Aissue+is%3Aopen+label%3A%22first+timers+only%22).'
|
||||
].join('\n') + FOOTER
|
||||
);
|
||||
}
|
||||
};
|
||||
67
.github/scripts/pr-guidelines/check-pr-template.js
vendored
Normal file
67
.github/scripts/pr-guidelines/check-pr-template.js
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
|
||||
const FOOTER =
|
||||
'\n\n---\nJoin us in our [chat room](https://discord.gg/PRyKn3Vbay) or our [forum](https://forum.freecodecamp.org/c/contributors/3) if you have any questions or need help with contributing.';
|
||||
|
||||
const TEMPLATE_BLOCK = [
|
||||
'```md',
|
||||
'Checklist:',
|
||||
'',
|
||||
'<!-- Please follow this checklist and put an x in each of the boxes, like this: [x]. It will ensure that our team takes your pull request seriously. -->',
|
||||
'',
|
||||
'- [ ] I have read and followed the [contribution guidelines](https://contribute.freecodecamp.org).',
|
||||
'- [ ] I have read and followed the [how to open a pull request guide](https://contribute.freecodecamp.org/how-to-open-a-pull-request/).',
|
||||
"- [ ] My pull request targets the `main` branch of freeCodeCamp.",
|
||||
'- [ ] I have tested these changes either locally on my machine, or GitHub Codespaces.',
|
||||
'',
|
||||
'<!--If your pull request closes a GitHub issue, replace the XXXXX below with the issue number.-->',
|
||||
'',
|
||||
'Closes #XXXXX',
|
||||
'',
|
||||
'<!-- Feel free to add any additional description of changes below this line -->',
|
||||
'```'
|
||||
].join('\n');
|
||||
|
||||
module.exports = async ({ github, context, isAllowListed }) => {
|
||||
if (isAllowListed === 'true') return;
|
||||
|
||||
const body = context.payload.pull_request.body || '';
|
||||
|
||||
// The template must be present and the first 3 checkboxes must be
|
||||
// ticked ([x] or [X]). The last checkbox (tested locally) is
|
||||
// acceptable to leave unticked.
|
||||
const templatePresent = body.includes('Checklist:');
|
||||
const requiredTicked = [
|
||||
'I have read and followed the [contribution guidelines]',
|
||||
'I have read and followed the [how to open a pull request guide]',
|
||||
'My pull request targets the'
|
||||
];
|
||||
const allRequiredTicked = requiredTicked.every(
|
||||
item => body.includes(`[x] ${item}`) || body.includes(`[X] ${item}`)
|
||||
);
|
||||
|
||||
if (templatePresent && allRequiredTicked) return;
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ['deprioritized']
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body:
|
||||
[
|
||||
'Hi there,',
|
||||
'',
|
||||
'Thank you for the contribution.',
|
||||
'',
|
||||
"Please add back the following template to the PR description and complete the checklist items. We won't be able to review this PR until then.",
|
||||
'',
|
||||
TEMPLATE_BLOCK
|
||||
].join('\n') + FOOTER
|
||||
});
|
||||
};
|
||||
76
.github/scripts/pr-guidelines/fix-pr-title.js
vendored
Normal file
76
.github/scripts/pr-guidelines/fix-pr-title.js
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
// Returns the minimum number of single-character edits (insert, delete, substitute)
|
||||
// needed to turn string `a` into string `b`.
|
||||
function levenshtein(a, b) {
|
||||
const dp = Array.from({ length: a.length + 1 }, (_, i) =>
|
||||
Array.from({ length: b.length + 1 }, (_, j) =>
|
||||
i === 0 ? j : j === 0 ? i : 0
|
||||
)
|
||||
);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
dp[i][j] =
|
||||
a[i - 1] === b[j - 1]
|
||||
? dp[i - 1][j - 1]
|
||||
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
||||
}
|
||||
}
|
||||
return dp[a.length][b.length];
|
||||
}
|
||||
|
||||
module.exports = async ({ github, context }) => {
|
||||
const title = context.payload.pull_request.title;
|
||||
const ccRegex =
|
||||
/^(feat|fix|refactor|docs|chore|build|ci|test|perf|revert)(\([^)]+\))?: .+/;
|
||||
|
||||
if (ccRegex.test(title)) return;
|
||||
|
||||
const types = [
|
||||
'feat',
|
||||
'fix',
|
||||
'refactor',
|
||||
'docs',
|
||||
'chore',
|
||||
'build',
|
||||
'ci',
|
||||
'test',
|
||||
'perf',
|
||||
'revert'
|
||||
];
|
||||
|
||||
let newTitle = title;
|
||||
|
||||
// Fix 1: space between type and scope — "feat (scope):" → "feat(scope):"
|
||||
newTitle = newTitle.replace(/^(\w+)\s+(\([^)]+\):)/, '$1$2');
|
||||
|
||||
// Fix 2: missing colon after scope — "feat(scope) desc" → "feat(scope): desc"
|
||||
newTitle = newTitle.replace(/^(\w+\([^)]+\)) ([^:])/, '$1: $2');
|
||||
|
||||
// Fix 3: typo in type — "refator(scope):" → "refactor(scope):" (distance ≤ 2)
|
||||
const typoMatch = newTitle.match(/^(\w+)(\([^)]+\))?:/);
|
||||
if (typoMatch) {
|
||||
const candidate = typoMatch[1];
|
||||
if (!types.includes(candidate)) {
|
||||
const closest = types
|
||||
.map(t => ({ t, d: levenshtein(candidate, t) }))
|
||||
.filter(x => x.d <= 2)
|
||||
.sort((a, b) => a.d - b.d)[0];
|
||||
if (closest) newTitle = newTitle.replace(candidate, closest.t);
|
||||
}
|
||||
}
|
||||
|
||||
// Catch-all: prefix with "fix: " if still not a valid CC title
|
||||
if (!ccRegex.test(newTitle)) {
|
||||
newTitle = `fix: ${title}`;
|
||||
}
|
||||
|
||||
if (newTitle !== title) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
title: newTitle
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user