diff --git a/.github/workflows/auto-label.yaml b/.github/workflows/auto-label.yaml new file mode 100644 index 000000000..977e677f6 --- /dev/null +++ b/.github/workflows/auto-label.yaml @@ -0,0 +1,62 @@ +name: auto-label + +# Adds missing `type:` and `area:` labels to open PRs (hack/auto_label.py). +# +# Runs on a schedule rather than on pull_request: fork PRs only get a +# read-only token there, and pull_request_target is a dangerous trigger +# (zizmor). The scheduled run checks out the default branch only and never +# executes PR code -- it reads PR titles and file lists through the REST API. +# The pull_request trigger below only runs the script's unit tests. +on: + schedule: + - cron: '*/15 * * * *' + workflow_dispatch: + pull_request: + paths: + - 'hack/auto_label.py' + - 'hack/test_auto_label.py' + - '.github/workflows/auto-label.yaml' + +permissions: {} + +concurrency: + group: auto-label-${{ github.event_name == 'pull_request' && github.ref || 'label' }} + cancel-in-progress: false + +jobs: + test: + name: auto-label unit tests + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run unit tests + run: python3 hack/test_auto_label.py -v + + label: + name: label open pull requests + # Scheduled runs use the default branch; keep manual runs there too so a + # branch's copy of the script never gets the write token. + if: >- + github.event_name != 'pull_request' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # add labels to PRs + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Add missing labels + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 hack/auto_label.py diff --git a/docs/contributing.md b/docs/contributing.md index 1456bb945..77a65466e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -139,7 +139,7 @@ the same review, lint, and test gates below. 2. **Do not run `make test` or `make lint` locally.** A quick compile check (`go build ./...` or `tsc --noEmit`) is fine to avoid pushing obviously-broken code, but the full test and lint suites run only on CI (GitHub Actions) — that is where they are gated and where failures surface. The exception is workflow changes: if you modify `.github/workflows/` or `.github/actions/`, run `actionlint` and `zizmor` locally before pushing (see CI and workflows section above). 3. For UI work, include the Pencil node id(s) touched in the PR description 4. Sign commits (`git commit -s`) -5. **PR labels**: every PR must carry at least one `type:` and one `area:` label. CodeRabbit (see Code review below) applies them automatically, but you are responsible for verifying they are correct. The `type:` taxonomy is `feature` / `fix` / `refactor` / `test` / `ci` / `chore` / `docs` / `security`. The `area:` taxonomy is `operator` / `api` / `agent` / `web` / `modules` / `chart` / `e2e` / `specs` / `shared` / `optional-components`. A breaking CRD, API, or chart-value change also takes `breaking`. The `status:` labels (`blocked`, `needs-maintainer`, `in-progress`) are optional. A PR spanning several components takes several `area:` labels rather than being left unlabelled. +5. **PR labels**: every PR must carry at least one `type:` and one `area:` label. CodeRabbit (see Code review below) applies them automatically, and the `auto-label` workflow (`hack/auto_label.py`, every 15 minutes) fills in a missing `type:` from the conventional-commit title prefix and a missing `area:` from the changed paths; `type: security` is never derived. You are responsible for verifying they are correct. The `type:` taxonomy is `feature` / `fix` / `refactor` / `test` / `ci` / `chore` / `docs` / `security`. The `area:` taxonomy is `operator` / `api` / `agent` / `web` / `modules` / `chart` / `e2e` / `specs` / `shared` / `optional-components`. A breaking CRD, API, or chart-value change also takes `breaking`. The `status:` labels (`blocked`, `needs-maintainer`, `in-progress`) are optional. A PR spanning several components takes several `area:` labels rather than being left unlabelled. Game-module changes (`modules/`) belong in the separate **`gameplane-module`** repo, which this repo vendors as a submodule. Open the module PR there; once it diff --git a/hack/auto_label.py b/hack/auto_label.py new file mode 100755 index 000000000..ca68ad8d2 --- /dev/null +++ b/hack/auto_label.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""auto_label.py: add missing `type:` and `area:` labels to open pull requests. + +Run by .github/workflows/auto-label.yaml on a schedule. For every open PR: + + - no `type:` label -> derive one from the conventional-commit title prefix + (`fix(api): ...` -> `type: fix`); unknown prefixes add + nothing. + - no `area:` label -> derive one per top-level directory the PR changes + (docs/contributing.md "PR labels" taxonomy). docs/ + pages only count when nothing else changed. + - `!` before the colon in the title (`feat(api)!: ...`) -> `breaking`. + +A category that already has a label is left alone, so a maintainer's manual +choice is never overridden (removing the last label of a category lets the +next run derive one again). Labels are only added, +never removed. `type: security` cannot be derived and stays manual. + +The script reads PR titles and file lists through the REST API only; it never +checks out or executes pull request code. + +Environment: + GITHUB_TOKEN token with pull-requests: write + GITHUB_REPOSITORY owner/repo +Flags: + --dry-run print the planned labels without applying them +""" + +import json +import os +import re +import sys +import urllib.request + +API = "https://api.github.com" + +TYPE_LABELS = { + "feat": "type: feature", + "fix": "type: fix", + "refactor": "type: refactor", + "test": "type: test", + "ci": "type: ci", + "chore": "type: chore", + "docs": "type: docs", +} + +# `type(scope)(scope2)!: subject` -- Dependabot titles carry two scopes. +TITLE_RE = re.compile(r"^(?P[a-z]+)(?:\([^)]*\))*(?P!)?:\s") + +AREA_BY_DIR = { + "operator": "area: operator", + "api": "area: api", + "agent": "area: agent", + "web": "area: web", + "design-export": "area: web", + "modules": "area: modules", + "charts": "area: chart", + "specs": "area: specs", + "sentinel": "area: optional-components", + "capture-sidecar": "area: optional-components", + "mcp-server": "area: optional-components", + "tunnel": "area: optional-components", + "audit-syslog-bridge": "area: optional-components", + "telemetry-receiver": "area: optional-components", +} + +AREA_BY_FILE = { + "design.pen": "area: web", + "docs/module-authoring.md": "area: modules", +} + + +def parse_title(title): + """Return (type label or None, breaking flag) for a PR title.""" + m = TITLE_RE.match(title.strip()) + if not m: + return None, False + return TYPE_LABELS.get(m.group("type")), m.group("bang") is not None + + +def area_for_path(path): + """Map one changed file path to its `area:` label (fallback: shared). + + Returns None for docs/ pages, which accompany most code changes and only + mean `area: shared` when nothing else changed. + """ + if path in AREA_BY_FILE: + return AREA_BY_FILE[path] + if path.startswith("test/e2e/"): + return "area: e2e" + if path.startswith("docs/"): + return None + return AREA_BY_DIR.get(path.split("/", 1)[0], "area: shared") + + +def planned_labels(title, files, existing): + """Labels to add to a PR, given its title, changed files and labels.""" + existing = set(existing) + add = [] + type_label, breaking = parse_title(title) + if type_label and not any(name.startswith("type: ") for name in existing): + add.append(type_label) + if files and not any(name.startswith("area: ") for name in existing): + areas = {area_for_path(f) for f in files} - {None} + add.extend(sorted(areas) or ["area: shared"]) + if breaking and "breaking" not in existing: + add.append("breaking") + return add + + +def api(method, path, token, body=None): + data = None if body is None else json.dumps(body).encode() + headers = { + "Accept": "application/vnd.github+json", + "Authorization": "Bearer " + token, + "X-GitHub-Api-Version": "2022-11-28", + } + if data is not None: + # urllib defaults bodies to application/x-www-form-urlencoded. + headers["Content-Type"] = "application/json" + req = urllib.request.Request(API + path, data=data, method=method, headers=headers) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.load(resp) + + +def paginate(path, token, max_pages=30): + sep = "&" if "?" in path else "?" + items = [] + for page in range(1, max_pages + 1): + batch = api("GET", f"{path}{sep}per_page=100&page={page}", token) + items.extend(batch) + if len(batch) < 100: + return items + print(f"warning: {path} truncated at {len(items)} items", file=sys.stderr) + return items + + +def main(argv): + dry_run = "--dry-run" in argv + token = os.environ.get("GITHUB_TOKEN", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + if not token or not repo: + print("GITHUB_TOKEN and GITHUB_REPOSITORY must be set", file=sys.stderr) + return 1 + + try: + pulls = paginate(f"/repos/{repo}/pulls?state=open", token) + except (OSError, ValueError) as err: + print(f"listing open pull requests: {err}", file=sys.stderr) + return 1 + + failed = False + for pr in pulls: + number = pr["number"] + existing = [label["name"] for label in pr.get("labels", [])] + try: + files = [] + if not any(name.startswith("area: ") for name in existing): + files = [ + f["filename"] + for f in paginate(f"/repos/{repo}/pulls/{number}/files", token) + ] + add = planned_labels(pr["title"], files, existing) + if not add: + continue + print(f"#{number}: add {', '.join(add)}") + if not dry_run: + api("POST", f"/repos/{repo}/issues/{number}/labels", token, + {"labels": add}) + # HTTPError/URLError and timeouts are OSError; bad JSON is ValueError. + except (OSError, ValueError, KeyError) as err: + print(f"#{number}: {err}", file=sys.stderr) + failed = True + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/hack/test_auto_label.py b/hack/test_auto_label.py new file mode 100755 index 000000000..4f0fb3452 --- /dev/null +++ b/hack/test_auto_label.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Unit tests for hack/auto_label.py. Run: python3 hack/test_auto_label.py""" + +import unittest + +from auto_label import area_for_path, parse_title, planned_labels + + +class ParseTitle(unittest.TestCase): + def test_prefixes(self): + cases = { + "feat: add thing": "type: feature", + "fix(api): bind identities": "type: fix", + "refactor(web): split": "type: refactor", + "test(e2e): cover": "type: test", + "ci: reduce checks": "type: ci", + "chore(deps)(deps-dev): Bump x from 1 to 2": "type: chore", + "docs(specs): record release": "type: docs", + } + for title, want in cases.items(): + with self.subTest(title=title): + self.assertEqual(parse_title(title), (want, False)) + + def test_breaking(self): + self.assertEqual(parse_title("feat(api)!: drop v1"), ("type: feature", True)) + + def test_unknown_or_malformed(self): + for title in ("perf: faster", "Fix the thing", "fix:no space", ""): + with self.subTest(title=title): + self.assertEqual(parse_title(title)[0], None) + + +class AreaForPath(unittest.TestCase): + def test_mapping(self): + cases = { + "operator/internal/controller/x.go": "area: operator", + "api/cmd/main.go": "area: api", + "agent/internal/files/a.go": "area: agent", + "web/src/routes/Share.tsx": "area: web", + "design.pen": "area: web", + "design-export/json/a.json": "area: web", + "modules": "area: modules", + "docs/module-authoring.md": "area: modules", + "charts/gameplane/values.yaml": "area: chart", + "test/e2e/buckets.sh": "area: e2e", + "specs/018-x/tasks.md": "area: specs", + "specs/done_014-x/spec.md": "area: specs", + "mcp-server/internal/kube/log_tail.go": "area: optional-components", + "telemetry-receiver/main.go": "area: optional-components", + "netguard/guard.go": "area: shared", + ".github/workflows/ci.yaml": "area: shared", + "Makefile": "area: shared", + "docs/security.md": None, + } + for path, want in cases.items(): + with self.subTest(path=path): + self.assertEqual(area_for_path(path), want) + + +class PlannedLabels(unittest.TestCase): + def test_unlabelled(self): + got = planned_labels( + "fix(mods): long installs", + ["agent/a.go", "api/b.go", "web/c.tsx", "agent/d.go"], + [], + ) + self.assertEqual(got, ["type: fix", "area: agent", "area: api", "area: web"]) + + def test_existing_categories_untouched(self): + got = planned_labels( + "fix(api): x", ["operator/a.go"], ["type: security", "area: api"] + ) + self.assertEqual(got, []) + + def test_only_missing_category(self): + got = planned_labels("fix(api): x", ["api/a.go"], ["type: security"]) + self.assertEqual(got, ["area: api"]) + + def test_breaking_added_once(self): + self.assertEqual( + planned_labels("feat!: x", ["api/a.go"], ["type: feature", "area: api"]), + ["breaking"], + ) + self.assertEqual( + planned_labels("feat!: x", [], ["type: feature", "area: api", "breaking"]), + [], + ) + + def test_docs_only_count_alone(self): + self.assertEqual( + planned_labels("docs: x", ["docs/a.md", "api/b.go"], []), + ["type: docs", "area: api"], + ) + self.assertEqual( + planned_labels("docs: x", ["docs/a.md"], []), + ["type: docs", "area: shared"], + ) + + def test_unknown_prefix_still_gets_area(self): + self.assertEqual(planned_labels("Update stuff", ["web/a.ts"], []), ["area: web"]) + + +if __name__ == "__main__": + unittest.main()