diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml new file mode 100644 index 000000000..7a338a76e --- /dev/null +++ b/.github/workflows/pr-size.yml @@ -0,0 +1,166 @@ +name: PR Size + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + label: + name: Label PR size + runs-on: ubuntu-24.04 + concurrency: + group: pr-size-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + - name: Sync PR size label + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.payload.pull_request.number; + const additions = context.payload.pull_request.additions ?? 0; + const deletions = context.payload.pull_request.deletions ?? 0; + const changedLines = additions + deletions; + + const managedLabels = [ + { + name: "size:XS", + color: "0e8a16", + description: "0-9 changed lines (additions + deletions).", + }, + { + name: "size:S", + color: "5ebd3e", + description: "10-29 changed lines (additions + deletions).", + }, + { + name: "size:M", + color: "fbca04", + description: "30-99 changed lines (additions + deletions).", + }, + { + name: "size:L", + color: "fe7d37", + description: "100-499 changed lines (additions + deletions).", + }, + { + name: "size:XL", + color: "d93f0b", + description: "500-999 changed lines (additions + deletions).", + }, + { + name: "size:XXL", + color: "b60205", + description: "1,000+ changed lines (additions + deletions).", + }, + ]; + + const managedLabelNames = new Set(managedLabels.map((label) => label.name)); + + const resolveSizeLabel = (totalChangedLines) => { + if (totalChangedLines < 10) { + return "size:XS"; + } + + if (totalChangedLines < 30) { + return "size:S"; + } + + if (totalChangedLines < 100) { + return "size:M"; + } + + if (totalChangedLines < 500) { + return "size:L"; + } + + if (totalChangedLines < 1000) { + return "size:XL"; + } + + return "size:XXL"; + }; + + for (const label of managedLabels) { + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + }); + + if ( + existing.color !== label.color || + (existing.description ?? "") !== label.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + } + + const nextLabelName = resolveSizeLabel(changedLines); + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + + for (const label of currentLabels) { + if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { + continue; + } + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + if (!currentLabels.some((label) => label.name === nextLabelName)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [nextLabelName], + }); + } + + core.info(`PR #${issueNumber}: ${changedLines} changed lines -> ${nextLabelName}`); diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml index 976a9a097..350a71b6b 100644 --- a/.github/workflows/pr-vouch.yml +++ b/.github/workflows/pr-vouch.yml @@ -111,7 +111,7 @@ jobs: }, ]; - const managedLabelNames = managedLabels.map((label) => label.name); + const managedLabelNames = new Set(managedLabels.map((label) => label.name)); for (const label of managedLabels) { try { @@ -138,13 +138,19 @@ jobs: throw error; } - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } } } @@ -159,17 +165,35 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, + per_page: 100, }); - const preservedLabels = currentLabels - .map((label) => label.name) - .filter((name) => !managedLabelNames.includes(name)); + for (const label of currentLabels) { + if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { + continue; + } - await github.rest.issues.setLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [...preservedLabels, nextLabelName], - }); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + if (!currentLabels.some((label) => label.name === nextLabelName)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [nextLabelName], + }); + } core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2021ecdcb..8b734a99b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ You can still open an issue or PR, but please do so knowing there is a high chan If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control. -PRs are automatically labeled with a `vouch:*` trust status. +PRs are automatically labeled with a `vouch:*` trust status and a `size:*` diff size based on changed lines. If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td).