From 05bb6abc7d61be7414e70e94b5902ac44fd92357 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:44:07 +0000 Subject: [PATCH 1/3] ci: auto-label open PRs with missing type and area labels Scheduled workflow (every 15 min + manual) runs hack/auto_label.py, which derives a type: label from the conventional-commit title prefix and area: labels from changed paths, and adds them only when that category has no label. It never checks out or runs PR code; the pull_request trigger only runs the unit tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018e43TZASG72EppLGzKTNvo Signed-off-by: Claude --- .github/workflows/auto-label.yaml | 58 +++++++++++ docs/contributing.md | 2 +- hack/auto_label.py | 163 ++++++++++++++++++++++++++++++ hack/test_auto_label.py | 94 +++++++++++++++++ 4 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/auto-label.yaml create mode 100755 hack/auto_label.py create mode 100755 hack/test_auto_label.py diff --git a/.github/workflows/auto-label.yaml b/.github/workflows/auto-label.yaml new file mode 100644 index 000000000..a0e587d54 --- /dev/null +++ b/.github/workflows/auto-label.yaml @@ -0,0 +1,58 @@ +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 + if: github.event_name != 'pull_request' + 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..b986514bc --- /dev/null +++ b/hack/auto_label.py @@ -0,0 +1,163 @@ +#!/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). + - `!` 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 or re-added after removal. 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.error +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).""" + if path in AREA_BY_FILE: + return AREA_BY_FILE[path] + if path.startswith("test/e2e/"): + return "area: e2e" + 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(l.startswith("type: ") for l in existing): + add.append(type_label) + if not any(l.startswith("area: ") for l in existing): + add.extend(sorted({area_for_path(f) for f in files})) + 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() + req = urllib.request.Request( + API + path, + data=data, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": "Bearer " + token, + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + 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: + break + 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 + + failed = False + for pr in paginate(f"/repos/{repo}/pulls?state=open", token): + number = pr["number"] + existing = [l["name"] for l in pr.get("labels", [])] + try: + files = [] + if not any(l.startswith("area: ") for l 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}) + except urllib.error.HTTPError 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..44fa74018 --- /dev/null +++ b/hack/test_auto_label.py @@ -0,0 +1,94 @@ +#!/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": "area: shared", + } + 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_unknown_prefix_still_gets_area(self): + self.assertEqual(planned_labels("Update stuff", ["web/a.ts"], []), ["area: web"]) + + +if __name__ == "__main__": + unittest.main() From 3234f93de3b05d7da91855c6c6d953334dd4965f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:33:21 +0000 Subject: [PATCH 2/3] fix(ci): harden auto-label error handling and scope - Catch network errors, timeouts and bad JSON per PR instead of aborting the run; report a failed PR listing cleanly. - Only label docs/ changes `area: shared` when nothing else changed. - Restrict the label job to the default branch so a manual run on another branch never hands its script the write token. - Warn when pagination truncates; correct the docstring on re-adding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018e43TZASG72EppLGzKTNvo Signed-off-by: Claude --- .github/workflows/auto-label.yaml | 6 +++++- hack/auto_label.py | 34 +++++++++++++++++++++++-------- hack/test_auto_label.py | 12 ++++++++++- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.github/workflows/auto-label.yaml b/.github/workflows/auto-label.yaml index a0e587d54..977e677f6 100644 --- a/.github/workflows/auto-label.yaml +++ b/.github/workflows/auto-label.yaml @@ -41,7 +41,11 @@ jobs: label: name: label open pull requests - if: github.event_name != 'pull_request' + # 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 diff --git a/hack/auto_label.py b/hack/auto_label.py index b986514bc..26a11b0b8 100755 --- a/hack/auto_label.py +++ b/hack/auto_label.py @@ -7,11 +7,13 @@ (`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/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 or re-added after removal. Labels are only added, +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 @@ -28,7 +30,6 @@ import os import re import sys -import urllib.error import urllib.request API = "https://api.github.com" @@ -78,11 +79,17 @@ def parse_title(title): def area_for_path(path): - """Map one changed file path to its `area:` label (fallback: shared).""" + """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") @@ -93,8 +100,9 @@ def planned_labels(title, files, existing): type_label, breaking = parse_title(title) if type_label and not any(l.startswith("type: ") for l in existing): add.append(type_label) - if not any(l.startswith("area: ") for l in existing): - add.extend(sorted({area_for_path(f) for f in files})) + if files and not any(l.startswith("area: ") for l 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 @@ -123,7 +131,8 @@ def paginate(path, token, max_pages=30): batch = api("GET", f"{path}{sep}per_page=100&page={page}", token) items.extend(batch) if len(batch) < 100: - break + return items + print(f"warning: {path} truncated at {len(items)} items", file=sys.stderr) return items @@ -135,8 +144,14 @@ def main(argv): 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 paginate(f"/repos/{repo}/pulls?state=open", token): + for pr in pulls: number = pr["number"] existing = [l["name"] for l in pr.get("labels", [])] try: @@ -153,7 +168,8 @@ def main(argv): if not dry_run: api("POST", f"/repos/{repo}/issues/{number}/labels", token, {"labels": add}) - except urllib.error.HTTPError as err: + # 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 diff --git a/hack/test_auto_label.py b/hack/test_auto_label.py index 44fa74018..4f0fb3452 100755 --- a/hack/test_auto_label.py +++ b/hack/test_auto_label.py @@ -50,7 +50,7 @@ def test_mapping(self): "netguard/guard.go": "area: shared", ".github/workflows/ci.yaml": "area: shared", "Makefile": "area: shared", - "docs/security.md": "area: shared", + "docs/security.md": None, } for path, want in cases.items(): with self.subTest(path=path): @@ -86,6 +86,16 @@ def test_breaking_added_once(self): [], ) + 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"]) From ad1d4215d69c306232a766ea2422b08a89ac4a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:43:16 +0000 Subject: [PATCH 3/3] fix(ci): send auto-label request bodies as JSON urllib defaults a request body to application/x-www-form-urlencoded; declare application/json for the label POST (CodeRabbit). Also rename the ambiguous single-letter loop variables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018e43TZASG72EppLGzKTNvo Signed-off-by: Claude --- hack/auto_label.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/hack/auto_label.py b/hack/auto_label.py index 26a11b0b8..ca68ad8d2 100755 --- a/hack/auto_label.py +++ b/hack/auto_label.py @@ -98,9 +98,9 @@ def planned_labels(title, files, existing): existing = set(existing) add = [] type_label, breaking = parse_title(title) - if type_label and not any(l.startswith("type: ") for l in existing): + if type_label and not any(name.startswith("type: ") for name in existing): add.append(type_label) - if files and not any(l.startswith("area: ") for l in existing): + 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: @@ -110,16 +110,15 @@ def planned_labels(title, files, existing): def api(method, path, token, body=None): data = None if body is None else json.dumps(body).encode() - req = urllib.request.Request( - API + path, - data=data, - method=method, - headers={ - "Accept": "application/vnd.github+json", - "Authorization": "Bearer " + token, - "X-GitHub-Api-Version": "2022-11-28", - }, - ) + 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) @@ -153,10 +152,10 @@ def main(argv): failed = False for pr in pulls: number = pr["number"] - existing = [l["name"] for l in pr.get("labels", [])] + existing = [label["name"] for label in pr.get("labels", [])] try: files = [] - if not any(l.startswith("area: ") for l in existing): + if not any(name.startswith("area: ") for name in existing): files = [ f["filename"] for f in paginate(f"/repos/{repo}/pulls/{number}/files", token)