Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/auto-label.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 178 additions & 0 deletions hack/auto_label.py
Original file line number Diff line number Diff line change
@@ -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<type>[a-z]+)(?:\([^)]*\))*(?P<bang>!)?:\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:]))
104 changes: 104 additions & 0 deletions hack/test_auto_label.py
Original file line number Diff line number Diff line change
@@ -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()
Loading