diff --git a/.github/scripts/pr-guidelines/check-allow-list.js b/.github/scripts/pr-guidelines/check-allow-list.js new file mode 100644 index 00000000000..6005f121763 --- /dev/null +++ b/.github/scripts/pr-guidelines/check-allow-list.js @@ -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); +}; diff --git a/.github/scripts/pr-guidelines/check-linked-issue.js b/.github/scripts/pr-guidelines/check-linked-issue.js new file mode 100644 index 00000000000..5892de53b88 --- /dev/null +++ b/.github/scripts/pr-guidelines/check-linked-issue.js @@ -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 + ); + } +}; diff --git a/.github/scripts/pr-guidelines/check-pr-template.js b/.github/scripts/pr-guidelines/check-pr-template.js new file mode 100644 index 00000000000..6744603feb9 --- /dev/null +++ b/.github/scripts/pr-guidelines/check-pr-template.js @@ -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:', + '', + '', + '', + '- [ ] 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.', + '', + '', + '', + 'Closes #XXXXX', + '', + '', + '```' +].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 + }); +}; diff --git a/.github/scripts/pr-guidelines/fix-pr-title.js b/.github/scripts/pr-guidelines/fix-pr-title.js new file mode 100644 index 00000000000..ddcbbf91801 --- /dev/null +++ b/.github/scripts/pr-guidelines/fix-pr-title.js @@ -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 + }); + } +}; diff --git a/.github/workflows/github-no-web-commits.yml b/.github/workflows/github-no-web-commits.yml deleted file mode 100644 index 492b85a6ecd..00000000000 --- a/.github/workflows/github-no-web-commits.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: GitHub - No Commits on GitHub Web -on: - pull_request_target: - types: - - opened - - reopened - # The "synchronize" type may not be used because code review commits, - # from GitHub UI might be acceptable. Enable this if you want to block - # all commits from GitHub UI. - # - # - synchronize - -jobs: - has-web-commits: - runs-on: ubuntu-24.04 - steps: - - name: Check if PR author is allow-listed - id: pr_author - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const prAuthor = context.payload.pull_request.user.login; - const allowedTeams = ['moderators', 'staff']; - let isAllowListed = prAuthor === 'renovate[bot]'; - if (!isAllowListed) { - for (const team of allowedTeams) { - const response = await github.rest.teams - .getMembershipForUserInOrg({ - org: context.repo.owner, - team_slug: team, - username: prAuthor - }) - .catch(() => ({ status: 404 })); - if (response.status === 200) { - isAllowListed = true; - break; - } - } - } - core.setOutput('is_allow_listed', isAllowListed); - - - name: Check if commits are made on GitHub Web UI - id: check-commits - if: steps.pr_author.outputs.is_allow_listed == 'false' - run: | - PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") - COMMITS_URL="https://api.github.com/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" - IS_GITHUB_COMMIT=$(curl --header "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "$COMMITS_URL" | jq '[.[] | select(.commit.committer.name == "GitHub") | select(.commit.message | test("revert"; "i") | not)] | length > 0') - if [ "$IS_GITHUB_COMMIT" = "true" ]; then - echo "IS_GITHUB_COMMIT=true" >> $GITHUB_ENV - fi - - - name: Add comment on PR if commits are made on GitHub Web UI - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - if: steps.pr_author.outputs.is_allow_listed == 'false' && env.IS_GITHUB_COMMIT == 'true' - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - core.setFailed("Commits were added via the GitHub Web UI."); - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: "Thanks for your pull request.\n\n**Please do not add commits via the GitHub Web UI.**\n\nIt generally means you have yet to test these changes in a development setup or complete any prerequisites. We need you to follow the guides mentioned in the checklist. Please revalidate these changes in a developer environment and confirm how you validated your changes.\n\nHappy contributing!\n\n---\n_**Note:** This message was automatically generated by a bot. If you feel this message is in error or would like help resolving it, feel free to reach us [in our contributor chat](https://discord.gg/PRyKn3Vbay)._" - }); diff --git a/.github/workflows/github-pr-guidelines.yml b/.github/workflows/github-pr-guidelines.yml new file mode 100644 index 00000000000..b48709671cd --- /dev/null +++ b/.github/workflows/github-pr-guidelines.yml @@ -0,0 +1,137 @@ +name: GitHub - PR Contribution Guidelines + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + # Ensures PR commits were not added via the GitHub Web UI, which typically indicates + # the contributor hasn't tested their changes in a local development environment. + no-web-commits: + name: No Commits on GitHub Web + runs-on: ubuntu-24.04 + outputs: + is_allow_listed: ${{ steps.pr_author.outputs.is_allow_listed }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + sparse-checkout: .github/scripts/pr-guidelines + sparse-checkout-cone-mode: false + + - name: Check if PR author is allow-listed + id: pr_author + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + # GITHUB_TOKEN does not have the read:org permission needed for this call + # while CAMPERBOT_NO_TRANSLATE does, since it is a PAT for the camperbot account with read:org scope + github-token: ${{ secrets.CAMPERBOT_NO_TRANSLATE }} + script: | + const fn = require('./.github/scripts/pr-guidelines/check-allow-list.js'); + await fn({ github, context, core }); + + - name: Check if commits are made on GitHub Web UI + id: check-commits + if: steps.pr_author.outputs.is_allow_listed == 'false' + env: + HEAD_REF: ${{ github.head_ref }} + run: | + PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") + COMMITS_URL="https://api.github.com/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" + HAS_GITHUB_SIGNED_COMMIT=$(curl --header "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "$COMMITS_URL" | jq '[.[] | select(.commit.committer.name == "GitHub") | select(.commit.message | test("revert"; "i") | not)] | length > 0') + # GitHub Codespaces also produces GitHub-signed commits, but Codespaces users + # work on descriptively named branches. The web editor defaults to patch-N branches. + IS_PATCH_BRANCH=false + if [[ "$HEAD_REF" =~ ^patch-[0-9]+$ ]]; then + IS_PATCH_BRANCH=true + fi + if [ "$HAS_GITHUB_SIGNED_COMMIT" = "true" ] && [ "$IS_PATCH_BRANCH" = "true" ]; then + echo "IS_GITHUB_COMMIT=true" >> $GITHUB_ENV + fi + + - name: Add comment on PR if commits are made on GitHub Web UI + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + if: steps.pr_author.outputs.is_allow_listed == 'false' && env.IS_GITHUB_COMMIT == 'true' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + core.setFailed("Commits were added via the GitHub Web UI."); + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "Thanks for your pull request.\n\n**Please do not add commits via the GitHub Web UI.**\n\nIt generally means you have yet to test these changes in a development setup or complete any prerequisites. We need you to follow the guides mentioned in the checklist. Please revalidate these changes in a developer environment and confirm how you validated your changes.\n\nHappy contributing!\n\n---\n_**Note:** This message was automatically generated by a bot. If you feel this message is in error or would like help resolving it, feel free to reach us [in our contributor chat](https://discord.gg/PRyKn3Vbay)._" + }); + + - name: Add deprioritized label + if: failure() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['deprioritized'] + }); + + # Normalizes PR titles to follow Conventional Commits format, applying fuzzy fixes + # for common mistakes like typos, missing colons, or incorrect spacing. + fix-pr-title: + name: Fix PR Title + runs-on: ubuntu-24.04 + needs: no-web-commits + if: needs.no-web-commits.result == 'success' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + sparse-checkout: .github/scripts/pr-guidelines + sparse-checkout-cone-mode: false + + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fn = require('./.github/scripts/pr-guidelines/fix-pr-title.js'); + await fn({ github, context }); + + # Checks that the PR description still contains the required template. + # The first 3 checkboxes must be ticked ([x] or [X]). + # The last checkbox (tested locally) is acceptable to leave unticked + # but removing the entire template is not. + check-pr-template: + name: Check PR Template + runs-on: ubuntu-24.04 + needs: no-web-commits + if: needs.no-web-commits.result == 'success' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + sparse-checkout: .github/scripts/pr-guidelines + sparse-checkout-cone-mode: false + + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fn = require('./.github/scripts/pr-guidelines/check-pr-template.js'); + await fn({ github, context, isAllowListed: '${{ needs.no-web-commits.outputs.is_allow_listed }}' }); + + # Verifies that each PR references a linked, triaged issue before it can be reviewed. + check-linked-issue: + name: Check Linked Issue + runs-on: ubuntu-24.04 + needs: no-web-commits + if: needs.no-web-commits.result == 'success' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + sparse-checkout: .github/scripts/pr-guidelines + sparse-checkout-cone-mode: false + + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fn = require('./.github/scripts/pr-guidelines/check-linked-issue.js'); + await fn({ github, context, isAllowListed: '${{ needs.no-web-commits.outputs.is_allow_listed }}' });