diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml new file mode 100644 index 0000000..1982c05 --- /dev/null +++ b/.github/workflows/pr-size.yml @@ -0,0 +1,103 @@ +name: PR Size Check and Label + +# Uses pull_request_target so it works on fork PRs (write access to labels). +# Safe because this workflow only reads PR metadata — it never checks out untrusted code. +on: + pull_request_target: + branches: [main] + +jobs: + label-size: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Calculate PR size and apply label + uses: actions/github-script@v8 + with: + script: | + const prNumber = context.payload.pull_request.number; + const { owner, repo } = context.repo; + + // Fetch file-level stats so we can exclude generated/lock files + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: prNumber, + per_page: 100 + }); + + const EXCLUDED = [ + /package-lock\.json$/, + /yarn\.lock$/, + /pnpm-lock\.yaml$/, + /\.snap$/ + ]; + + let additions = 0; + let deletions = 0; + for (const file of files) { + if (EXCLUDED.some(re => re.test(file.filename))) continue; + additions += file.additions; + deletions += file.deletions; + } + const totalChanges = additions + deletions; + + // Remove existing size labels (catch 404 in case of race) + const labels = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber + }); + + for (const label of labels.data) { + if (label.name.startsWith('size/')) { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: label.name + }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + } + + // Determine size label + let sizeLabel; + if (totalChanges <= 20) sizeLabel = 'size/xs'; + else if (totalChanges <= 100) sizeLabel = 'size/s'; + else if (totalChanges <= 500) sizeLabel = 'size/m'; + else if (totalChanges <= 1000) sizeLabel = 'size/l'; + else sizeLabel = 'size/xl'; + + // Ensure the label exists before applying it + const LABEL_COLORS = { + 'size/xs': '3CBF00', + 'size/s': '5D9801', + 'size/m': 'FBCA04', + 'size/l': 'E67409', + 'size/xl': 'D93F0B' + }; + + try { + await github.rest.issues.getLabel({ owner, repo, name: sizeLabel }); + } catch (e) { + if (e.status === 404) { + await github.rest.issues.createLabel({ + owner, repo, + name: sizeLabel, + color: LABEL_COLORS[sizeLabel], + description: `PR size: ${sizeLabel.split('/')[1].toUpperCase()}` + }); + } else { + throw e; + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [sizeLabel] + }); + + core.info(`PR has ${totalChanges} changed lines (excluding lockfiles/snapshots) → ${sizeLabel}`); + + if (sizeLabel === 'size/xl') { + core.warning(`PR is large (${totalChanges} lines). Consider splitting into smaller PRs.`); + } diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..46fd69b --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,45 @@ +name: Validate PR Title + +on: + pull_request_target: + branches: [main] + types: [opened, edited, synchronize, reopened] + +jobs: + validate-pr-title: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Types aligned with this repo's commit conventions (see AGENTS.md) + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + requireScope: false + # Repo convention: lowercase subjects (e.g., "feat: add new command") + subjectPattern: ^[a-z].+$ + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + must start with a lowercase letter. + Example: "feat: add deploy command" + # Allow [WIP] prefix to skip validation on in-progress PRs + wip: true + # Validate the commit message when a PR has a single commit, since + # GitHub suggests using it as the merge commit message on squash-merge + validateSingleCommit: true + validateSingleCommitMatchesPrTitle: true + # Skip validation for bot/dependency PRs + ignoreLabels: | + bot + dependencies