PR labels: a kind from the template's ticked box, areas from the paths

The template gets a "Kind" list (parity, bug, perf, feature, extra, tooling,
docs). pr-labels.yml (pull_request_target, main's script only, never the
pull request's code) sets the ticked kind when the pull request has none, swaps
it when the author changes the tick, and never removes a label set by hand.
Area labels come from the changed paths: at most 3, each a tenth of the lines.
`feature` keeps its release meaning.

pr_labels.py also creates the labels and labels the open pull requests once
(backfill: kind only where clear, else none).

Part of #3859.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-29 21:04:01 +01:00
1 parent 668419b92a
commit 59b1c33bb2
5 files changed
+435 -2

No files matched your search

+12
View File
@@ -4,6 +4,18 @@
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
## Kind
<!-- Tick one: it sets the pull request's label (CONTRIBUTING.md "Pull requests"). -->
- [ ] parity: Horizon now does what vanilla 26.3 does
- [ ] bug: a crash or a breakage
- [ ] perf: faster, or less memory
- [ ] feature: something new for players (listed under New in the release notes)
- [ ] extra: Horizon-only, off by default
- [ ] tooling: tools, CI, tests, harnesses
- [ ] docs
## Vanilla reference
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
+36
View File
@@ -0,0 +1,36 @@
name: pr labels
# A pull request's kind label (the box ticked in the template) and area labels (the paths it changes):
# tools/ci/pr_labels.py, CONTRIBUTING.md "Pull requests".
#
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write to fork PRs:
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
# - It checks out main's script only (`ref: main`), never the pull request's head or merge commit.
# - The script reads the event file (the description is data in JSON, never in a shell line) and the API's list of
# changed file names; it runs, builds and imports nothing of theirs. The token writes labels only.
on:
pull_request_target:
types: [opened, reopened, synchronize, edited, ready_for_review]
permissions:
contents: read
jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
tools/ci/pr_labels.py
sparse-checkout-cone-mode: false
- name: Label
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: python3 tools/ci/pr_labels.py event
+6 -2
View File
@@ -82,8 +82,12 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
ourselves instead of sending it back to you; only design questions come back.
- **Labels** (we set them if you can't): `feature` when players get something new (it appears under "New" in the
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
`label:parity` is the list of real progress toward vanilla.
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the release notes,
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
+138
View File
@@ -0,0 +1,138 @@
import unittest
from pathlib import Path
from tools.ci import pr_labels as pl
ROOT = Path(__file__).resolve().parents[1]
def body(*ticked, other=""):
lines = [f"- [{'x' if word in ticked else ' '}] {word}: something" for word, _, _ in pl.KINDS]
return "## What and why\n\nStuff.\n\n## Kind\n\n" + "\n".join(lines) + f"\n\n## Self-check\n\n{other}"
def event(new, old=None, labels=(), action="edited"):
ev = {"action": action, "pull_request": {"number": 9, "body": new, "labels": [{"name": n} for n in labels]}}
if old is not None:
ev["changes"] = {"body": {"from": old}}
return ev
class KindTests(unittest.TestCase):
def test_ticked(self):
self.assertEqual(pl.ticked(body("parity")), ["parity"])
self.assertEqual(pl.ticked(body("docs")), ["documentation"])
self.assertEqual(pl.ticked(body()), [])
self.assertEqual(pl.ticked(body("perf", "bug")), ["bug", "perf"])
self.assertEqual(pl.ticked(None), [])
def test_only_the_kind_section(self):
self.assertEqual(pl.ticked(body(other="- [x] bug\n- [x] tooling")), [])
self.assertEqual(pl.ticked("- [x] parity"), [])
self.assertEqual(pl.ticked("## Kind\n* [X] **perf**: faster\n- [x] unknown\n"), ["perf"])
def test_the_template_has_every_kind_unticked(self):
text = (ROOT / ".github/pull_request_template.md").read_text()
self.assertEqual(pl.ticked(text), [])
for word, _, _ in pl.KINDS:
self.assertIn(f"- [ ] {word}", text)
def test_added_when_none_yet(self):
self.assertEqual(pl.plan_event(event(body("parity"), action="opened"), []), (["parity"], None))
def test_one_box_only(self):
self.assertEqual(pl.plan_event(event(body("parity", "bug"), action="opened"), []), ([], None))
def test_a_kind_set_by_hand_stays(self):
self.assertEqual(pl.plan_event(event(body("parity"), body(), labels=("feature",)), None), ([], None))
def test_changing_the_tick_swaps(self):
self.assertEqual(pl.plan_event(event(body("bug"), body("parity"), labels=("parity",)), None),
(["bug"], "parity"))
def test_changing_the_tick_never_drops_a_hand_label(self):
self.assertEqual(pl.plan_event(event(body("bug"), body("parity"), labels=("parity", "feature")), None),
([], None))
self.assertEqual(pl.plan_event(event(body("bug"), body("parity"), labels=("feature",)), None), ([], None))
def test_already_there(self):
self.assertEqual(pl.plan_event(event(body("perf"), body(), labels=("perf",)), None), ([], None))
class AreaTests(unittest.TestCase):
def test_paths(self):
cases = {
"launcher/src/main.rs": "launcher",
"crates/horizon-world/src/worldgen/structure/mod.rs": "worldgen",
"crates/horizon-client/src/mobs/ai.rs": "mobs",
"crates/horizon-client/src/game/wither.rs": "mobs",
"crates/horizon-client-render/src/gfx/cull.rs": "rendering",
"crates/horizon-client-render/src/terrain.wgsl": "rendering",
"crates/horizon-client/src/game/dedicated/status.rs": "server",
"crates/horizon-server-files/src/lib.rs": "server",
"crates/horizon-client/src/lan/hub.rs": "multiplayer",
"crates/horizon-client/src/game/remote_vehicles.rs": "multiplayer",
"crates/horizon-protocol/src/packets/play.rs": "multiplayer",
"crates/horizon-mods/src/lib.rs": "mods",
"crates/horizon-client/src/game/native_hooks.rs": "mods",
"crates/horizon-world/src/datafix/v3.rs": "saves",
"crates/horizon-client/src/game/commands/admin.rs": "commands",
"crates/horizon-command/src/args.rs": "commands",
"crates/horizon-client-render/src/sound_engine.rs": "audio",
"crates/horizon-client-render/src/streaming.rs": "chunks",
"crates/horizon-world/src/level/redstone.rs": "blocks",
"crates/horizon-client/src/title/worlds.rs": "ui",
"crates/horizon-client-render/src/options_ui.rs": "ui",
"docs/api/shaders.md": "website",
"roadmap/roadmap.toml": "website",
}
for path, area in cases.items():
self.assertEqual(pl.area_of(path), area, path)
def test_no_area(self):
for path in ("tools/parity.py", "crates/horizon-client/src/game.rs", "Cargo.lock", "docs/README.md",
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobOracle.java",
"crates/horizon-client/examples/mob_ai_probe.rs"):
self.assertIsNone(pl.area_of(path), path)
def test_biggest_three_with_a_tenth_each(self):
files = [("launcher/src/a.rs", 50), ("crates/horizon-client/src/mobs/a.rs", 30),
("crates/horizon-command/src/a.rs", 10), ("crates/horizon-client-render/src/audio.rs", 9),
("tools/x.py", 1)]
self.assertEqual(pl.areas(files), ["launcher", "mobs", "commands"])
self.assertEqual(pl.areas([("tools/x.py", 95), ("launcher/src/a.rs", 5)]), [])
self.assertEqual(pl.areas([]), [])
def test_files_count_on_push_not_on_edit(self):
files = [("launcher/src/a.rs", 5)]
self.assertEqual(pl.plan_event(event(body(), action="synchronize"), files), (["launcher"], None))
self.assertEqual(pl.plan_event(event(body(), labels=("launcher",), action="synchronize"), files), ([], None))
class BackfillTests(unittest.TestCase):
def test_guess_only_when_clear(self):
self.assertEqual(pl.guess_kind("x", body("extra"), []), "extra")
self.assertEqual(pl.guess_kind("x", None, [("docs/a.md", 3), ("README.md", 1)]), "documentation")
self.assertEqual(pl.guess_kind("x", None, [("tools/a.py", 3), ("tests/test_a.py", 1)]), "tooling")
self.assertEqual(pl.guess_kind("perf(client): less", None, [("launcher/a.rs", 1)]), "perf")
self.assertEqual(pl.guess_kind("Fix a crash when", None, [("launcher/a.rs", 1)]), "bug")
self.assertIsNone(pl.guess_kind("Mooshrooms keep their colour", None, [("crates/a.rs", 1)]))
self.assertIsNone(pl.guess_kind("x", None, []))
class WorkflowTests(unittest.TestCase):
def test_workflow_is_safe(self):
text = (ROOT / ".github/workflows/pr-labels.yml").read_text()
run_lines = [l.strip() for l in text.splitlines() if l.strip().startswith("run:")]
self.assertEqual(run_lines, ["run: python3 tools/ci/pr_labels.py event"])
self.assertIn("pull_request_target", text)
self.assertIn("ref: main", text)
self.assertIn("persist-credentials: false", text)
for bad in ("contents: write", "issues: write", "head.sha", "head.ref", "github.event.pull_request.body"):
self.assertNotIn(bad, text)
# The checkout fetches the script alone.
self.assertIn("sparse-checkout: |\n tools/ci/pr_labels.py\n sparse-checkout-cone-mode", text)
if __name__ == "__main__":
unittest.main()
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Kind and area labels on pull requests (CONTRIBUTING.md "Pull requests", issue #3859).
pr_labels.py event one pull request event (pr-labels.yml): its kind and area labels
pr_labels.py create [--apply] creates the kind and area labels the repository lacks
pr_labels.py backfill [--apply] labels the open pull requests once; prints what it would do without --apply
Kind: the box ticked in the template's "Kind" list. One label, added only when the pull request has no kind label yet;
unticking the box that set it and ticking another swaps them. A label someone set by hand is never removed.
`feature` keeps its release meaning (listed under New, docs/release-flow.md).
Areas: from the paths the pull request changes (docs/architecture.md), at most 3, each with a tenth of the changed
lines or more. Added, never removed.
The workflow runs on `pull_request_target` from main: this script reads the event file and the API's list of changed
files, never a pull request's code.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
# Kind: (the word ticked in the template, the label, its description).
KINDS = [
("parity", "parity", "Kind: matches vanilla 26.3 behaviour"),
("bug", "bug", "Something isn't working"),
("perf", "perf", "Kind: faster, or less memory"),
("feature", "feature", "A new feature: listed under New in the release notes (Kira pings for it)"),
("extra", "extra", "Kind: Horizon-only, off by default"),
("tooling", "tooling", "Kind: tools, CI, tests, harnesses"),
("docs", "documentation", "Improvements or additions to documentation"),
]
KIND_LABELS = {label for _, label, _ in KINDS}
_G = r"crates/horizon-client(?:-render|-core|-input)?/src/" # the client's crates
_GAME = _G + r"(?:game/)?"
# Area: (label, description, the paths). First match wins for each file.
AREAS = [
("launcher", "Changes the launcher", r"^launcher/"),
("mods", "Area: mods, mod SDK, modpack porting",
r"^crates/(?:horizon-mod|cargo-horizon-mod|horizon-port-pack)|" + _GAME
+ r"(?:mods|mod_content|mod_list|native_\w+)\.rs$|^docs/(?:mods|native-mods|modpacks)"),
("worldgen", "Area: world generation", r"^crates/horizon-world/src/worldgen/|/vanillagen\.rs$"),
("mobs", "Area: mobs, their AI, spawning and models",
r"/mobs/|/mobs\.rs$|" + _GAME + r"(?:creatures|natural_spawning|spawners?|dragon|wither|villagers|piglins|golems"
r"|raids|llamas|wardens?)\.rs$"),
("rendering", "Area: the renderer, shaders, models, particles",
r"\.wgsl$|/gfx(?:\.rs$|/)|/mesher(?:\.rs$|/)|/lod(?:\.rs$|/)|^crates/(?:horizon-shader|horizon-assets|horizon-hdr)|"
+ _GAME + r"(?:particles|block_entity_render|item_render|fog|clouds|camera|first_person|render_tick|item_draw"
r"|sprites|atlas_sources|mipmap|model_library)\.rs$"),
("server", "Area: dedicated and integrated server, hosting",
r"^crates/horizon-server|/dedicated(?:\.rs$|/)|" + _GAME + r"(?:integrated|levels|server_travel)\.rs$"
r"|^docs/hosting"),
("multiplayer", "Area: protocol, LAN, multiplayer client, friends",
r"^crates/(?:horizon-protocol|horizon-friends|horizon-relay)/|/lan/|" + _GAME + r"(?:lan\w*|remote\w*|net)\.rs$"),
("saves", "Area: saving and loading worlds",
r"^crates/horizon-world/src/(?:region|nbt)\.rs$|/datafix/|" + _GAME
+ r"(?:save|saving|persist|entity_nbt|block_entity_nbt)\.rs$"),
("commands", "Area: commands and functions",
r"^crates/horizon-command/|/commands(?:\.rs$|/)|" + _GAME + r"(?:scoreboard_cmd|data_cmd|functions"
r"|command_blocks)\.rs$"),
("audio", "Area: sound and music",
_GAME + r"(?:audio|sound_\w+|music|ambience|subtitle_hud)\.rs$"),
("chunks", "Area: chunk streaming, light, sections, tickets",
_GAME + r"(?:streaming|chunk|chunk_map|light|world|world_loading|ticket_levels|section\w*|\w+_sections"
r"|tick_chunk_membership)\.rs$|^crates/horizon-core/src/paletted"),
("blocks", "Area: blocks, items, redstone, block entities",
r"^crates/horizon-world/src/level/|^crates/horizon-items/|" + _GAME + r"(?:dispenser|machines|moving_pistons"
r"|block_entity_ticks|hand|consume|potions|crossbow|trident|fishing|shield|stations|crafting|menu)\.rs$"),
("ui", "Area: screens, menus, HUD, chat",
r"^crates/horizon-theme/|/title(?:\.rs$|/)|" + _GAME + r"(?:\w+_ui|\w+_screen|screens|screen_draw|ui|hud\w*"
r"|chat|tab_list|startup_menu|options|sign_edit|books|containers)\.rs$"),
("website", "Area: what playhorizon.dev reads from this repository",
r"^docs/api/|^roadmap/|^README\.md$|^launcher/links\.json$"),
]
AREA_RES = [(label, re.compile(paths)) for label, _, paths in AREAS]
MAX_AREAS = 3
KIND_LINE = re.compile(r"^\s*[-*]\s*\[([ xX])\]\s*\**`?(\w+)`?", re.M)
def ticked(body: str | None) -> list[str]:
"""The kind labels ticked in a description's "Kind" section (any order, any number)."""
if not body:
return []
m = re.search(r"^#+\s*Kind\b.*?$(.*?)(?=^#|\Z)", body, re.M | re.S | re.I)
if not m:
return []
by_word = {word: label for word, label, _ in KINDS}
return [by_word[w.lower()] for box, w in KIND_LINE.findall(m.group(1)) if box != " " and w.lower() in by_word]
def area_of(path: str) -> str | None:
return next((label for label, rx in AREA_RES if rx.search(path)), None)
def areas(files: list[tuple[str, int]]) -> list[str]:
"""Areas of a change, from (path, changed lines): the biggest first, each a tenth of the lines or more."""
total = sum(max(n, 1) for _, n in files) or 1
lines: dict[str, int] = {}
for path, n in files:
if (a := area_of(path)):
lines[a] = lines.get(a, 0) + max(n, 1)
big = [a for a, n in sorted(lines.items(), key=lambda kv: -kv[1]) if n * 10 >= total]
return big[:MAX_AREAS]
def kind_change(body: str | None, old_body: str | None, have: set[str]) -> tuple[str | None, str | None]:
"""(label to add, label to remove) for the description's kind. One ticked box only; else nothing."""
now = ticked(body)
if len(now) != 1 or now[0] in have:
return None, None
before = ticked(old_body)
kinds_on = have & KIND_LABELS
if not kinds_on:
return now[0], None
if len(before) == 1 and before[0] in kinds_on and len(kinds_on) == 1:
return now[0], before[0] # the author changed their tick: follow it
return None, None # a kind someone set stays
# --- GitHub ------------------------------------------------------------------------------------------------------
def gh(*args: str) -> str:
r = subprocess.run(["gh", *args], capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"gh {args[0]} {args[1] if len(args) > 1 else ''}: {r.stderr.strip()[:300]}")
return r.stdout.strip()
class Api:
def __init__(self, repo: str):
self.repo = repo
def files(self, n: int) -> list[tuple[str, int]]:
raw = gh("api", "--paginate", f"repos/{self.repo}/pulls/{n}/files?per_page=100", "--jq",
".[] | [.filename, .changes]")
return [tuple(json.loads(line)) for line in raw.splitlines() if line.strip()]
def add(self, n: int, names: list[str]) -> None:
args = ["api", f"repos/{self.repo}/issues/{n}/labels"]
for name in names:
args += ["-f", f"labels[]={name}"]
gh(*args)
def remove(self, n: int, name: str) -> None:
gh("api", "-X", "DELETE", f"repos/{self.repo}/issues/{n}/labels/{name}")
def open_pulls(self) -> list[dict]:
raw = gh("pr", "list", "-R", self.repo, "--state", "open", "--limit", "1000", "--json",
"number,title,body,labels")
return json.loads(raw)
def existing_labels(self) -> set[str]:
return set(gh("label", "list", "-R", self.repo, "--limit", "1000", "--json", "name", "--jq", ".[].name")
.splitlines())
def create_label(self, name: str, color: str, description: str) -> None:
gh("label", "create", name, "-R", self.repo, "--color", color, "--description", description)
def plan_event(event: dict, files: list[tuple[str, int]] | None) -> tuple[list[str], str | None]:
"""(labels to add, label to remove) for one pull_request_target event. `files` is None when they didn't change."""
pr = event["pull_request"]
have = {l["name"] for l in pr.get("labels", [])}
old_body = ((event.get("changes") or {}).get("body") or {}).get("from")
if event.get("action") == "edited" and old_body is None:
old_body = pr.get("body") # a title edit: the tick didn't move
add, remove = kind_change(pr.get("body"), old_body, have)
new = [add] if add else []
if files is not None:
new += [a for a in areas(files) if a not in have and a not in new]
return new, remove
def event(api) -> int:
with open(os.environ["GITHUB_EVENT_PATH"]) as f:
ev = json.load(f)
n = ev["pull_request"]["number"]
files = None if ev.get("action") == "edited" else api.files(n)
add, remove = plan_event(ev, files)
if remove:
api.remove(n, remove)
if add:
api.add(n, add)
print(f"#{n}: +{add} -{remove or []}")
return 0
def guess_kind(title: str, body: str | None, files: list[tuple[str, int]]) -> str | None:
"""The kind of an open pull request without a ticked box, only where it is clear; None when unsure."""
if (t := ticked(body)) and len(t) == 1:
return t[0]
paths = [p for p, _ in files]
if paths and all(p.endswith(".md") or p.startswith("docs/") for p in paths):
return "documentation"
if paths and all(re.match(r"^(?:tools/|tests/|\.github/|\.githooks/|xtask/|mods/horizon/src/test/)", p)
for p in paths):
return "tooling"
if re.search(r"^perf\b|\b(?:faster|stutters?|allocations?|fps|mspt)\b", title, re.I):
return "perf"
if re.search(r"\bcrash", title, re.I):
return "bug"
return None
def backfill(api, apply: bool) -> list[tuple[int, list[str]]]:
out = []
for pr in api.open_pulls():
have = {l["name"] for l in pr["labels"]}
files = api.files(pr["number"])
add = []
if not have & KIND_LABELS and (k := guess_kind(pr["title"], pr["body"], files)):
add.append(k)
add += [a for a in areas(files) if a not in have]
if add:
out.append((pr["number"], add))
if apply:
api.add(pr["number"], add)
return out
def create(api, apply: bool) -> list[str]:
have = {n.lower() for n in api.existing_labels()}
todo = [(label, "5319e7", desc) for _, label, desc in KINDS if label not in have]
todo += [(label, "c5def5", desc) for label, desc, _ in AREAS if label not in have]
for label, color, desc in todo if apply else []:
api.create_label(label, color, desc)
return [t[0] for t in todo]
if __name__ == "__main__":
verb, rest = (sys.argv[1:2] or [""])[0], sys.argv[2:]
if verb == "event" and not rest:
sys.exit(event(Api(os.environ["REPO"])))
if verb in ("create", "backfill") and rest in ([], ["--apply"]):
api, apply = Api(os.environ.get("REPO", "horizon-rust/horizon")), rest == ["--apply"]
for line in (create(api, apply) if verb == "create" else backfill(api, apply)):
print(line)
sys.exit(0)
sys.exit(__doc__)