diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..1322963 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,354 @@ +# cozystack-ui repository labels +# +# Label conventions follow the Kubernetes scheme: +# https://github.com/kubernetes/test-infra/blob/master/label_sync/labels.md +# +# Synced into the repository by .github/workflows/labels.yaml +# (EndBug/label-sync@v2). Edit this file via pull request — UI changes +# will be overwritten on the next sync. +# +# Constraints (enforced by the validate job in labels.yaml): +# - description ≤ 100 characters (GitHub REST API limit) +# - color is a 6-character hex string (no leading #) +# - label names are unique +# - aliases do not collide with top-level names +# +# Categories: +# kind/ issue or PR type +# priority/ urgency +# triage/ review state +# lifecycle/ issue or PR lifecycle +# area/ subsystem; extensible — add when 3+ open issues exist +# do-not-merge/ PR merge blockers +# security/ security-finding severity and status +# size/ PR size (auto-applied) +# +# `aliases:` lets EndBug/label-sync rename existing labels without losing +# references on already-tagged issues and PRs. +# +# size/* line-count thresholds in the descriptions below are the contract +# the .github/workflows/pr-size.yaml bucket logic encodes. Editing one and +# not the other will drift the label off what the workflow actually applies +# — keep them in lockstep. + +# ────────────────────────────────────────────── +# kind/ — issue or PR type +# ────────────────────────────────────────────── + +- name: kind/bug + color: 'd73a4a' + description: Categorizes issue or PR as related to a bug + aliases: ['bug'] + +- name: kind/feature + color: 'a2eeef' + description: Categorizes issue or PR as related to a new feature + aliases: ['enhancement'] + +- name: kind/documentation + color: '0075ca' + description: Categorizes issue or PR as related to documentation + aliases: ['documentation'] + +- name: kind/support + color: 'd876e3' + description: Categorizes issue as a support question + aliases: ['question'] + +- name: kind/cleanup + color: 'c7def8' + description: Categorizes issue or PR as related to cleanup of code, process, or technical debt + +- name: kind/regression + color: 'e11d21' + description: Categorizes issue or PR as related to a regression from a prior release + +- name: kind/flake + color: 'f7c6c7' + description: Categorizes issue or PR as related to a flaky test + +- name: kind/failing-test + color: 'e11d21' + description: Categorizes issue or PR as related to a consistently or frequently failing test + +- name: kind/api-change + color: 'c7def8' + description: Categorizes issue or PR as related to adding, removing, or otherwise changing an API + +- name: kind/breaking-change + color: 'e11d21' + description: Indicates the change introduces a breaking API or behaviour change + +# ────────────────────────────────────────────── +# priority/ — urgency +# ────────────────────────────────────────────── + +- name: priority/critical-urgent + color: 'e11d21' + description: Highest priority. Must be actively worked on as someone's top priority right now + +- name: priority/important-soon + color: 'eb6420' + description: Must be staffed and worked on either currently, or very soon, ideally in time for the next release + +- name: priority/important-longterm + color: 'fbca04' + description: Important over the long term, but may not be staffed and/or may need multiple releases to complete + +- name: priority/backlog + color: 'fef2c0' + description: General backlog priority. Lower than priority/important-longterm + +# ────────────────────────────────────────────── +# triage/ — review state +# ────────────────────────────────────────────── + +- name: triage/needs-triage + color: 'ededed' + description: Indicates an issue needs triage by a maintainer + +- name: triage/accepted + color: '0e8a16' + description: Indicates an issue is ready to be actively worked on + +- name: triage/needs-information + color: 'fbca04' + description: Indicates an issue needs more information in order to work on it + +- name: triage/not-reproducible + color: 'fbca04' + description: Indicates an issue can not be reproduced as described + +- name: triage/duplicate + color: 'cfd3d7' + description: Indicates an issue is a duplicate of another issue + aliases: ['duplicate'] + +- name: triage/unresolved + color: 'cfd3d7' + description: Indicates an issue that can not or will not be resolved + +# ────────────────────────────────────────────── +# lifecycle/ — issue or PR lifecycle +# ────────────────────────────────────────────── + +- name: lifecycle/active + color: '1d76db' + description: Indicates that an issue or PR is actively being worked on by a contributor + +- name: lifecycle/frozen + color: 'db5dd6' + description: Marks an issue or PR as kept alive regardless of inactivity; manual marker only + aliases: ['frozen'] + +- name: lifecycle/stale + color: 'dadada' + description: Denotes an issue or PR that has remained open with no activity; manual marker only + aliases: ['stale'] + +- name: lifecycle/rotten + color: '795548' + description: Denotes an issue or PR that has aged beyond stale; manual marker only + +# ────────────────────────────────────────────── +# area/ — subsystem (extensible) +# Add a new area/* when there are 3+ open issues on the topic. +# ────────────────────────────────────────────── + +- name: area/console + color: 'bfd4f2' + description: Issues or PRs related to apps/console — routes, detail pages, marketplace, command palette + +- name: area/forms + color: 'bfd4f2' + description: Issues or PRs related to RJSF schema forms and widgets (backup, external-ips, storage-class, etc.) + +- name: area/k8s-client + color: 'bfd4f2' + description: Issues or PRs related to packages/k8s-client — K8sClient, hooks, watch layer + +- name: area/ui + color: 'bfd4f2' + description: Issues or PRs related to packages/ui — AppShell, Sidebar, Header, Button, primitives + +- name: area/types + color: 'bfd4f2' + description: Issues or PRs related to packages/types — shared Kubernetes resource types + +- name: area/tenants + color: 'bfd4f2' + description: Issues or PRs related to tenant context, tenant-namespace scoping, multi-tenancy + +- name: area/auth + color: 'bfd4f2' + description: Issues or PRs related to authentication, oauth2-proxy integration, userinfo + +- name: area/vm + color: 'bfd4f2' + description: Issues or PRs related to virtual machines — VNC console, VM tabs, kubevirt integration + +- name: area/container + color: 'bfd4f2' + description: Issues or PRs related to the container image build, Containerfile, nginx configuration + +- name: area/ci + color: 'bfd4f2' + description: Issues or PRs related to CI workflows, GitHub Actions, automation + +- name: area/docs + color: 'bfd4f2' + description: Issues or PRs related to documentation — README, CLAUDE.md, AGENTS.md, contributor guides + +- name: area/tests + color: 'bfd4f2' + description: Issues or PRs related to testing infrastructure — vitest, jsdom, testing-library + +- name: area/uncategorized + color: 'fbca04' + description: PR auto-labeler could not map title scope to a known area/*; please review + +# ────────────────────────────────────────────── +# do-not-merge/ — PR merge blockers (Prow convention) +# ────────────────────────────────────────────── + +- name: do-not-merge/work-in-progress + color: 'e11d21' + description: Indicates that a PR should not merge because it is a work in progress + +- name: do-not-merge/hold + color: 'e11d21' + description: Indicates that a PR should not merge because someone has issued /hold + +# ────────────────────────────────────────────── +# Cross-cutting / preserved +# ────────────────────────────────────────────── + +- name: epic + color: 'a335ee' + description: A large development increment that brings definite value to Cozystack users + +- name: community + color: '97458a' + description: Community contributions are welcome in this issue + +- name: help wanted + color: '008672' + description: Extra attention is needed + +- name: good first issue + color: '7057ff' + description: Good for newcomers + +- name: quality-of-life + color: 'aaaaaa' + description: QoL improvements + +- name: upstream-issue + color: 'aaaaaa' + description: Requires resolving an issue in an upstream project + +- name: release + color: 'aaaaaa' + description: Releasing a new cozystack-ui version + +- name: automated + color: 'ededed' + description: Created by automation + +- name: debug + color: '704479' + description: Debugging in progress + +- name: sponsored + color: '00ff00' + description: Sponsored work + +- name: lgtm + color: '238636' + description: Manual marker that a reviewer has signed off; informational — does not gate merge + +- name: ok-to-test + color: '00ff00' + description: Indicates a non-member PR is safe to run CI on + +# ────────────────────────────────────────────── +# size/ — PR size (auto-applied by .github/workflows/pr-size.yaml) +# ────────────────────────────────────────────── + +- name: size/XS + color: '00ff00' + description: This PR changes 0-9 lines, ignoring generated files + aliases: ['size:XS'] + +- name: size/S + color: '77b800' + description: This PR changes 10-29 lines, ignoring generated files + aliases: ['size:S'] + +- name: size/M + color: 'ebb800' + description: This PR changes 30-99 lines, ignoring generated files + aliases: ['size:M'] + +- name: size/L + color: 'eb9500' + description: This PR changes 100-499 lines, ignoring generated files + aliases: ['size:L'] + +- name: size/XL + color: 'ff823f' + description: This PR changes 500-999 lines, ignoring generated files + aliases: ['size:XL'] + +- name: size/XXL + color: 'ffb8b8' + description: This PR changes 1000+ lines, ignoring generated files + aliases: ['size:XXL'] + +# ────────────────────────────────────────────── +# security/ — security-finding severity and status +# ────────────────────────────────────────────── + +- name: security + color: 'aaaaaa' + description: Security-related issues and features + +- name: security/critical + color: 'd73a4a' + description: Critical security vulnerability + +- name: security/high + color: 'e99695' + description: High severity security finding + +- name: security/medium + color: 'f9c513' + description: Medium severity security finding + +- name: security/low + color: '0e8a16' + description: Low severity security finding + +- name: security/triage-needed + color: 'fbca04' + description: Needs security triage + +- name: security/confirmed + color: '1d76db' + description: Confirmed vulnerability + +- name: security/false-positive + color: 'c5def5' + description: Triaged as false positive + +- name: security/accepted-risk + color: 'bfd4f2' + description: Risk accepted with justification + +- name: security/in-progress + color: '0075ca' + description: Fix in progress + +- name: security/fixed + color: '0e8a16' + description: Fix released diff --git a/.github/scripts/pr-labeler.js b/.github/scripts/pr-labeler.js new file mode 100644 index 0000000..7186102 --- /dev/null +++ b/.github/scripts/pr-labeler.js @@ -0,0 +1,208 @@ +// Pure label-derivation logic for .github/workflows/pr-labeler.yaml. +// Kept as a standalone module so it can be unit-tested with `node --test` +// (.github/scripts/pr-labeler.test.js) without spinning up the workflow. + +// Conventional Commits types accepted by cozystack-ui: +// feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert +// Mapping below maps a subset to kind/* — types not listed do not produce a kind/*. +export const typeToKind = { + feat: 'kind/feature', + fix: 'kind/bug', + docs: 'kind/documentation', + chore: 'kind/cleanup', + refactor: 'kind/cleanup', + // style, perf, test, build, ci, revert — no kind mapping +} + +// scope -> area/* mapping. Keys are the scopes observed in cozystack-ui issues +// and PRs. Add new entries when a scope recurs (3+ times). +export const scopeToArea = { + // area/console — apps/console: routes, detail pages, marketplace, + // command palette, top-level app shell wiring. + 'console': 'area/console', + 'app': 'area/console', + 'routes': 'area/console', + 'detail': 'area/console', + 'overview': 'area/console', + 'marketplace': 'area/console', + 'command-palette': 'area/console', + 'palette': 'area/console', + + // area/forms — RJSF schema forms and the per-field widgets that back them. + // Specific widget names route here too. + 'forms': 'area/forms', + 'form': 'area/forms', + 'schema': 'area/forms', + 'schema-form': 'area/forms', + 'rjsf': 'area/forms', + 'widgets': 'area/forms', + 'widget': 'area/forms', + 'backup': 'area/forms', + 'backups': 'area/forms', + 'external-ips': 'area/forms', + 'storage-class': 'area/forms', + 'storageclass': 'area/forms', + 'vm-disk': 'area/forms', + 'vmdisk': 'area/forms', + 'sensitive': 'area/forms', + 'quota': 'area/forms', + 'quotas': 'area/forms', + 'source': 'area/forms', + 'additional-properties': 'area/forms', + 'key-value': 'area/forms', + + // area/k8s-client — packages/k8s-client: K8sClient, React Query hooks, + // the watch layer. + 'k8s-client': 'area/k8s-client', + 'k8s': 'area/k8s-client', + 'client': 'area/k8s-client', + 'watch': 'area/k8s-client', + + // area/ui — packages/ui: AppShell, Sidebar, Header, Button, Dropdown, + // StatusBadge, Spinner, Section, primitives. + 'ui': 'area/ui', + 'app-shell': 'area/ui', + 'appshell': 'area/ui', + 'sidebar': 'area/ui', + 'header': 'area/ui', + 'button': 'area/ui', + 'dropdown': 'area/ui', + 'spinner': 'area/ui', + 'status-badge': 'area/ui', + + // area/types — packages/types: shared Kubernetes resource types. + 'types': 'area/types', + + // area/tenants — TenantContext, tenant-namespace scoping. + 'tenant': 'area/tenants', + 'tenants': 'area/tenants', + + // area/auth — oauth2-proxy integration, userinfo, cookies. + 'auth': 'area/auth', + 'oauth': 'area/auth', + 'oauth2': 'area/auth', + 'oauth2-proxy': 'area/auth', + 'userinfo': 'area/auth', + + // area/vm — VNC console, VM-specific detail tabs. + 'vm': 'area/vm', + 'vmi': 'area/vm', + 'vnc': 'area/vm', + 'kubevirt': 'area/vm', + + // area/container — Containerfile, nginx, image build. + 'container': 'area/container', + 'containerfile': 'area/container', + 'dockerfile': 'area/container', + 'nginx': 'area/container', + 'image': 'area/container', + + // area/ci — GitHub Actions workflows, automation. + 'ci': 'area/ci', + 'workflows': 'area/ci', + 'actions': 'area/ci', + + // area/docs — README, CLAUDE.md, AGENTS.md, contributor docs. + 'docs': 'area/docs', + 'readme': 'area/docs', + 'claude-md': 'area/docs', + 'agents-md': 'area/docs', + + // area/tests — vitest, jsdom, testing-library wiring. + 'tests': 'area/tests', + 'test': 'area/tests', + 'vitest': 'area/tests', +} + +// computeLabels returns { add, remove, warnings } given the current PR title, +// body, and existing labels. The set of labels the labeler may remove is +// intentionally narrow — only labels the labeler itself is authoritative for, +// never maintainer-added labels: +// +// - `area/uncategorized` — only set as fallback; remove once a real +// area/* is derived from the title. +// - `kind/breaking-change` — set from the conventional-commit `!` marker +// or a `BREAKING CHANGE:` footer; remove when +// neither signal is present anymore. +// +// Anything else (maintainer-added `area/*`, manual `kind/*`, anything +// outside these two namespaces) is preserved on every run. +export function computeLabels({ title = '', body = '', existingLabels = [] } = {}) { + const existing = new Set(existingLabels) + const toAdd = new Set() + + // 1. Try Conventional Commits form: type(scope)?(!)?: description + // Inner scope group accepts empty content so that `feat():` still parses + // as type=feat with no scope (instead of falling through to the bracket + // form and producing area/uncategorized but also no kind/*). + const conv = title.match(/^([a-z]+)(?:\(([^)]*)\))?(!)?:\s*.+$/) + // 2. Fall back to bracket form: [scope] description + const bracket = !conv && title.match(/^\[([^\]]+)\]\s+.+$/) + + let type = null + let scopeStr = null + let breaking = false + const warnings = [] + + if (conv) { + type = conv[1] + scopeStr = conv[2] || null + breaking = !!conv[3] + } else if (bracket) { + scopeStr = bracket[1] + } + + // 3. Detect BREAKING CHANGE: or BREAKING-CHANGE: footer in body. + // Conventional Commits 1.0 spec item 16 treats them as synonymous. + if (/^BREAKING[ -]CHANGE:/m.test(body)) { + breaking = true + } + + // 4. Apply kind/* from type. + if (type) { + if (typeToKind[type]) { + toAdd.add(typeToKind[type]) + } else { + warnings.push(`type "${type}" has no kind/* mapping — typo or new type? See .github/scripts/pr-labeler.js typeToKind`) + } + } + + // 5. Apply area/* from scope. Composite scopes split on comma. + const scopes = (scopeStr || '') + .split(/,\s*/) + .map((s) => s.trim()) + .filter(Boolean) + for (const s of scopes) { + if (scopeToArea[s]) { + toAdd.add(scopeToArea[s]) + } else { + warnings.push(`scope "${s}" has no area/* mapping — consider extending scopeToArea in .github/scripts/pr-labeler.js if it recurs`) + } + } + + // 6. kind/breaking-change. + if (breaking) { + toAdd.add('kind/breaking-change') + } + + // 7. Fallback: no area/* applied -> area/uncategorized. + const hasArea = [...toAdd].some((l) => l.startsWith('area/')) + if (!hasArea) { + toAdd.add('area/uncategorized') + } + + // 8. Compute removals — only labels the labeler is authoritative for. + const toRemove = new Set() + if (hasArea && existing.has('area/uncategorized')) { + toRemove.add('area/uncategorized') + } + if (!breaking && existing.has('kind/breaking-change')) { + toRemove.add('kind/breaking-change') + } + + // 9. Additive over existing. + const add = [...toAdd].filter((l) => !existing.has(l)) + const remove = [...toRemove] + + return { add, remove, warnings } +} diff --git a/.github/scripts/pr-labeler.test.js b/.github/scripts/pr-labeler.test.js new file mode 100644 index 0000000..2cc49c6 --- /dev/null +++ b/.github/scripts/pr-labeler.test.js @@ -0,0 +1,162 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { computeLabels } from './pr-labeler.js' + +test('conventional commit type → kind/*', () => { + const { add } = computeLabels({ title: 'feat(console): add foo' }) + assert.ok(add.includes('kind/feature')) + assert.ok(add.includes('area/console')) +}) + +test('scope maps to area/*', () => { + const { add } = computeLabels({ title: 'fix(backup): cleanup' }) + assert.ok(add.includes('kind/bug')) + assert.ok(add.includes('area/forms')) +}) + +test('composite scope splits on comma', () => { + const { add } = computeLabels({ title: 'feat(ui, forms): combo' }) + assert.ok(add.includes('area/ui')) + assert.ok(add.includes('area/forms')) +}) + +test('bracket form: [scope] description', () => { + const { add } = computeLabels({ title: '[ci] tweak workflow' }) + assert.ok(add.includes('area/ci')) +}) + +test('no scope → area/uncategorized fallback', () => { + const { add } = computeLabels({ title: 'chore: housekeeping' }) + assert.ok(add.includes('kind/cleanup')) + assert.ok(add.includes('area/uncategorized')) +}) + +test('breaking change via ! marker', () => { + const { add } = computeLabels({ title: 'feat(api)!: drop legacy' }) + assert.ok(add.includes('kind/breaking-change')) +}) + +test('breaking change via BREAKING CHANGE: body footer', () => { + const { add } = computeLabels({ + title: 'feat(api): refactor', + body: 'Some description\n\nBREAKING CHANGE: removed old endpoint', + }) + assert.ok(add.includes('kind/breaking-change')) +}) + +test('breaking change via BREAKING-CHANGE: footer (Conventional Commits 1.0 #16)', () => { + const { add } = computeLabels({ + title: 'feat: x', + body: 'BREAKING-CHANGE: synonymous spelling', + }) + assert.ok(add.includes('kind/breaking-change')) +}) + +test('unknown type emits warning, no kind/*', () => { + const { add, warnings } = computeLabels({ title: 'wat(ui): mystery type' }) + assert.ok(!add.some((l) => l.startsWith('kind/'))) + assert.ok(warnings.some((w) => w.includes('"wat"'))) +}) + +test('unknown scope emits warning, falls back to area/uncategorized', () => { + const { add, warnings } = computeLabels({ title: 'feat(unknown-scope): x' }) + assert.ok(add.includes('area/uncategorized')) + assert.ok(warnings.some((w) => w.includes('"unknown-scope"'))) +}) + +test('additive only — already-present labels not re-added', () => { + const { add } = computeLabels({ + title: 'feat(console): existing', + existingLabels: ['kind/feature', 'area/console'], + }) + assert.deepEqual(add, []) +}) + +// ── Stale-label removal tests (the regression that triggered the rewrite) ── + +test('retitle: chore: foo → chore(ci): foo strips area/uncategorized', () => { + const { add, remove } = computeLabels({ + title: 'chore(ci): pin pnpm', + existingLabels: ['kind/cleanup', 'area/uncategorized'], + }) + assert.ok(add.includes('area/ci')) + assert.ok(remove.includes('area/uncategorized')) +}) + +test('retitle: real scope added — maintainer-added area/* is preserved', () => { + // Maintainer added area/forms manually; title later gains area/ci. Both + // legitimately apply (the change touches both areas) so neither is removed. + const { add, remove } = computeLabels({ + title: 'chore(ci): bump workflow', + existingLabels: ['area/forms'], + }) + assert.ok(add.includes('area/ci')) + assert.ok(!remove.includes('area/forms')) +}) + +test('retitle: feat!: x → feat: x strips kind/breaking-change', () => { + const { remove } = computeLabels({ + title: 'feat(api): refactor', + body: '', + existingLabels: ['kind/feature', 'area/uncategorized', 'kind/breaking-change'], + }) + assert.ok(remove.includes('kind/breaking-change')) +}) + +test('retitle: still breaking — kind/breaking-change preserved', () => { + const { add, remove } = computeLabels({ + title: 'feat(api)!: still breaking', + existingLabels: ['kind/breaking-change'], + }) + assert.ok(!remove.includes('kind/breaking-change')) + // Already present, not re-added. + assert.ok(!add.includes('kind/breaking-change')) +}) + +test('retitle does not strip area/uncategorized while still uncategorized', () => { + // Title has no scope — area/uncategorized is still the right state. + const { add, remove } = computeLabels({ + title: 'chore: housekeeping', + existingLabels: ['kind/cleanup', 'area/uncategorized'], + }) + assert.ok(!remove.includes('area/uncategorized')) + assert.deepEqual(add, []) +}) + +test('plural scope: feat(backups) maps to area/forms', () => { + const { add } = computeLabels({ title: 'feat(backups): polish' }) + assert.ok(add.includes('area/forms')) +}) + +test('overview scope maps to area/console', () => { + const { add } = computeLabels({ title: 'style(overview): tweak layout' }) + assert.ok(add.includes('area/console')) +}) + +test('vmdisk (no hyphen) maps to area/forms', () => { + const { add } = computeLabels({ title: 'fix(vmdisk): reorder' }) + assert.ok(add.includes('area/forms')) +}) + +test('empty parens: feat(): x parses as type=feat, scope=empty', () => { + const { add, warnings } = computeLabels({ title: 'feat(): bare description' }) + assert.ok(add.includes('kind/feature')) + assert.ok(add.includes('area/uncategorized')) + assert.deepEqual(warnings, []) +}) + +test('non-labeler labels are never removed', () => { + // The labeler must never touch labels outside its authoritative set + // (area/uncategorized, kind/breaking-change). Maintainer-added kind/*, + // priority/*, triage/*, lifecycle/*, etc. survive untouched. + const { remove } = computeLabels({ + title: 'fix(forms): regression', + existingLabels: [ + 'kind/feature', // wrong kind, but not the labeler's call to fix + 'priority/important-soon', + 'lifecycle/active', + 'area/forms', + ], + }) + assert.deepEqual(remove, []) +}) diff --git a/.github/workflows/labels.yaml b/.github/workflows/labels.yaml new file mode 100644 index 0000000..d5c2ba2 --- /dev/null +++ b/.github/workflows/labels.yaml @@ -0,0 +1,131 @@ +name: Labels + +on: + pull_request: + paths: + - .github/labels.yml + - .github/workflows/labels.yaml + - .github/workflows/pr-labeler.yaml + - .github/workflows/pr-size.yaml + - .github/scripts/pr-labeler.js + - .github/scripts/pr-labeler.test.js + push: + branches: [main] + paths: + - .github/labels.yml + - .github/workflows/labels.yaml + workflow_dispatch: + schedule: + - cron: '17 4 * * 1' # Mondays at 04:17 UTC + +permissions: + contents: read + +concurrency: + group: labels-sync + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate labels.yml schema + run: | + python3 - <<'PY' + import glob, re, sys, yaml + + path = '.github/labels.yml' + data = yaml.safe_load(open(path)) + errors = [] + + # 1. description ≤ 100 chars (GitHub REST API limit) + for label in data: + desc = label.get('description', '') or '' + if len(desc) > 100: + errors.append(f"{label['name']}: description {len(desc)} chars (max 100)") + + # 2. color is 6-char hex without leading # + for label in data: + color = label.get('color', '') or '' + if not re.match(r'^[0-9A-Fa-f]{6}$', color): + errors.append(f"{label['name']}: bad color {color!r} (must be 6-char hex without #)") + + # 3. unique top-level names + names = [label['name'] for label in data] + dups = sorted({n for n in names if names.count(n) > 1}) + for n in dups: + errors.append(f"duplicate name: {n}") + + # 4. aliases do not collide with any top-level name + name_set = set(names) + for label in data: + for alias in (label.get('aliases') or []): + if alias in name_set: + errors.append(f"alias {alias!r} (under {label['name']!r}) collides with a top-level name") + + # 5. description-vs-automation drift: any label whose description + # advertises automation ("auto-applied", "auto-closed", "auto-label*", + # "auto-close*") must be referenced by name in at least one + # automation file under .github/workflows/ or .github/scripts/, so + # the description does not outlive the code that backs it. + automation_files = sorted( + glob.glob('.github/workflows/*.yaml') + + glob.glob('.github/workflows/*.yml') + + glob.glob('.github/scripts/*.js') + + glob.glob('.github/scripts/*.cjs') + + glob.glob('.github/scripts/*.mjs') + ) + automation_blob = '\n'.join(open(f).read() for f in automation_files) + claim_pattern = re.compile(r'auto[- ](applied|closed|label[a-z]*|close[a-z]*)', re.I) + for label in data: + desc = label.get('description', '') or '' + if claim_pattern.search(desc) and label['name'] not in automation_blob: + errors.append( + f"{label['name']}: description claims automation " + f"({claim_pattern.search(desc).group(0)!r}) but the label name " + f"is not referenced in .github/workflows/ or .github/scripts/ — " + f"either wire the automation or drop the claim" + ) + + if errors: + for err in errors: + print(f"::error::{err}") + sys.exit(1) + + print(f"labels.yml schema OK ({len(data)} labels)") + PY + + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Run pr-labeler unit tests + # Glob to test files only — pointing `node --test` at the directory + # would treat the module under test as a test file and fail. The + # grep guard catches the "passing because nothing ran" trap: a + # zero-file glob exits 0 with `ℹ tests 0`, which would otherwise + # green the build silently if the test file is moved or renamed. + run: | + output=$(node --test '.github/scripts/*.test.js') + echo "$output" + echo "$output" | grep -E '^[^ ]* tests [1-9]' \ + || { echo "::error::no pr-labeler tests ran — check the glob in labels.yaml"; exit 1; } + + sync: + needs: [validate, unit-tests] + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: EndBug/label-sync@v2 + with: + config-file: .github/labels.yml + delete-other-labels: false diff --git a/.github/workflows/pr-labeler.yaml b/.github/workflows/pr-labeler.yaml new file mode 100644 index 0000000..ec59493 --- /dev/null +++ b/.github/workflows/pr-labeler.yaml @@ -0,0 +1,73 @@ +name: PR Auto-Label + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + +# Coalesce rapid edited/synchronize bursts on the same PR so an older run +# cannot land its label mutations after a newer run computed a different set. +concurrency: + group: pr-labeler-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Check out labeler script + uses: actions/checkout@v4 + with: + # pull_request_target defaults to the PR base; that's exactly what + # we want — the labeler script must come from the base, never from + # the PR head, because pull_request_target runs with write-scoped + # GITHUB_TOKEN. Pinning to base.sha is defence-in-depth. + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Apply labels from PR title + uses: actions/github-script@v7 + with: + script: | + const { computeLabels } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/pr-labeler.js`) + const pr = context.payload.pull_request + const { add, remove, warnings } = computeLabels({ + title: pr.title || '', + body: pr.body || '', + existingLabels: (pr.labels || []).map((l) => l.name), + }) + + for (const w of warnings) core.warning(w) + + // Remove stale labels the labeler is authoritative for. Tolerate + // 404 — concurrent runs or manual edits between event payload and + // execution can race here. + for (const name of remove) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name, + }) + core.info(`Removed label: ${name}`) + } catch (e) { + if (e.status !== 404) throw e + core.info(`label ${name} already gone (404)`) + } + } + + if (add.length === 0) { + core.info('No new labels to apply') + return + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: add, + }) + core.info(`Applied labels: ${add.join(', ')}`) diff --git a/.github/workflows/pr-size.yaml b/.github/workflows/pr-size.yaml new file mode 100644 index 0000000..0d04415 --- /dev/null +++ b/.github/workflows/pr-size.yaml @@ -0,0 +1,94 @@ +name: PR size label + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-size-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + size: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + // Skip files that should not count as "real diff" — they are produced by tools + // or vendored, not authored by humans, and including them lies to reviewers + // about review effort: + // - lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb, *.lock) + // - build / dist output checked in by accident + // - generated code trees (any path segment named `generated/`) + // - vendored deps (node_modules — should be gitignored, defence in depth) + const isIgnored = (path) => + path === 'pnpm-lock.yaml' || path.endsWith('/pnpm-lock.yaml') || + path === 'package-lock.json' || path.endsWith('/package-lock.json') || + path === 'yarn.lock' || path.endsWith('/yarn.lock') || + path === 'bun.lockb' || path.endsWith('/bun.lockb') || + path.endsWith('.lock') || + /(^|\/)node_modules\//.test(path) || + /(^|\/)dist\//.test(path) || + /(^|\/)build\//.test(path) || + /(^|\/)generated\//.test(path); + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + }); + const lines = files + .filter((f) => !isIgnored(f.filename)) + .reduce((acc, f) => acc + (f.additions || 0) + (f.deletions || 0), 0); + + // Thresholds match the descriptions of size/* labels in .github/labels.yml. + const bucket = + lines <= 9 ? 'XS' : + lines <= 29 ? 'S' : + lines <= 99 ? 'M' : + lines <= 499 ? 'L' : + lines <= 999 ? 'XL' : 'XXL'; + const target = `size/${bucket}`; + + // Match both legacy "size:" and canonical "size/" during the migration window. + const existing = (pr.labels || []).map((l) => l.name); + const oldSizes = existing.filter((n) => n.startsWith('size/') || n.startsWith('size:')); + const alreadyTarget = oldSizes.includes(target); + + // Remove every size/* label that is not the target. Tolerate 404 — concurrent + // runs or manual edits between event payload and execution can race here. + for (const name of oldSizes) { + if (name === target) continue; + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name, + }); + } catch (e) { + if (e.status !== 404) throw e; + core.info(`label ${name} already gone (404)`); + } + } + + if (alreadyTarget && oldSizes.length === 1) { + core.info(`PR #${pr.number}: ${lines} lines, label already ${target}`); + return; + } + if (!alreadyTarget) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [target], + }); + } + core.info(`PR #${pr.number}: ${lines} lines -> ${target}`);