Skip to content

feat(area-label): deterministic path sub-labels alongside the classified area - #331

Merged
mattmillerai merged 1 commit into
mainfrom
matt/area-label-path-sub-labels
Sep 19, 2026
Merged

mattmillerai merged 1 commit into
mainfrom
matt/area-label-path-sub-labels

Conversation

@mattmillerai

Copy link
Copy Markdown
Member

ELI-5

The area classifier picks exactly one area:* label and deletes every other one. That is the right shape for "which area owns this PR?" — but it leaves a subsystem that lives inside another area's service with no way to stay filterable. Both options are bad:

  • leave it under its parent's label → those PRs are invisible as a group;
  • promote it to its own area → those PRs now drop out of the parent's filter, which is exactly what the parent's reviewers want to keep seeing.

The fix is that it was never a one-or-the-other question. "Which area owns this?" is a judgement call, which is why a model makes it. "Does this PR touch X?" is a path question, and a path question has a deterministic answer that can ride alongside the classification instead of competing with it.

So: an optional sub_labels: block in the taxonomy. Each entry is an area:* name carrying paths: globs, matched against the PR's changed files — no model, no extra API call, no extra cost. A PR touching those paths ends up with the classified area and the sub-label.

What changed

scripts/area-label/lib.sh — four new pure functions, all hermetically tested:

  • taxonomy_sub_labels — parses the optional block; [] when absent.
  • validate_sub_labels — the gate. Names must be canonical area:* slugs, unique, disjoint from labels[], each with at least one non-blank glob.
  • glob_to_regex — segment-aware glob → anchored regex. */? stay within a segment, ** crosses them, a leading **/ matches zero or more directories. Deliberately the same semantics a workflow paths: filter uses, so a glob can be copied between the two. Everything else that means something to a regex is escaped.
  • matched_sub_labels — the names whose globs hit at least one changed file, in taxonomy order.

scripts/area-label/classify-and-apply.sh — the end state is now KEEP = the classified area plus every match. That one value drives all three decisions: the no-op short-circuit, the additive POST, and the cleanup's exemption list. The matched names are exempt from the area:* cleanup; a sub-label that stops matching is not in KEEP, so the same loop retires it automatically when a PR drops those files.

The sub-label POST is deliberately separate from the primary one, so the existing "add the classified area FIRST, so no failure can leave a PR unlabeled" guarantee is untouched — a hiccup adding a sub-label can never cost the PR its area.

scripts/area-label/sync-labels.sh — sub-labels are ordinary repository labels; only how they're applied differs. They're created and healed from the taxonomy exactly like the classified ones.

Why the names are constrained

^area:[a-z0-9-]+$ and disjoint from labels[] is load-bearing twice:

  1. Every write this tool makes stays inside the area:* namespace. These names are handed to gh label create --force and to the label delete path, so an unconstrained name could overwrite or remove an unrelated repository label.
  2. No label can be both "the classification" and "an exempt sub-label" — which would make the cleanup's "is this stale?" question ambiguous.

A malformed block skips the run with a warning rather than labeling under half a taxonomy, matching the existing validate_names / validate_vocab gates.

Opt-in and backwards compatible: a taxonomy without the key parses as an empty set and behaves exactly as before. No caller change is needed to adopt it.

Tests

scripts/area-label/tests/test_apply.sh is new. classify-and-apply.sh had zero coverage — the lib suite only ever exercised the pure core — and this change lands squarely in its label-writing path, which is the part that can do damage across every consuming repo.

gh and curl are stubbed, and the fake gh holds the PR's label set in a file that POST/DELETE actually mutate. That's what makes the assertions end states rather than a transcript diff:

  • a PR matching the globs gets both labels;
  • one that matches nothing gets only the classified area;
  • re-running on a correct PR is a no-op — not one API write;
  • a sub-label that no longer matches is retired;
  • a misclassified PR is corrected without losing its matched sub-label;
  • non-area: labels survive the cleanup untouched;
  • dry-run writes nothing but prints the full decision;
  • and the fail-soft paths (no API key, malformed sub_labels, HTTP 500) all write nothing.

tests/test_lib.sh gains 23 assertions over the new functions, including the glob semantics table and the disjointness gate.

CI now loops tests/*.sh instead of naming one file — bash a.sh b.sh runs only a.sh, so a listed second suite would have been silently skipped. This is the convention AGENTS.md already documents for shell suites.

Non-vacuity

Mutating matched_sub_labels to never match kills 9 assertions in test_apply.sh and 7 in test_lib.sh. Restored, both suites are green.

test_apply.sh: 20 passed, 0 failed
test_lib.sh:   55 passed, 0 failed
shellcheck -x scripts/area-label/*.sh scripts/area-label/tests/*.sh   # clean

Also green: check_workflow_pins.py, check-org-repo-literals.sh, check_agents_md.py.

A glob_to_regex bug worth noting, since shellcheck caught it and a test now pins it: a lone backslash written as '\\' in a case pattern is two characters and can never match a one-character input, so a literal \ in a glob would have gone unescaped into the regex.

Docs

scripts/area-label/README.md, docs/callers/pr-area-label.md (including the glob semantics and the honest limits — matching is paths-only, and the changed-file list comes from gh pr view --json files, which caps at 100), the README catalog row, and the reusable's header contract.

🤖 Generated with Claude Code

…ied area

The classifier picks exactly one `area:*` and deletes every other one, so a
subsystem that lives INSIDE another area's service has no way to stay
filterable: it is either invisible under its parent's label, or promoted to its
own area and thereby hidden FROM that parent's filter.

Add an optional `sub_labels[]` block to the taxonomy. Each entry is an `area:*`
name carrying `paths:` globs, matched against the PR's changed files — no model
involved, because "does this PR touch X?" is a path question, not the
domain-vs-path judgement call `labels[]` exists for. Matches ride alongside the
classified area and are exempt from the one-area cleanup; a sub-label that stops
matching is retired by that same cleanup on the next run.

Names are held to the same `^area:[a-z0-9-]+$` shape and must be disjoint from
`labels[]`, so every write stays inside the `area:*` namespace and no label can
be both "the classification" and "an exempt sub-label". A malformed block skips
the run with a warning rather than labeling under half a taxonomy, matching the
existing validate_names / validate_vocab gates. sync-labels creates sub-labels
from the taxonomy exactly as it does the classified ones.

Opt-in and backwards compatible: a taxonomy without the key parses as an empty
set and behaves exactly as before.

Also adds tests/test_apply.sh — classify-and-apply.sh had no coverage, and this
change lands in its label-write path. `gh` and `curl` are stubbed, with the stub
`gh` holding the PR's label set in a file so POST/DELETE actually move it; the
suite asserts end states (both labels applied, idempotence, stale sub-label
retired, wrong area corrected without losing the sub-label, non-area labels
untouched, and the fail-soft paths). CI now loops `tests/*.sh` rather than
naming one file, per the repo's shell-suite convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: d26817a6-9958-4012-86c7-6b036048308c

📥 Commits

Reviewing files that changed from the base of the PR and between 2c6be66 and 233dd8a.

📒 Files selected for processing (10)
  • .github/workflows/pr-area-label.yml
  • .github/workflows/test-area-label.yml
  • README.md
  • docs/callers/pr-area-label.md
  • scripts/area-label/README.md
  • scripts/area-label/classify-and-apply.sh
  • scripts/area-label/lib.sh
  • scripts/area-label/sync-labels.sh
  • scripts/area-label/tests/test_apply.sh
  • scripts/area-label/tests/test_lib.sh
 ________________________________________________________________
< Fully armed and operationally intelligent code reviewer bunny. >
 ----------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
✨ Simplify code
  • Commit to this branch
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@mattmillerai
mattmillerai merged commit f7de5c4 into main Sep 19, 2026
8 of 9 checks passed
@mattmillerai
mattmillerai deleted the matt/area-label-path-sub-labels branch September 19, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants