From 11670a4c4087812bcd910ec206da33cbf87bedbb Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:03:06 +0200 Subject: [PATCH] feat(ra): create every label release automation applies before applying it --- release_automation/docs/repository-setup.md | 2 +- release_automation/scripts/common_sync.py | 6 +- release_automation/scripts/issue_sync.py | 21 +--- release_automation/scripts/labels.py | 61 ++++++++++ release_automation/scripts/sync_pr.py | 6 +- release_automation/tests/test_common_sync.py | 10 ++ release_automation/tests/test_issue_sync.py | 37 +----- release_automation/tests/test_labels.py | 116 +++++++++++++++++++ release_automation/tests/test_sync_pr.py | 13 ++- 9 files changed, 208 insertions(+), 64 deletions(-) create mode 100644 release_automation/scripts/labels.py create mode 100644 release_automation/tests/test_labels.py diff --git a/release_automation/docs/repository-setup.md b/release_automation/docs/repository-setup.md index c76ebbe..abde6dd 100644 --- a/release_automation/docs/repository-setup.md +++ b/release_automation/docs/repository-setup.md @@ -16,7 +16,7 @@ This document defines the required configuration for each API repository. It ser | Item | Mechanism | |------|-----------| -| 6 labels | Auto-created on first use (`release-issue` + 5 state labels) | +| 9 labels | Created on first use if missing, never modified: `release-issue` + 5 state labels on the Release Issue; `common-sync`, `post-release`, `automated` on sync PRs | | Concurrency | `concurrency:` block in reusable workflow (one run per repo) | | Permissions | `permissions:` block in caller workflow | diff --git a/release_automation/scripts/common_sync.py b/release_automation/scripts/common_sync.py index 281e89a..a6c5a03 100644 --- a/release_automation/scripts/common_sync.py +++ b/release_automation/scripts/common_sync.py @@ -29,6 +29,7 @@ import yaml from release_automation.scripts.git_operations import GitOperationsError +from release_automation.scripts.labels import add_pr_labels from tooling_lib.cache_sync import ( COMMON_DIR, MANIFEST_FILENAME, @@ -313,10 +314,7 @@ def sync_common(api_git, commonalities_git, gh, release: str, bot_name: str, bot except Exception as e: print(f"::error::Failed to create sync PR: {e}") return CommonSyncOutcome(status="failed", unexpected_files=unexpected) - try: - gh.add_pr_labels(number, PR_LABELS) - except Exception as e: - print(f"Could not add labels: {e}") + add_pr_labels(gh, number, PR_LABELS) print(f"Sync PR created: {url}") return CommonSyncOutcome(status="created", pr_url=url, pr_number=str(number), unexpected_files=unexpected) diff --git a/release_automation/scripts/issue_sync.py b/release_automation/scripts/issue_sync.py index 1914b2c..12c389c 100644 --- a/release_automation/scripts/issue_sync.py +++ b/release_automation/scripts/issue_sync.py @@ -17,6 +17,7 @@ from .state_manager import ReleaseState, ReleaseStateManager from .issue_manager import IssueManager from .bot_responder import BotResponder +from .labels import RELEASE_ISSUE_LABELS, ensure_labels # Marker to identify workflow-owned issues @@ -25,18 +26,6 @@ r"" ) -# Required labels for release automation -# Format: (name, color_hex_without_hash, description) -REQUIRED_LABELS = [ - ("release-issue", "5319E7", "Release tracking issue managed by automation"), - ("release-state:planned", "0E8A16", "Release is planned"), - ("release-state:snapshot-active", "FBCA04", "Release snapshot is active"), - ("release-state:draft-ready", "1D76DB", "Draft release is ready"), - ("release-state:published", "0E8A16", "Release has been published"), - ("release-state:not-planned", "C2C9D1", "No release is currently planned"), -] - - @dataclass class SyncResult: """ @@ -105,13 +94,7 @@ def ensure_labels_exist(self) -> List[str]: if self._labels_ensured: return [] - created = [] - for name, color, description in REQUIRED_LABELS: - existing = self.gh.get_label(name) - if existing is None: - self.gh.create_label(name, color, description) - created.append(name) - + created = ensure_labels(self.gh, RELEASE_ISSUE_LABELS) self._labels_ensured = True return created diff --git a/release_automation/scripts/labels.py b/release_automation/scripts/labels.py new file mode 100644 index 0000000..57e1aba --- /dev/null +++ b/release_automation/scripts/labels.py @@ -0,0 +1,61 @@ +""" +Labels that release automation applies. + +Template repositories don't copy labels, so a new API repository has none of +these; release automation creates a missing label before it applies it. An +existing label is never modified (project-wide labels are maintained centrally). + +Each label is looked up by name: the repository label list pages at 30 and +some repositories carry more than 100 labels, so listing and diffing would +miss labels and try to recreate them. +""" + +from typing import List, Sequence + +from .github_client import GitHubClientError + +# (name, color hex without '#', description) +LABELS = [ + ("release-issue", "5319E7", "Release tracking issue managed by automation"), + ("release-state:planned", "0E8A16", "Release is planned"), + ("release-state:snapshot-active", "FBCA04", "Release snapshot is active"), + ("release-state:draft-ready", "1D76DB", "Draft release is ready"), + ("release-state:published", "0E8A16", "Release has been published"), + ("release-state:not-planned", "C2C9D1", "No release is currently planned"), + ("common-sync", "C5DEF5", "Sync of code/common from Commonalities"), + ("automated", "D1D5DB", "Created by automation"), + ("post-release", "BFD4F2", "Post-release sync of README and CHANGELOG"), +] + +_BY_NAME = {name: (color, description) for name, color, description in LABELS} + +RELEASE_ISSUE_LABELS = [ + name for name, _, _ in LABELS + if name == "release-issue" or name.startswith("release-state:") +] + + +def _create_if_missing(gh, name: str) -> bool: + color, description = _BY_NAME[name] + if gh.get_label(name) is not None: + return False + gh.create_label(name, color, description) + return True + + +def ensure_labels(gh, names: Sequence[str]) -> List[str]: + """Create the named labels that don't exist yet; returns the created names.""" + return [name for name in names if _create_if_missing(gh, name)] + + +def add_pr_labels(gh, pr_number: int, names: Sequence[str]) -> None: + """Label a PR, creating missing labels first; failures are warnings.""" + for name in names: + try: + _create_if_missing(gh, name) + except GitHubClientError as e: + print(f"::warning::Could not create label {name}: {e}") + try: + gh.add_pr_labels(pr_number, list(names)) + except GitHubClientError as e: + print(f"::warning::Could not add labels {', '.join(names)} to #{pr_number}: {e}") diff --git a/release_automation/scripts/sync_pr.py b/release_automation/scripts/sync_pr.py index f4d26bc..fb5d358 100644 --- a/release_automation/scripts/sync_pr.py +++ b/release_automation/scripts/sync_pr.py @@ -18,6 +18,7 @@ from .git_operations import GitOperationsError from .github_client import GitHubClientError +from .labels import add_pr_labels RELEASE_INFO_START = "" RELEASE_INFO_END = "" @@ -174,9 +175,6 @@ def create_sync_pr(gh, git, tag: str, bot_name: str, bot_email: str) -> SyncPrOu print(f"PR creation failed: {e}") return _failed() - try: - gh.add_pr_labels(number, PR_LABELS) - except GitHubClientError as e: - print(f"Could not add labels: {e}") + add_pr_labels(gh, number, PR_LABELS) print(f"Sync PR created: {url}") return SyncPrOutcome(status="created", pr_url=url, pr_number=str(number)) diff --git a/release_automation/tests/test_common_sync.py b/release_automation/tests/test_common_sync.py index 73296b0..172852d 100644 --- a/release_automation/tests/test_common_sync.py +++ b/release_automation/tests/test_common_sync.py @@ -173,6 +173,8 @@ def __init__(self, open_prs=(), fail_create=False, fail_close=False): self.closed = [] self.created = [] self.labelled = [] + self.labels = {"automated"} + self.created_labels = [] def find_open_pr(self, head_branch): for pr in self.prs: @@ -194,6 +196,13 @@ def create_pr(self, title, body, head, base): self.created.append({"title": title, "body": body, "head": head, "base": base}) return 40, "https://github.com/camaraproject/Example/pull/40" + def get_label(self, name): + return {"name": name} if name in self.labels else None + + def create_label(self, name, color, description=""): + self.labels.add(name) + self.created_labels.append(name) + def add_pr_labels(self, pr_number, labels): self.labelled.append((pr_number, list(labels))) @@ -314,6 +323,7 @@ def test_sync_copies_files_writes_manifest_and_opens_pr(tmp_path, remotes): "head": branch, "base": "main", }] + assert gh.created_labels == ["common-sync"] assert gh.labelled == [(40, ["common-sync", "automated"])] diff --git a/release_automation/tests/test_issue_sync.py b/release_automation/tests/test_issue_sync.py index 8e44387..0527e82 100644 --- a/release_automation/tests/test_issue_sync.py +++ b/release_automation/tests/test_issue_sync.py @@ -9,11 +9,11 @@ from release_automation.scripts.github_client import GitHubClientError from release_automation.scripts.issue_manager import IssueManager +from release_automation.scripts.labels import RELEASE_ISSUE_LABELS from release_automation.scripts.issue_sync import ( IssueSyncManager, SyncResult, WORKFLOW_MARKER, - REQUIRED_LABELS, post_publish_warnings, ) from release_automation.scripts.state_manager import ReleaseInfoResult, ReleaseState @@ -654,39 +654,6 @@ def test_marker_format(self): assert WORKFLOW_MARKER.endswith("-->") -class TestRequiredLabels: - """Tests for REQUIRED_LABELS constant.""" - - def test_required_labels_defined(self): - """Test required labels are defined.""" - assert len(REQUIRED_LABELS) == 6 - - def test_required_labels_have_correct_format(self): - """Test each label has (name, color, description) format.""" - for label in REQUIRED_LABELS: - assert len(label) == 3 - name, color, description = label - assert isinstance(name, str) - assert isinstance(color, str) - assert isinstance(description, str) - # Color should be 6 hex chars - assert len(color) == 6 - - def test_required_labels_include_release_issue(self): - """Test release-issue label is included.""" - names = [l[0] for l in REQUIRED_LABELS] - assert "release-issue" in names - - def test_required_labels_include_all_states(self): - """Test all state labels are included.""" - names = [l[0] for l in REQUIRED_LABELS] - assert "release-state:planned" in names - assert "release-state:snapshot-active" in names - assert "release-state:draft-ready" in names - assert "release-state:published" in names - assert "release-state:not-planned" in names - - class TestEnsureLabelsExist: """Tests for ensure_labels_exist method.""" @@ -740,7 +707,7 @@ def test_returns_created_label_names(self): manager = IssueSyncManager(gh, MagicMock(), MagicMock(), MagicMock()) created = manager.ensure_labels_exist() - expected_names = [l[0] for l in REQUIRED_LABELS] + expected_names = RELEASE_ISSUE_LABELS assert sorted(created) == sorted(expected_names) def test_passes_correct_color_and_description(self): diff --git a/release_automation/tests/test_labels.py b/release_automation/tests/test_labels.py new file mode 100644 index 0000000..353533b --- /dev/null +++ b/release_automation/tests/test_labels.py @@ -0,0 +1,116 @@ +"""Tests for labels.py (the labels release automation applies).""" + +import pytest + +from release_automation.scripts.github_client import GitHubClientError +from release_automation.scripts.labels import ( + LABELS, + RELEASE_ISSUE_LABELS, + add_pr_labels, + ensure_labels, +) + + +class FakeGitHub: + def __init__(self, existing=(), fail_create=False, fail_add=False): + self.existing = set(existing) + self.fail_create = fail_create + self.fail_add = fail_add + self.looked_up = [] + self.created = [] + self.labelled = [] + + def get_label(self, name): + self.looked_up.append(name) + return {"name": name} if name in self.existing else None + + def create_label(self, name, color, description=""): + if self.fail_create: + raise GitHubClientError("gh command failed: 422") + self.created.append((name, color, description)) + self.existing.add(name) + + def add_pr_labels(self, pr_number, labels): + if self.fail_add: + raise GitHubClientError("gh command failed: label not found") + self.labelled.append((pr_number, list(labels))) + + +def test_table_covers_every_label_ra_applies(): + names = [name for name, _, _ in LABELS] + + assert names == [ + "release-issue", + "release-state:planned", + "release-state:snapshot-active", + "release-state:draft-ready", + "release-state:published", + "release-state:not-planned", + "common-sync", + "automated", + "post-release", + ] + for _, color, description in LABELS: + assert len(color) == 6 and int(color, 16) >= 0 + assert description + + +def test_release_issue_labels_are_the_issue_and_state_labels(): + assert RELEASE_ISSUE_LABELS == [ + "release-issue", + "release-state:planned", + "release-state:snapshot-active", + "release-state:draft-ready", + "release-state:published", + "release-state:not-planned", + ] + + +def test_ensure_labels_creates_only_missing_ones(): + gh = FakeGitHub(existing={"automated"}) + + created = ensure_labels(gh, ["common-sync", "automated"]) + + assert created == ["common-sync"] + assert gh.looked_up == ["common-sync", "automated"] + assert [name for name, _, _ in gh.created] == ["common-sync"] + + +def test_ensure_labels_uses_table_color_and_description(): + gh = FakeGitHub() + + ensure_labels(gh, ["release-issue"]) + + assert gh.created == [("release-issue", "5319E7", "Release tracking issue managed by automation")] + + +def test_ensure_labels_rejects_a_name_outside_the_table(): + with pytest.raises(KeyError): + ensure_labels(FakeGitHub(), ["needs-triage"]) + + +def test_add_pr_labels_creates_then_labels(): + gh = FakeGitHub() + + add_pr_labels(gh, 31, ["post-release", "automated"]) + + assert [name for name, _, _ in gh.created] == ["post-release", "automated"] + assert gh.labelled == [(31, ["post-release", "automated"])] + + +def test_add_pr_labels_failure_is_a_warning(capsys): + gh = FakeGitHub(existing={"post-release", "automated"}, fail_add=True) + + add_pr_labels(gh, 31, ["post-release", "automated"]) + + out = capsys.readouterr().out + assert "::warning::Could not add labels post-release, automated to #31: gh command failed: label not found" in out + + +def test_label_creation_failure_is_a_warning_and_labelling_still_tried(capsys): + gh = FakeGitHub(existing={"automated"}, fail_create=True) + + add_pr_labels(gh, 31, ["post-release", "automated"]) + + assert "::warning::Could not create label post-release: gh command failed: 422" in capsys.readouterr().out + assert gh.labelled == [(31, ["post-release", "automated"])] diff --git a/release_automation/tests/test_sync_pr.py b/release_automation/tests/test_sync_pr.py index 0c77d8b..eae7f0f 100644 --- a/release_automation/tests/test_sync_pr.py +++ b/release_automation/tests/test_sync_pr.py @@ -40,6 +40,8 @@ def __init__(self, files=None, open_prs=None, fail_create=False, fail_labels=Fal self.fail_labels = fail_labels self.created = [] self.labelled = [] + self.labels = set() + self.created_labels = [] def get_file_content(self, path, ref="main", strict=False): return self.files.get((ref, path)) @@ -53,6 +55,13 @@ def create_pr(self, title, body, head, base): self.created.append({"title": title, "body": body, "head": head, "base": base}) return 31, "https://github.com/camaraproject/Example/pull/31" + def get_label(self, name): + return {"name": name} if name in self.labels else None + + def create_label(self, name, color, description=""): + self.labels.add(name) + self.created_labels.append(name) + def add_pr_labels(self, pr_number, labels): if self.fail_labels: raise GitHubClientError("gh command failed: label not found") @@ -103,6 +112,7 @@ def test_readme_block_and_cycle_changelog_from_tag(tmp_path, remote): "head": "pr-to-main/r1.3", "base": "main", }] + assert gh.created_labels == ["post-release", "automated"] assert gh.labelled == [(31, ["post-release", "automated"])] @@ -177,10 +187,11 @@ def test_pr_creation_failure_is_failed(tmp_path, remote, capsys): assert "::error::Failed to create post-release sync PR" in capsys.readouterr().out -def test_label_failure_is_ignored(tmp_path, remote): +def test_label_failure_is_a_warning(tmp_path, remote, capsys): gh = FakeGitHub(files=tag_files(**{"README.md": TAG_README}), fail_labels=True) assert run(tmp_path, remote, gh).status == "created" + assert "::warning::Could not add labels post-release, automated to #31" in capsys.readouterr().out def test_clone_failure_is_failed(tmp_path, capsys):