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
2 changes: 1 addition & 1 deletion release_automation/docs/repository-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
6 changes: 2 additions & 4 deletions release_automation/scripts/common_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
21 changes: 2 additions & 19 deletions release_automation/scripts/issue_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,18 +26,6 @@
r"<!--\s*release-automation:release-tag:(?P<tag>.*?)\s*-->"
)

# 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:
"""
Expand Down Expand Up @@ -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

Expand Down
61 changes: 61 additions & 0 deletions release_automation/scripts/labels.py
Original file line number Diff line number Diff line change
@@ -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}")
6 changes: 2 additions & 4 deletions release_automation/scripts/sync_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from .git_operations import GitOperationsError
from .github_client import GitHubClientError
from .labels import add_pr_labels

RELEASE_INFO_START = "<!-- CAMARA:RELEASE-INFO:START -->"
RELEASE_INFO_END = "<!-- CAMARA:RELEASE-INFO:END -->"
Expand Down Expand Up @@ -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))
10 changes: 10 additions & 0 deletions release_automation/tests/test_common_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)))

Expand Down Expand Up @@ -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"])]


Expand Down
37 changes: 2 additions & 35 deletions release_automation/tests/test_issue_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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):
Expand Down
116 changes: 116 additions & 0 deletions release_automation/tests/test_labels.py
Original file line number Diff line number Diff line change
@@ -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"])]
Loading
Loading