From 88044e699034f2441b5c4cb7fff8e87054c49444 Mon Sep 17 00:00:00 2001 From: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:02:21 +0530 Subject: [PATCH] fix(gha): consolidate PR check comments into single report (#66869) --- .../pr-guidelines/check-linked-issue.js | 55 ++------- .../pr-guidelines/check-pr-template.js | 70 +----------- .github/scripts/pr-guidelines/fix-pr-title.js | 5 +- .../scripts/pr-guidelines/report-results.js | 107 ++++++++++++++++++ .github/workflows/github-autoclose.yml | 2 +- .github/workflows/github-lock-closed-prs.yml | 21 ++++ .github/workflows/github-no-i18n-via-prs.yml | 2 +- .github/workflows/github-pr-guidelines.yml | 43 ++++++- .github/workflows/github-spam.yml | 19 ++-- .github/workflows/i18n-validate-prs.yml | 2 +- 10 files changed, 197 insertions(+), 129 deletions(-) create mode 100644 .github/scripts/pr-guidelines/report-results.js create mode 100644 .github/workflows/github-lock-closed-prs.yml diff --git a/.github/scripts/pr-guidelines/check-linked-issue.js b/.github/scripts/pr-guidelines/check-linked-issue.js index b57239a33c0..0bed018e63e 100644 --- a/.github/scripts/pr-guidelines/check-linked-issue.js +++ b/.github/scripts/pr-guidelines/check-linked-issue.js @@ -1,8 +1,5 @@ '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, @@ -12,16 +9,7 @@ async function addDeprioritizedLabel(github, context) { }); } -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 }) => { +module.exports = async ({ github, context, core, isAllowListed }) => { if (isAllowListed === 'true') return; const result = await github.graphql( @@ -51,20 +39,9 @@ module.exports = async ({ github, context, isAllowListed }) => { const linkedIssues = pr.closingIssuesReferences.nodes; if (linkedIssues.length === 0) { + core.setOutput('failure_reason', 'no_linked_issue'); + core.setFailed('No linked issue found.'); 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; } @@ -72,18 +49,9 @@ module.exports = async ({ github, context, isAllowListed }) => { issue.labels.nodes.some(l => l.name === 'status: waiting triage') ); if (hasWaitingTriage) { + core.setOutput('failure_reason', 'waiting_triage'); + core.setFailed('Linked issue has not been triaged yet.'); 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; } @@ -109,17 +77,8 @@ module.exports = async ({ github, context, isAllowListed }) => { ) ); if (!isOpenForContribution) { + core.setOutput('failure_reason', 'not_open_for_contribution'); + core.setFailed('Linked issue is not open for contribution.'); 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 index 52515b982e1..1637f5159b8 100644 --- a/.github/scripts/pr-guidelines/check-pr-template.js +++ b/.github/scripts/pr-guidelines/check-pr-template.js @@ -1,32 +1,9 @@ '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 }) => { +module.exports = async ({ github, context, core, isAllowListed }) => { if (isAllowListed === 'true') return; const body = (context.payload.pull_request.body || '').toLowerCase(); - const action = context.payload.action; // The template must be present and the first 3 checkboxes must be // ticked. The last checkbox (tested locally) is acceptable to leave @@ -46,25 +23,12 @@ module.exports = async ({ github, context, isAllowListed }) => { normalizedBody.includes(`[x] ${item}`) ); - if (templatePresent && allRequiredTicked) { - // On edit, remove the deprioritized label if the check now passes. - if (action === 'edited') { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - name: 'deprioritized' - }); - } catch { - // Label may not exist — ignore. - } - } - return; - } + if (templatePresent && allRequiredTicked) return; - // On edit, don't re-comment — the original comment is already there. - if (action === 'edited') return; + core.setOutput('failure_reason', 'incomplete_checklist'); + core.setFailed( + 'PR description is missing the required checklist or some items are incomplete.' + ); await github.rest.issues.addLabels({ owner: context.repo.owner, @@ -72,26 +36,4 @@ module.exports = async ({ github, context, isAllowListed }) => { 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.', - '', - 'The automated checks found a few issues with the PR. Currently the PR description is missing the required checklist or some of its items are not completed:', - '', - '1. The `Checklist:` heading is present in the PR description.', - '2. The checkbox items are ticked (changed from `[ ]` to `[x]`).', - '3. You have actually completed the items in the checklist.', - '', - 'Please edit your PR description to include the following template with the checklist items completed.', - '', - 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 index ddcbbf91801..08f00c96d28 100644 --- a/.github/scripts/pr-guidelines/fix-pr-title.js +++ b/.github/scripts/pr-guidelines/fix-pr-title.js @@ -60,9 +60,12 @@ module.exports = async ({ github, context }) => { } } + // Fix 4: missing space after colon — "fix:desc" → "fix: desc" + newTitle = newTitle.replace(/^(\w+(?:\([^)]+\))?):(\S)/, '$1: $2'); + // Catch-all: prefix with "fix: " if still not a valid CC title if (!ccRegex.test(newTitle)) { - newTitle = `fix: ${title}`; + newTitle = `fix: ${newTitle}`; } if (newTitle !== title) { diff --git a/.github/scripts/pr-guidelines/report-results.js b/.github/scripts/pr-guidelines/report-results.js new file mode 100644 index 00000000000..73a7565185d --- /dev/null +++ b/.github/scripts/pr-guidelines/report-results.js @@ -0,0 +1,107 @@ +'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'); + +const MESSAGES = { + incomplete_checklist: [ + '**Checklist:** The PR description is missing the required checklist or some of its items are not completed:', + '', + '1. The `Checklist:` heading is present in the PR description.', + '2. The checkbox items are ticked (changed from `[ ]` to `[x]`).', + '3. You have actually completed the items in the checklist.', + '', + 'Please edit your PR description to include the following template with the checklist items completed.', + '', + TEMPLATE_BLOCK + ].join('\n'), + + no_linked_issue: [ + '**Linked Issue:** 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'), + + waiting_triage: [ + '**Linked Issue:** 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'), + + not_open_for_contribution: + '**Linked Issue:** 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).' +}; + +module.exports = async ({ + github, + context, + templateResult, + templateReason, + linkedIssueResult, + linkedIssueReason +}) => { + const allPassed = + templateResult === 'success' && linkedIssueResult === 'success'; + + if (allPassed) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'deprioritized' + }); + } catch { + // Label may not exist — ignore. + } + return; + } + + // On edit, don't re-comment — the original comment is already there. + if (context.payload.action === 'edited') return; + + const sections = []; + if (templateResult === 'failure' && MESSAGES[templateReason]) { + sections.push(MESSAGES[templateReason]); + } + if (linkedIssueResult === 'failure' && MESSAGES[linkedIssueReason]) { + sections.push(MESSAGES[linkedIssueReason]); + } + + if (sections.length === 0) return; + + const body = + [ + 'Hi there,', + '', + 'Thanks for opening this pull request.', + '', + 'The automated checks found some issues:', + '', + sections.join('\n\n') + ].join('\n') + FOOTER; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); +}; diff --git a/.github/workflows/github-autoclose.yml b/.github/workflows/github-autoclose.yml index 7414edb9bdf..7af2c6ec2e9 100644 --- a/.github/workflows/github-autoclose.yml +++ b/.github/workflows/github-autoclose.yml @@ -51,7 +51,7 @@ jobs: } } core.setFailed("Invalid PR detected."); - github.rest.issues.createComment({ + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/github-lock-closed-prs.yml b/.github/workflows/github-lock-closed-prs.yml new file mode 100644 index 00000000000..5ae4973015c --- /dev/null +++ b/.github/workflows/github-lock-closed-prs.yml @@ -0,0 +1,21 @@ +name: GitHub - Lock Closed PRs + +on: + pull_request_target: + types: [closed] + +jobs: + lock: + name: Lock Closed PR + runs-on: ubuntu-24.04 + steps: + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + lock_reason: 'resolved' + }); diff --git a/.github/workflows/github-no-i18n-via-prs.yml b/.github/workflows/github-no-i18n-via-prs.yml index d7ffcc177b3..ddd2f5f519d 100644 --- a/.github/workflows/github-no-i18n-via-prs.yml +++ b/.github/workflows/github-no-i18n-via-prs.yml @@ -23,7 +23,7 @@ jobs: }).catch(() => ({status: 404})); if (context.payload.pull_request.user.login !== "camperbot" && isDev.status !== 200) { core.setFailed('This PR appears to touch translated curriculum files.') - github.rest.issues.createComment({ + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/github-pr-guidelines.yml b/.github/workflows/github-pr-guidelines.yml index a259fb05928..cf22135be50 100644 --- a/.github/workflows/github-pr-guidelines.yml +++ b/.github/workflows/github-pr-guidelines.yml @@ -104,25 +104,51 @@ jobs: runs-on: ubuntu-24.04 needs: no-web-commits if: needs.no-web-commits.result == 'success' + outputs: + failure_reason: ${{ steps.check.outputs.failure_reason }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: sparse-checkout: .github/scripts/pr-guidelines sparse-checkout-cone-mode: false - - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - id: check + 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 }}' }); + await fn({ github, context, core, 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' && github.event.action != 'edited' + if: needs.no-web-commits.result == 'success' + outputs: + failure_reason: ${{ steps.check.outputs.failure_reason }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + sparse-checkout: .github/scripts/pr-guidelines + sparse-checkout-cone-mode: false + + - id: check + 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, core, isAllowListed: '${{ needs.no-web-commits.outputs.is_allow_listed }}' }); + + # Coordinates reporting: posts a single combined comment when checks fail, + # or removes the deprioritized label when all checks pass. + report: + name: Report + runs-on: ubuntu-24.04 + needs: [no-web-commits, check-pr-template, check-linked-issue] + if: always() && needs.no-web-commits.result == 'success' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -133,5 +159,12 @@ jobs: 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 }}' }); + const fn = require('./.github/scripts/pr-guidelines/report-results.js'); + await fn({ + github, + context, + templateResult: '${{ needs.check-pr-template.result }}', + templateReason: '${{ needs.check-pr-template.outputs.failure_reason }}', + linkedIssueResult: '${{ needs.check-linked-issue.result }}', + linkedIssueReason: '${{ needs.check-linked-issue.outputs.failure_reason }}' + }); diff --git a/.github/workflows/github-spam.yml b/.github/workflows/github-spam.yml index 29410d66572..6f797d8ae49 100644 --- a/.github/workflows/github-spam.yml +++ b/.github/workflows/github-spam.yml @@ -12,12 +12,15 @@ jobs: with: github-token: ${{secrets.CAMPERBOT_NO_TRANSLATE}} script: | - const isSpam = context.payload.pull_request.labels.find(label => label.name === "spam"); - if (isSpam) { - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: "We are marking this pull request as spam. Please note that if you are participating in Hacktoberfest, two or more PRs marked as spam will result in your permanent disqualification.\n\nIf you are interested in making quality and genuine contributions to our projects, check out our [contributing guidelines](https://contribute.freecodecamp.org)." - }) + if (context.payload.label.name === "spam") { + try { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "We are marking this pull request as spam. Please note that if you are participating in Hacktoberfest, two or more PRs marked as spam will result in your permanent disqualification.\n\nIf you are interested in making quality and genuine contributions to our projects, check out our [contributing guidelines](https://contribute.freecodecamp.org)." + }); + } catch { + // Conversation may already be locked — ignore. + } } diff --git a/.github/workflows/i18n-validate-prs.yml b/.github/workflows/i18n-validate-prs.yml index 34614a23ac7..bccea4c868d 100644 --- a/.github/workflows/i18n-validate-prs.yml +++ b/.github/workflows/i18n-validate-prs.yml @@ -53,7 +53,7 @@ jobs: with: github-token: ${{secrets.CAMPERBOT_NO_TRANSLATE}} script: | - github.rest.issues.createComment({ + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo,