diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml new file mode 100644 index 0000000..5ae96f2 --- /dev/null +++ b/.github/workflows/danger.yml @@ -0,0 +1,32 @@ +name: PR Template Validation + +# pull_request_target is used (instead of pull_request) so that GITHUB_TOKEN +# has write permissions even for PRs from forks, which is required for Danger +# to post review comments. The Dangerfile is always read from the base branch +# (checked out below), so no untrusted code from the fork is executed. +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + danger: + if: ${{ github.repository_owner == 'AOSSIE-Org' }} + runs-on: ubuntu-latest + steps: + - name: Checkout base branch + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Run Danger JS + run: npx --yes danger@13.0.7 ci + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/dangerfile.js b/dangerfile.js new file mode 100644 index 0000000..be6911d --- /dev/null +++ b/dangerfile.js @@ -0,0 +1,91 @@ +// dangerfile.js — enforces the PR description template +// Docs: https://danger.systems/js/ +const body = danger.github.pr.body || ""; +const normalizedBody = body.replace(/\r\n/g, "\n"); + +const issues = []; + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function hasCheckedChecklistItem(itemText) { + const escapedItem = escapeRegex(itemText).replace(/\s+/g, "\\s+"); + const checkedItemPattern = new RegExp(`-\\s*\\[[xX]\\]\\s*${escapedItem}`, "i"); + return checkedItemPattern.test(normalizedBody); +} + +// --------------------------------------------------------------------------- +// 1. Required section headings (tolerant to spacing and casing) +// --------------------------------------------------------------------------- +const requiredSections = [ + { + label: "### Addressed Issues:", + pattern: /#{3}\s+addressed\s+issues:/i, + }, + { + label: "## Checklist", + pattern: /#{2}\s+checklist/i, + }, +]; + +const missingSections = requiredSections + .filter((section) => !section.pattern.test(normalizedBody)) + .map((section) => section.label); + +if (!normalizedBody.trim()) { + fail("PR description is empty. Please follow the PR template."); +} + +if (missingSections.length > 0) { + issues.push( + `**PR description is missing required sections:**\n` + + missingSections.map((s) => `- \`${s}\``).join("\n") + + `\n\nPlease follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md).` + ); +} + +// --------------------------------------------------------------------------- +// 2. Issue link — warn on placeholder and missing issue reference +// --------------------------------------------------------------------------- +if (/\bfixes\s*#\s*\(\s*issue\s*number\s*\)/i.test(normalizedBody)) { + issues.push( + "Please replace the placeholder `Fixes #(issue number)` with the actual " + + "issue number (e.g. `Fixes #42`)." + ); +} else if (!/\b(fixes|closes|resolves)\s*#\d+\b/i.test(normalizedBody)) { + issues.push( + "No issue linked. Consider adding `Fixes #` (e.g. `Fixes #42`) " + + "under the **Addressed Issues** section." + ); +} + +// --------------------------------------------------------------------------- +// 3. Checklist — required items must be checked +// --------------------------------------------------------------------------- +const requiredChecklistItems = [ + "My PR addresses a single issue", + "My code follows the project's code style", + "My changes generate no new warnings or errors", +]; + +const missingRequired = requiredChecklistItems.filter( + (item) => !hasCheckedChecklistItem(item) +); + +if (missingRequired.length > 0) { + issues.push( + "Some required checklist items are not completed:\n" + + missingRequired.map((item) => `- ${item}`).join("\n") + ); +} + +if (issues.length > 0) { + message(` +### ⚠️ PR Template Check + +These are non-blocking, but please fix: + +${issues.map((issue) => `- ${issue}`).join("\n")} + `); +}