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
10 changes: 4 additions & 6 deletions .github/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 105 additions & 2 deletions scripts/apply-settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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:]:
Expand All @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions tests/test_apply_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
]
Loading