diff --git a/.github/settings.yml b/.github/settings.yml index d223212..93e402e 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -24,12 +24,10 @@ repository: delete_branch_on_merge: true allow_update_branch: true -# Labels that should exist in every repo. -# -# NOT YET APPLIED. `apply-settings.py` acts on `repository`, `branches` and `rulesets` -# only, so these labels are still created per repo by hand with `gh label create`. This -# block is the canonical set; teaching the reconciler to apply it is the remaining work on -# https://github.com/swimblocks/.github/issues/46, which stays open until it lands. +# Labels that should exist in every repo. Created when missing and then left alone — +# the values here seed a new label, they don't govern an existing one. Labels not +# listed are untouched: repos carry GitHub's defaults and Dependabot's too, and +# deleting a label strips it from every issue and PR that used it. labels: - name: no-issue color: ededed diff --git a/scripts/apply-settings.py b/scripts/apply-settings.py index 00a87b7..7e9f073 100644 --- a/scripts/apply-settings.py +++ b/scripts/apply-settings.py @@ -26,8 +26,8 @@ # Only fields under `repository:` that we know how to PATCH via # `PATCH /repos/{owner}/{repo}`. Anything else in the YAML is left for a -# future iteration (branches, labels, collaborators — Probot Settings' -# other top-level sections). +# future iteration (collaborators and Probot Settings' other top-level +# sections); `branches`, `rulesets` and `labels` have their own handling below. PATCHABLE = { "allow_squash_merge", "allow_merge_commit", @@ -89,6 +89,102 @@ def verify(repo: str, repo_block: dict) -> bool: return ok +# --------------------------------------------------------------------------- +# Labels +# +# Create-if-missing, and nothing else. The values here seed a new label; they do +# not govern an existing one. A repo that recolours its labels to group them +# visually is doing something useful, and a weekly job reverting that would be +# churn — colour carries no policy weight. What does carry weight is that the +# label *exists*, since a PR cannot be given a label the repo doesn't have. +# +# Nor are unlisted labels removed: repos carry GitHub's defaults and the ones +# Dependabot creates, and deleting a label strips it from every issue and PR +# that used it. `settings.yml` is the minimum set, not the whole set. +# --------------------------------------------------------------------------- + +def normalize_color(value: str | None) -> str: + """GitHub stores label colours as six lowercase hex digits with no '#'.""" + return str(value or "").lstrip("#").lower() + + +def list_labels(repo: str) -> dict[str, dict]: + """Every label on *repo*, keyed by lowercased name. + + GitHub treats label names case-insensitively, so the key is lowercased to + stop a `No-Issue` on the repo reading as missing against a `no-issue` here. + """ + # `--jq .[]` emits one compact object per line, which pages cleanly; + # `--paginate` alone would concatenate raw JSON arrays. + out = subprocess.run( + ["gh", "api", f"repos/{repo}/labels", "--paginate", "--jq", ".[]"], + check=True, capture_output=True, text=True, + ).stdout + labels = [json.loads(line) for line in out.splitlines() if line.strip()] + return {label["name"].lower(): label for label in labels} + + +def missing_labels(desired: list[dict], actual: dict[str, dict]) -> list[dict]: + """The entries of *desired* that *actual* has no label for.""" + return [d for d in desired if d.get("name") and d["name"].lower() not in actual] + + +def apply_labels(repo: str, labels_block: list[dict]) -> bool: + """Create any label in *labels_block* the repo lacks. Returns False on failure.""" + if not labels_block: + return True + try: + actual = list_labels(repo) + except subprocess.CalledProcessError as e: + print(f" FAIL labels: could not list (exit {e.returncode})") + return False + + ok = True + for desired in missing_labels(labels_block, actual): + name = desired["name"] + try: + subprocess.run( + ["gh", "api", "-X", "POST", f"repos/{repo}/labels", + "-f", f"name={name}", + "-f", f"color={normalize_color(desired.get('color'))}", + "-f", f"description={desired.get('description') or ''}"], + check=True, stdout=subprocess.DEVNULL, + ) + print(f" OK label '{name}': created") + except subprocess.CalledProcessError as e: + print(f" FAIL label '{name}': create failed (exit {e.returncode})") + ok = False + return ok + + +def verify_labels(repo: str, labels_block: list[dict]) -> bool: + """Re-read the labels and confirm each one exists. + + Presence is the whole assertion — this only ever creates — so checking + presence checks everything claimed. (Contrast `verify_ruleset`, where the + rules are the substance and go unchecked: https://github.com/swimblocks/.github/issues/45) + """ + if not labels_block: + return True + try: + actual = list_labels(repo) + except subprocess.CalledProcessError as e: + print(f" FAIL labels: could not list (exit {e.returncode})") + return False + + ok = True + for desired in labels_block: + name = desired.get("name") + if not name: + continue + if name.lower() in actual: + print(f" OK label '{name}': present") + else: + print(f" FAIL label '{name}': missing") + ok = False + return ok + + # Top-level keys of the branch protection PUT body. Anything not in this set is # rejected by the API, so we filter `protection:` in the YAML down to these. PROTECTION_KEYS = { @@ -225,6 +321,7 @@ def main(argv: list[str]) -> int: args = patch_args(repo_block) rulesets_block = settings.get("rulesets") or [] + labels_block = settings.get("labels") or [] failures: list[str] = [] for repo in argv[1:]: @@ -233,6 +330,12 @@ def main(argv: list[str]) -> int: if not verify(repo, repo_block): failures.append(repo) + # Labels do not depend on visibility — private repos get them too. + if labels_block: + apply_labels(repo, labels_block) + if not verify_labels(repo, labels_block) and repo not in failures: + failures.append(repo) + is_public = get_repo_visibility(repo) == "public" if is_public and rulesets_block: diff --git a/tests/test_apply_settings.py b/tests/test_apply_settings.py index 5c7b826..96ea6a3 100644 --- a/tests/test_apply_settings.py +++ b/tests/test_apply_settings.py @@ -84,3 +84,52 @@ def test_non_wrapper_dict_passes_through_unchanged(self): def test_output_always_covers_every_protection_key(self): flat = apply_settings._flatten_protection({}) assert set(flat) == apply_settings.PROTECTION_KEYS + + +class TestNormalizeColor: + def test_strips_leading_hash(self): + assert apply_settings.normalize_color("#EDEDED") == "ededed" + + def test_lowercases(self): + assert apply_settings.normalize_color("A2EEEF") == "a2eeef" + + def test_already_normal_is_unchanged(self): + assert apply_settings.normalize_color("ededed") == "ededed" + + def test_none_becomes_empty(self): + assert apply_settings.normalize_color(None) == "" + + +class TestMissingLabels: + _DESIRED = [ + {"name": "no-issue", "color": "ededed", "description": "Skips the issue"}, + ] + + def test_absent_label_is_reported(self): + assert apply_settings.missing_labels(self._DESIRED, {}) == self._DESIRED + + def test_present_label_is_not_reported(self): + actual = {"no-issue": {"color": "ededed", "description": "Skips the issue"}} + assert apply_settings.missing_labels(self._DESIRED, actual) == [] + + def test_name_match_is_case_insensitive(self): + # GitHub label names are case-insensitive, so a differently-cased + # label on the repo must not read as missing. + actual = {"no-issue": {"color": "ededed"}} + desired = [{"name": "No-Issue", "color": "ededed"}] + assert apply_settings.missing_labels(desired, actual) == [] + + def test_drifted_colour_is_not_treated_as_missing(self): + # Existing labels are left alone; only absence is acted on. + actual = {"no-issue": {"color": "ff0000", "description": "something else"}} + assert apply_settings.missing_labels(self._DESIRED, actual) == [] + + def test_entry_without_a_name_is_skipped(self): + assert apply_settings.missing_labels([{"color": "ededed"}], {}) == [] + + def test_only_the_absent_entries_are_returned(self): + desired = [{"name": "no-issue"}, {"name": "needs-decision"}] + actual = {"no-issue": {"color": "ededed"}} + assert apply_settings.missing_labels(desired, actual) == [ + {"name": "needs-decision"}, + ]