diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index bfba114..568a10e 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -10,13 +10,12 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Install packages + + - name: Install uv run: | - python -m venv venv - . venv/bin/activate - pip install -e .[dev] + curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Run tests run: | - . venv/bin/activate - pytest \ No newline at end of file + uv run --all-extras pytest \ No newline at end of file diff --git a/config.yaml b/config.yaml index c6098ce..3e513b5 100644 --- a/config.yaml +++ b/config.yaml @@ -1,7 +1,7 @@ version: 1 packages: docker: - # available types: docker, debian + # available types: docker, debian, homebrew package_type: docker # repo where the workflow is started repo: redis/docker-library-redis @@ -33,7 +33,7 @@ packages: publish_timeout_minutes: 10 publish_inputs: {} rpm: - package_type: debian + package_type: rpm repo: redis/redis-rpm build_workflow: release_build_and_test.yml build_inputs: {} @@ -41,3 +41,14 @@ packages: publish_workflow: release_publish.yml publish_timeout_minutes: 10 publish_inputs: {} + homebrew: + package_type: homebrew + repo: redis/homebrew-redis + # homebrew has one fixed release branch: main + ref: main + build_workflow: release_build_and_test.yml + build_inputs: {} + publish_internal_release: yes + publish_workflow: release_publish.yml + publish_timeout_minutes: 10 + publish_inputs: {} diff --git a/src/redis_release/bht/behaviours.py b/src/redis_release/bht/behaviours.py index 738e624..b18c4a5 100644 --- a/src/redis_release/bht/behaviours.py +++ b/src/redis_release/bht/behaviours.py @@ -19,6 +19,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional +from py import log from py_trees.behaviour import Behaviour from py_trees.common import Status @@ -26,6 +27,7 @@ from ..github_client_async import GitHubClientAsync from ..models import ( + HomebrewChannel, PackageType, RedisVersion, ReleaseType, @@ -33,7 +35,7 @@ WorkflowStatus, ) from .logging_wrapper import PyTreesLoggerWrapper -from .state import Package, PackageMeta, ReleaseMeta, Workflow +from .state import HomebrewMeta, Package, PackageMeta, ReleaseMeta, Workflow logger = logging.getLogger(__name__) @@ -709,63 +711,21 @@ def __init__( self.workflow = workflow self.package_meta = package_meta self.release_meta = release_meta - super().__init__(name=name, log_prefix=log_prefix) + super().__init__(name=f"{name} - debian", log_prefix=log_prefix) def update(self) -> Status: + if self.package_meta.release_type is not None: + self.workflow.inputs["release_type"] = self.package_meta.release_type.value + if self.release_meta.tag is not None: + self.workflow.inputs["release_tag"] = self.release_meta.tag return Status.SUCCESS -def create_prepare_build_workflow_inputs( - name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, -) -> Behaviour: - cls_map = { - PackageType.DEBIAN: DebianWorkflowInputs, - } - - selected_class = ( - cls_map.get(package_meta.package_type, GenericWorkflowInputs) - if package_meta.package_type - else GenericWorkflowInputs - ) - return selected_class( - name, - workflow, - package_meta, - release_meta, - log_prefix=log_prefix, - ) - - -def create_prepare_publish_workflow_inputs( - name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, -) -> Behaviour: - cls_map = { - PackageType.DEBIAN: DebianWorkflowInputs, - } - - selected_class = ( - cls_map.get(package_meta.package_type, GenericWorkflowInputs) - if package_meta.package_type - else GenericWorkflowInputs - ) - return selected_class( - name, - workflow, - package_meta, - release_meta, - log_prefix=log_prefix, - ) - - -class DebianWorkflowInputs(ReleaseAction): +class DockerWorkflowInputs(ReleaseAction): + """ + Docker uses only release_tag input which is set automatically in TriggerWorkflow + """ + def __init__( self, name: str, @@ -777,16 +737,253 @@ def __init__( self.workflow = workflow self.package_meta = package_meta self.release_meta = release_meta - super().__init__(name=f"{name} - debian", log_prefix=log_prefix) + super().__init__(name=name, log_prefix=log_prefix) + + def update(self) -> Status: + return Status.SUCCESS + + +class HomewbrewWorkflowInputs(ReleaseAction): + def __init__( + self, + name: str, + workflow: Workflow, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.workflow = workflow + self.package_meta = package_meta + self.release_meta = release_meta + super().__init__(name=f"{name} - homebrew", log_prefix=log_prefix) def update(self) -> Status: - if self.release_meta.release_type is not None: - self.workflow.inputs["release_type"] = self.release_meta.release_type.value + if self.package_meta.release_type is not None: + self.workflow.inputs["release_type"] = self.package_meta.release_type.value if self.release_meta.tag is not None: self.workflow.inputs["release_tag"] = self.release_meta.tag + if self.package_meta.homebrew_channel is not None: + self.workflow.inputs["channel"] = self.package_meta.homebrew_channel.value return Status.SUCCESS +class DetectHombrewReleaseAndChannel(ReleaseAction): + def __init__( + self, + name: str, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.package_meta = package_meta + self.release_meta = release_meta + self.release_version: Optional[RedisVersion] = None + super().__init__(name=name, log_prefix=log_prefix) + + def initialise(self) -> None: + if self.release_meta.tag is None: + self.logger.error("Release tag is not set") + return + if self.release_version is not None: + return + + self.feedback_message = "" + try: + self.release_version = RedisVersion.parse(self.release_meta.tag) + except ValueError as e: + self.logger.error(f"Failed to parse release tag: {e}") + return + + def update(self) -> Status: + if self.release_meta.tag is None: + logger.error("Release tag is not set") + return Status.FAILURE + + if ( + self.package_meta.homebrew_channel is not None + and self.package_meta.release_type is not None + ): + pass + else: + assert self.release_version is not None + if self.release_version.is_internal: + self.package_meta.release_type = ReleaseType.INTERNAL + self.package_meta.homebrew_channel = HomebrewChannel.RC + else: + if self.release_version.is_ga: + self.package_meta.release_type = ReleaseType.PUBLIC + self.package_meta.homebrew_channel = HomebrewChannel.STABLE + elif self.release_version.is_rc: + self.package_meta.release_type = ReleaseType.PUBLIC + self.package_meta.homebrew_channel = HomebrewChannel.RC + else: + self.package_meta.release_type = ReleaseType.INTERNAL + self.package_meta.homebrew_channel = HomebrewChannel.RC + self.feedback_message = f"release_type: {self.package_meta.release_type.value}, homebrew_channel: {self.package_meta.homebrew_channel.value}" + + if self.log_once( + "homebrew_channel_detected", self.package_meta.ephemeral.log_once_flags + ): + self.logger.info( + f"Hombrew release_type: {self.package_meta.release_type}, homebrew_channel: {self.package_meta.homebrew_channel}" + ) + + return Status.SUCCESS + + +class ClassifyHomebrewVersion(ReleaseAction): + """Classify Homebrew version by downloading and parsing the cask file. + + This behavior downloads the appropriate Homebrew cask file (redis.rb or redis-rc.rb) + based on the homebrew_channel, extracts the version, and compares it with the + release tag version to determine if the version is acceptable. + """ + + def __init__( + self, + name: str, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + log_prefix: str = "", + ) -> None: + self.package_meta = package_meta + self.release_meta = release_meta + self.github_client = github_client + self.task: Optional[asyncio.Task] = None + self.release_version: Optional[RedisVersion] = None + self.cask_version: Optional[RedisVersion] = None + super().__init__(name=name, log_prefix=log_prefix) + + def initialise(self) -> None: + """Initialize by validating inputs and starting download task.""" + if self.package_meta.ephemeral.is_version_acceptable is not None: + return + + self.feedback_message = "" + # Validate homebrew_channel is set + if self.package_meta.homebrew_channel is None: + self.logger.error("Homebrew channel is not set") + return + + # Validate repo and ref are set + if not self.package_meta.repo: + self.logger.error("Package repository is not set") + return + + if not self.package_meta.ref: + self.logger.error("Package ref is not set") + return + + # Parse release version from tag + if self.release_meta.tag is None: + self.logger.error("Release tag is not set") + return + + if self.package_meta.release_type is None: + self.logger.error("Package release type is not set") + return + + try: + self.release_version = RedisVersion.parse(self.release_meta.tag) + self.logger.debug(f"Parsed release version: {self.release_version}") + except ValueError as e: + self.logger.error(f"Failed to parse release tag: {e}") + return + + # Determine which cask file to download based on channel + if self.package_meta.homebrew_channel == HomebrewChannel.STABLE: + cask_file = "Casks/redis.rb" + elif self.package_meta.homebrew_channel == HomebrewChannel.RC: + cask_file = "Casks/redis-rc.rb" + else: + self.logger.error( + f"Unknown homebrew channel: {self.package_meta.homebrew_channel}" + ) + return + + self.logger.debug( + f"Downloading cask file: {cask_file} from {self.package_meta.repo}@{self.package_meta.ref}" + ) + + # Start async task to download the cask file from package repo and ref + self.task = asyncio.create_task( + self.github_client.download_file( + self.package_meta.repo, cask_file, self.package_meta.ref + ) + ) + + def update(self) -> Status: + """Process downloaded cask file and classify version.""" + if self.package_meta.ephemeral.is_version_acceptable is not None: + return Status.SUCCESS + + try: + assert self.task is not None + + # Wait for download to complete + if not self.task.done(): + return Status.RUNNING + + # Get the downloaded content + cask_content = self.task.result() + if cask_content is None: + self.logger.error("Failed to download cask file") + return Status.FAILURE + + # Parse version from cask file + # Look for: version "X.Y.Z" + version_match = re.search( + r'^\s*version\s+"([^"]+)"', cask_content, re.MULTILINE + ) + if not version_match: + self.logger.error("Could not find version declaration in cask file") + return Status.FAILURE + + version_str = version_match.group(1) + self.logger.debug(f"Found version in cask file: {version_str}") + + # Parse the cask version + try: + self.cask_version = RedisVersion.parse(version_str) + self.logger.info( + f"Cask version: {self.cask_version}, Release version: {self.release_version}" + ) + except ValueError as e: + self.logger.error(f"Failed to parse cask version '{version_str}': {e}") + return Status.FAILURE + + # Compare versions: cask version >= release version means acceptable + assert self.release_version is not None + self.package_meta.remote_version = str(self.cask_version) + log_prepend = "" + prepend_color = "green" + if self.release_version >= self.cask_version: + self.package_meta.ephemeral.is_version_acceptable = True + self.feedback_message = ( + f"release {self.release_version} >= cask {self.cask_version}" + ) + log_prepend = "Version acceptable: " + else: + self.package_meta.ephemeral.is_version_acceptable = False + log_prepend = "Version NOT acceptable: " + prepend_color = "yellow" + self.feedback_message = ( + f"release {self.release_version} < cask {self.cask_version}" + ) + if self.log_once( + "homebrew_version_classified", + self.package_meta.ephemeral.log_once_flags, + ): + self.logger.info( + f"[{prepend_color}]{log_prepend}{self.feedback_message}[/]" + ) + return Status.SUCCESS + + except Exception as e: + return self.log_exception_and_return_failure(e) + + ### Conditions ### @@ -923,7 +1120,7 @@ def __init__( super().__init__(name=name, log_prefix=log_prefix) def update(self) -> Status: - if self.release_meta.release_type == ReleaseType.INTERNAL: + if self.package_meta.release_type == ReleaseType.INTERNAL: if self.package_meta.publish_internal_release: self.logger.debug( f"Internal release requires publishing: {self.release_meta.tag}" @@ -939,28 +1136,33 @@ def update(self) -> Status: class DetectReleaseType(LoggingAction): def __init__( - self, name: str, release_meta: ReleaseMeta, log_prefix: str = "" + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", ) -> None: self.release_meta = release_meta + self.package_meta = package_meta super().__init__(name=name, log_prefix=log_prefix) def update(self) -> Status: - if self.release_meta.release_type is not None: + if self.package_meta.release_type is not None: if self.log_once( - "release_type_detected", self.release_meta.ephemeral.log_once_flags + "release_type_detected", self.package_meta.ephemeral.log_once_flags ): self.logger.info( - f"Detected release type: {self.release_meta.release_type}" + f"Detected release type: {self.package_meta.release_type}" ) return Status.SUCCESS if self.release_meta.tag and re.search(r"-int\d*$", self.release_meta.tag): - self.release_meta.release_type = ReleaseType.INTERNAL + self.package_meta.release_type = ReleaseType.INTERNAL else: - self.release_meta.release_type = ReleaseType.PUBLIC + self.package_meta.release_type = ReleaseType.PUBLIC self.log_once( "release_type_detected", self.release_meta.ephemeral.log_once_flags ) - self.logger.info(f"Detected release type: {self.release_meta.release_type}") + self.logger.info(f"Detected release type: {self.package_meta.release_type}") return Status.SUCCESS @@ -975,3 +1177,21 @@ def update(self) -> Status: if self.package_meta.ephemeral.force_rebuild: return Status.SUCCESS return Status.FAILURE + + +class NeedToReleaseHomebrew(LoggingAction): + def __init__( + self, + name: str, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.package_meta = package_meta + self.release_meta = release_meta + super().__init__(name=name, log_prefix=log_prefix) + + def update(self) -> Status: + if self.package_meta.ephemeral.is_version_acceptable is True: + return Status.SUCCESS + return Status.FAILURE diff --git a/src/redis_release/bht/composites.py b/src/redis_release/bht/composites.py index 0a43a8b..327c4dc 100644 --- a/src/redis_release/bht/composites.py +++ b/src/redis_release/bht/composites.py @@ -21,6 +21,7 @@ from ..github_client_async import GitHubClientAsync from .behaviours import ( + ClassifyHomebrewVersion, ExtractArtifactResult, GetWorkflowArtifactsList, IdentifyTargetRef, @@ -32,7 +33,7 @@ from .behaviours import TriggerWorkflow as TriggerWorkflow from .behaviours import UpdateWorkflowStatusUntilCompletion from .decorators import ConditionGuard, FlagGuard, StatusFlagGuard -from .state import Package, PackageMeta, ReleaseMeta, Workflow +from .state import HomebrewMeta, Package, PackageMeta, ReleaseMeta, Workflow class ParallelBarrier(Composite): @@ -393,3 +394,27 @@ def __init__( guard_status=Status.FAILURE, log_prefix=log_prefix, ) + + +class ClassifyHomebrewVersionGuarded(StatusFlagGuard): + def __init__( + self, + name: str, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + log_prefix: str = "", + ) -> None: + super().__init__( + None if name == "" else name, + ClassifyHomebrewVersion( + "Classify Homebrew Version", + package_meta, + release_meta, + github_client, + log_prefix=log_prefix, + ), + package_meta.ephemeral, + "classify_remote_versions", + log_prefix=log_prefix, + ) diff --git a/src/redis_release/bht/ppas.py b/src/redis_release/bht/ppas.py index a1b5a39..c50c65f 100644 --- a/src/redis_release/bht/ppas.py +++ b/src/redis_release/bht/ppas.py @@ -3,7 +3,7 @@ See backchain.py for more details on backchaining. -Chains are formed and latched in `tree.py` +Chains are formed and latched in `tree_factory.py` """ @@ -23,8 +23,6 @@ IsWorkflowIdentified, IsWorkflowSuccessful, IsWorkflowTriggered, - create_prepare_build_workflow_inputs, - create_prepare_publish_workflow_inputs, ) from .composites import ( DownloadArtifactsListGuarded, @@ -139,12 +137,15 @@ def create_identify_target_ref_ppa( def create_detect_release_type_ppa( + package_meta: PackageMeta, release_meta: ReleaseMeta, log_prefix: str, ) -> Union[Selector, Sequence]: return create_PPA( "Detect Release Type", - DetectReleaseType("Detect Release Type", release_meta, log_prefix=log_prefix), + DetectReleaseType( + "Detect Release Type", package_meta, release_meta, log_prefix=log_prefix + ), ) @@ -210,39 +211,3 @@ def create_attach_release_handle_ppa( log_prefix=log_prefix, ), ) - - -def create_build_workflow_inputs_ppa( - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, -) -> Union[Selector, Sequence]: - return create_PPA( - "Set Build Workflow Inputs", - create_prepare_build_workflow_inputs( - "Set Build Workflow Inputs", - workflow, - package_meta, - release_meta, - log_prefix=log_prefix, - ), - ) - - -def create_publish_workflow_inputs_ppa( - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, -) -> Union[Selector, Sequence]: - return create_PPA( - "Set Publish Workflow Inputs", - create_prepare_publish_workflow_inputs( - "Set Publish Workflow Inputs", - workflow, - package_meta, - release_meta, - log_prefix=log_prefix, - ), - ) diff --git a/src/redis_release/bht/state.py b/src/redis_release/bht/state.py index 2c57feb..1784895 100644 --- a/src/redis_release/bht/state.py +++ b/src/redis_release/bht/state.py @@ -2,25 +2,27 @@ import logging from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Literal, Optional, Union from py_trees import common from py_trees.common import Status from pydantic import BaseModel, Field from redis_release.models import ( + HomebrewChannel, PackageType, ReleaseType, + SnapRiskLevel, WorkflowConclusion, WorkflowStatus, WorkflowType, ) -from ..config import Config +from ..config import Config, PackageConfig logger = logging.getLogger(__name__) -SUPPORTED_STATE_VERSION = 2 +SUPPORTED_STATE_VERSION = 3 class WorkflowEphemeral(BaseModel): @@ -112,20 +114,81 @@ class PackageMetaEphemeral(BaseModel): log_once_flags: Dict[str, bool] = Field(default_factory=dict, exclude=True) +class HomebrewMetaEphemeral(PackageMetaEphemeral): + """Ephemeral metadata for Homebrew package. + + Extends base ephemeral metadata with Homebrew-specific fields. + """ + + classify_remote_versions: Optional[common.Status] = None + + is_version_acceptable: Optional[bool] = None + + +class SnapMetaEphemeral(PackageMetaEphemeral): + """Ephemeral metadata for Snap package. + + Extends base ephemeral metadata with Snap-specific fields. + """ + + classify_remote_versions: Optional[common.Status] = None + + is_version_acceptable: Optional[bool] = None + + pass + + class PackageMeta(BaseModel): - """Metadata for a package.""" + """Metadata for a package (base/generic type).""" + serialization_hint: Literal["generic"] = "generic" package_type: Optional[PackageType] = None + release_type: Optional[ReleaseType] = None repo: str = "" ref: Optional[str] = None publish_internal_release: bool = False ephemeral: PackageMetaEphemeral = Field(default_factory=PackageMetaEphemeral) +class HomebrewMeta(PackageMeta): + """Metadata for Homebrew package.""" + + serialization_hint: Literal["homebrew"] = "homebrew" # type: ignore[assignment] + homebrew_channel: Optional[HomebrewChannel] = None + # remote_version field is for status display only (e.g. to pair with + # classify_remote_versions flag) actual decision is based on + # ephemeral.is_version_acceptable which is reset on each run to always + # reflect recent remote version + remote_version: Optional[str] = None + ephemeral: HomebrewMetaEphemeral = Field(default_factory=HomebrewMetaEphemeral) # type: ignore[assignment] + + +class SnapMeta(PackageMeta): + """Metadata for Snap package.""" + + serialization_hint: Literal["snap"] = "snap" # type: ignore[assignment] + snap_risk_level: Optional[SnapRiskLevel] = None + # remote_version field is for status display only (e.g. to pair with + # classify_remote_versions flag) actual decision is based on + # ephemeral.is_version_acceptable which is reset on each run to always + # reflect recent remote version + remote_version: Optional[str] = None + ephemeral: SnapMetaEphemeral = Field(default_factory=SnapMetaEphemeral) # type: ignore[assignment] + + class Package(BaseModel): - """State for a package in the release.""" + """State for a package in the release. - meta: PackageMeta = Field(default_factory=PackageMeta) + The meta field uses a discriminated union based on the serialization_hint field. + This ensures correct deserialization: + - serialization_hint="generic" -> PackageMeta + - serialization_hint="homebrew" -> HomebrewMeta + - serialization_hint="snap" -> SnapMeta + """ + + meta: Union[HomebrewMeta, SnapMeta, PackageMeta] = Field( + default_factory=PackageMeta, discriminator="serialization_hint" + ) build: Workflow = Field(default_factory=Workflow) publish: Workflow = Field(default_factory=Workflow) @@ -143,7 +206,6 @@ class ReleaseMeta(BaseModel): """Metadata for the release.""" tag: Optional[str] = None - release_type: Optional[ReleaseType] = None last_started_at: Optional[datetime] = None ephemeral: ReleaseMetaEphemeral = Field(default_factory=ReleaseMetaEphemeral) @@ -151,20 +213,56 @@ class ReleaseMeta(BaseModel): class ReleaseState(BaseModel): """Release state adapted for behavior tree usage.""" - version: int = 2 + version: int = SUPPORTED_STATE_VERSION meta: ReleaseMeta = Field(default_factory=ReleaseMeta) packages: Dict[str, Package] = Field(default_factory=dict) + @staticmethod + def _create_package_meta_from_config( + package_config: "PackageConfig", + ) -> Union[HomebrewMeta, SnapMeta, PackageMeta]: + """Create appropriate PackageMeta subclass based on package_type. + + Args: + package_config: Package configuration + + Returns: + PackageMeta subclass instance (HomebrewMeta, SnapMeta, or PackageMeta) + + Raises: + ValueError: If package_type is None + """ + if package_config.package_type == PackageType.HOMEBREW: + return HomebrewMeta( + repo=package_config.repo, + ref=package_config.ref, + package_type=package_config.package_type, + publish_internal_release=package_config.publish_internal_release, + ) + elif package_config.package_type == PackageType.SNAP: + return SnapMeta( + repo=package_config.repo, + ref=package_config.ref, + package_type=package_config.package_type, + publish_internal_release=package_config.publish_internal_release, + ) + elif package_config.package_type is not None: + return PackageMeta( + repo=package_config.repo, + ref=package_config.ref, + package_type=package_config.package_type, + publish_internal_release=package_config.publish_internal_release, + ) + else: + raise ValueError( + f"package_type must be a PackageType, got {type(package_config.package_type).__name__}" + ) + @classmethod def from_config(cls, config: Config) -> "ReleaseState": """Build ReleaseState from config with default values.""" packages = {} for package_name, package_config in config.packages.items(): - if not isinstance(package_config.package_type, PackageType): - raise ValueError( - f"Package '{package_name}': package_type must be a PackageType, " - f"got {type(package_config.package_type).__name__}" - ) # Validate and get build workflow file if not isinstance(package_config.build_workflow, str): raise ValueError( @@ -187,13 +285,11 @@ def from_config(cls, config: Config) -> "ReleaseState": f"Package '{package_name}': publish_workflow cannot be empty" ) - # Initialize package metadata - package_meta = PackageMeta( - repo=package_config.repo, - ref=package_config.ref, - package_type=package_config.package_type, - publish_internal_release=package_config.publish_internal_release, - ) + # Initialize package metadata - create appropriate subclass based on package_type + try: + package_meta = cls._create_package_meta_from_config(package_config) + except ValueError as e: + raise ValueError(f"Package '{package_name}': {e}") from e # Initialize build workflow build_workflow = Workflow( diff --git a/src/redis_release/bht/tree.py b/src/redis_release/bht/tree.py index df3ff5a..b501925 100644 --- a/src/redis_release/bht/tree.py +++ b/src/redis_release/bht/tree.py @@ -19,9 +19,9 @@ from py_trees.visitors import SnapshotVisitor from rich.text import Text -from ..config import Config +from ..config import Config, PackageConfig from ..github_client_async import GitHubClientAsync -from ..models import ReleaseArgs +from ..models import PackageType, ReleaseArgs from ..state_display import print_state_table from ..state_manager import S3StateStorage, StateManager, StateStorage from ..state_slack import SlackStatePrinter, init_slack_printer @@ -34,19 +34,23 @@ RestartWorkflowGuarded, ) from .ppas import ( - create_attach_release_handle_ppa, - create_build_workflow_inputs_ppa, - create_detect_release_type_ppa, create_download_artifacts_ppa, create_extract_artifact_result_ppa, create_find_workflow_by_uuid_ppa, create_identify_target_ref_ppa, - create_publish_workflow_inputs_ppa, create_trigger_workflow_ppa, create_workflow_completion_ppa, create_workflow_success_ppa, ) -from .state import Package, PackageMeta, ReleaseMeta, ReleaseState, Workflow +from .state import ( + SUPPORTED_STATE_VERSION, + Package, + PackageMeta, + ReleaseMeta, + ReleaseState, + Workflow, +) +from .tree_factory import get_factory logger = logging.getLogger(__name__) @@ -195,7 +199,9 @@ def create_root_node( logger.info(f"Skipping package {package_name} as it's not in only_packages") continue root.add_child( - create_package_release_tree_branch( + get_factory( + package.meta.package_type + ).create_package_release_goal_tree_branch( package, state.meta, default_state.packages[package_name], @@ -206,244 +212,6 @@ def create_root_node( return root -def create_package_release_tree_branch( - package: Package, - release_meta: ReleaseMeta, - default_package: Package, - github_client: GitHubClientAsync, - package_name: str, -) -> Union[Selector, Sequence]: - build = create_build_workflow_tree_branch( - package, - release_meta, - default_package, - github_client, - package_name, - ) - build.name = f"Build {package_name}" - publish = create_publish_workflow_tree_branch( - package.build, - package.publish, - package.meta, - release_meta, - default_package.publish, - github_client, - package_name, - ) - reset_package_state = ResetPackageStateGuarded( - "", - package, - default_package, - log_prefix=package_name, - ) - publish.name = f"Publish {package_name}" - package_release = Sequence( - f"Package Release {package_name}", - memory=False, - children=[reset_package_state, build, publish], - ) - return package_release - - -def create_build_workflow_tree_branch( - package: Package, - release_meta: ReleaseMeta, - default_package: Package, - github_client: GitHubClientAsync, - package_name: str, -) -> Union[Selector, Sequence]: - - build_workflow_args = create_build_workflow_inputs_ppa( - package.build, package.meta, release_meta, log_prefix=f"{package_name}.build" - ) - build_workflow = create_workflow_with_result_tree_branch( - "release_handle", - package.build, - package.meta, - release_meta, - github_client, - f"{package_name}.build", - trigger_preconditions=[build_workflow_args], - ) - assert isinstance(build_workflow, Selector) - - reset_package_state = RestartPackageGuarded( - "BuildRestartCondition", - package, - package.build, - default_package, - log_prefix=f"{package_name}.build", - ) - build_workflow.add_child(reset_package_state) - - return build_workflow - - -def create_publish_workflow_tree_branch( - build_workflow: Workflow, - publish_workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - default_publish_workflow: Workflow, - github_client: GitHubClientAsync, - package_name: str, -) -> Union[Selector, Sequence]: - attach_release_handle = create_attach_release_handle_ppa( - build_workflow, publish_workflow, log_prefix=f"{package_name}.publish" - ) - publish_workflow_args = create_publish_workflow_inputs_ppa( - publish_workflow, - package_meta, - release_meta, - log_prefix=f"{package_name}.publish", - ) - workflow_result = create_workflow_with_result_tree_branch( - "release_info", - publish_workflow, - package_meta, - release_meta, - github_client, - f"{package_name}.publish", - trigger_preconditions=[publish_workflow_args, attach_release_handle], - ) - not_need_to_publish = Inverter( - "Not", - NeedToPublishRelease( - "Need To Publish?", - package_meta, - release_meta, - log_prefix=f"{package_name}.publish", - ), - ) - reset_publish_workflow_state = RestartWorkflowGuarded( - "PublishRestartCondition", - publish_workflow, - package_meta, - default_publish_workflow, - log_prefix=f"{package_name}.publish", - ) - return Selector( - "Publish", - memory=False, - children=[not_need_to_publish, workflow_result, reset_publish_workflow_state], - ) - - -def create_workflow_with_result_tree_branch( - artifact_name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - github_client: GitHubClientAsync, - package_name: str, - trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, -) -> Union[Selector, Sequence]: - """ - Creates a workflow process that succedes when the workflow - is successful and a result artifact is extracted and json decoded. - - Args: - trigger_preconditions: List of preconditions to add to the workflow trigger - """ - workflow_result = create_extract_result_tree_branch( - artifact_name, - workflow, - package_meta, - github_client, - package_name, - ) - workflow_complete = create_workflow_complete_tree_branch( - workflow, - package_meta, - release_meta, - github_client, - package_name, - trigger_preconditions, - ) - - latch_chains(workflow_result, workflow_complete) - - return workflow_result - - -def create_workflow_complete_tree_branch( - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - github_client: GitHubClientAsync, - log_prefix: str, - trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, -) -> Union[Selector, Sequence]: - """ - - Args: - trigger_preconditions: List of preconditions to add to the workflow trigger - """ - workflow_complete = create_workflow_completion_ppa( - workflow, - package_meta, - github_client, - log_prefix, - ) - find_workflow_by_uud = create_find_workflow_by_uuid_ppa( - workflow, - package_meta, - github_client, - log_prefix, - ) - trigger_workflow = create_trigger_workflow_ppa( - workflow, - package_meta, - release_meta, - github_client, - log_prefix, - ) - if trigger_preconditions: - latch_chains(trigger_workflow, *trigger_preconditions) - identify_target_ref = create_identify_target_ref_ppa( - package_meta, - release_meta, - github_client, - log_prefix, - ) - detect_release_type = create_detect_release_type_ppa( - release_meta, - log_prefix, - ) - latch_chains( - workflow_complete, - find_workflow_by_uud, - trigger_workflow, - identify_target_ref, - detect_release_type, - ) - return workflow_complete - - -def create_extract_result_tree_branch( - artifact_name: str, - workflow: Workflow, - package_meta: PackageMeta, - github_client: GitHubClientAsync, - log_prefix: str, -) -> Union[Selector, Sequence]: - extract_artifact_result = create_extract_artifact_result_ppa( - artifact_name, - workflow, - package_meta, - github_client, - log_prefix, - ) - download_artifacts = create_download_artifacts_ppa( - workflow, - package_meta, - github_client, - log_prefix, - ) - latch_chains(extract_artifact_result, download_artifacts) - return extract_artifact_result - - class TreeInspector: """Inspector for creating and inspecting behavior tree branches and PPAs.""" @@ -460,17 +228,23 @@ class TreeInspector: "workflow_with_result_branch", "publish_workflow_branch", "build_workflow_branch", + "package_release_branch", + "package_release_goal_branch", "demo_sequence", "demo_selector", ] - def __init__(self, release_tag: str): + def __init__(self, release_tag: str, package_type: Optional[str] = None): """Initialize TreeInspector. Args: release_tag: Release tag for creating mock ReleaseMeta """ self.release_tag = release_tag + if package_type: + self.package_type = PackageType(package_type) + else: + self.package_type = PackageType.DOCKER def get_names(self) -> List[str]: """Get list of available branch/PPA names. @@ -496,16 +270,24 @@ def create_by_name(self, name: str) -> Union[Selector, Sequence, Behaviour]: available = ", ".join(self.get_names()) raise ValueError(f"Unknown name '{name}'. Available options: {available}") + config = Config( + version=SUPPORTED_STATE_VERSION, + packages={ + "inspected": PackageConfig( + repo="test/repo", + package_type=self.package_type, + build_workflow="build.yml", + publish_workflow="publish.yml", + ) + }, + ) + state = ReleaseState.from_config(config) # Create mock objects for PPA/branch creation - workflow = Workflow(workflow_file="test.yml", inputs={}) - package_meta = PackageMeta(repo="redis/redis", ref="main") - release_meta = ReleaseMeta(tag=self.release_tag) + workflow = state.packages["inspected"].build + package_meta = state.packages["inspected"].meta + release_meta = state.meta github_client = GitHubClientAsync(token="dummy") - package = Package( - meta=package_meta, - build=workflow, - publish=Workflow(workflow_file="publish.yml", inputs={}), - ) + package = state.packages["inspected"] log_prefix = "test" # Create and return the requested branch/PPA @@ -536,15 +318,17 @@ def create_by_name(self, name: str) -> Union[Selector, Sequence, Behaviour]: "test-artifact", workflow, package_meta, github_client, log_prefix ) elif name == "workflow_complete_branch": - return create_workflow_complete_tree_branch( + return get_factory(self.package_type).create_workflow_complete_tree_branch( workflow, package_meta, release_meta, github_client, "" ) elif name == "workflow_with_result_branch": - return create_workflow_with_result_tree_branch( + return get_factory( + self.package_type + ).create_workflow_with_result_tree_branch( "artifact", workflow, package_meta, release_meta, github_client, "" ) elif name == "publish_workflow_branch": - return create_publish_workflow_tree_branch( + return get_factory(self.package_type).create_publish_workflow_tree_branch( workflow, workflow, package_meta, @@ -554,7 +338,17 @@ def create_by_name(self, name: str) -> Union[Selector, Sequence, Behaviour]: "", ) elif name == "build_workflow_branch": - return create_build_workflow_tree_branch( + return get_factory(self.package_type).create_build_workflow_tree_branch( + package, release_meta, package, github_client, "" + ) + elif name == "package_release_branch": + return get_factory(self.package_type).create_package_release_tree_branch( + package, release_meta, package, github_client, "" + ) + elif name == "package_release_goal_branch": + return get_factory( + self.package_type + ).create_package_release_goal_tree_branch( package, release_meta, package, github_client, "" ) elif name == "demo_sequence": diff --git a/src/redis_release/bht/tree_factory.py b/src/redis_release/bht/tree_factory.py new file mode 100644 index 0000000..fde3571 --- /dev/null +++ b/src/redis_release/bht/tree_factory.py @@ -0,0 +1,556 @@ +""" +Package-specific tree factories and factory functions. +""" + +import logging +from abc import ABC +from typing import Dict, List, Optional, Union, cast + +from py_trees.behaviour import Behaviour +from py_trees.behaviours import Failure as AlwaysFailure +from py_trees.composites import Selector, Sequence +from py_trees.decorators import Inverter + +from ..github_client_async import GitHubClientAsync +from ..models import PackageType +from .backchain import create_PPA, latch_chains +from .behaviours import ( + DetectHombrewReleaseAndChannel, + DockerWorkflowInputs, + GenericWorkflowInputs, + HomewbrewWorkflowInputs, + NeedToPublishRelease, + NeedToReleaseHomebrew, +) +from .composites import ( + ClassifyHomebrewVersionGuarded, + ResetPackageStateGuarded, + RestartPackageGuarded, + RestartWorkflowGuarded, +) +from .ppas import ( + create_attach_release_handle_ppa, + create_detect_release_type_ppa, + create_download_artifacts_ppa, + create_extract_artifact_result_ppa, + create_find_workflow_by_uuid_ppa, + create_identify_target_ref_ppa, + create_trigger_workflow_ppa, + create_workflow_completion_ppa, +) +from .state import ( + HomebrewMeta, + Package, + PackageMeta, + ReleaseMeta, + ReleaseState, + Workflow, +) + +logger = logging.getLogger(__name__) + + +class GenericPackageFactory(ABC): + """Default factory for packages without specific customizations.""" + + def create_package_release_goal_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + package_release = self.create_package_release_tree_branch( + package, release_meta, default_package, github_client, package_name + ) + return Selector( + f"Package Release {package_name} Goal", + memory=False, + children=[AlwaysFailure("Yes"), package_release], + ) + + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return GenericWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) + + def create_publish_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return GenericWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) + + def create_workflow_complete_tree_branch( + self, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + log_prefix: str, + trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, + ) -> Union[Selector, Sequence]: + """ + + Args: + trigger_preconditions: List of preconditions to add to the workflow trigger + """ + workflow_complete = create_workflow_completion_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + find_workflow_by_uud = create_find_workflow_by_uuid_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + trigger_workflow = create_trigger_workflow_ppa( + workflow, + package_meta, + release_meta, + github_client, + log_prefix, + ) + if trigger_preconditions: + latch_chains(trigger_workflow, *trigger_preconditions) + identify_target_ref = create_identify_target_ref_ppa( + package_meta, + release_meta, + github_client, + log_prefix, + ) + detect_release_type = create_detect_release_type_ppa( + package_meta, + release_meta, + log_prefix, + ) + latch_chains( + workflow_complete, + find_workflow_by_uud, + trigger_workflow, + identify_target_ref, + detect_release_type, + ) + return workflow_complete + + def create_package_release_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + build = self.create_build_workflow_tree_branch( + package, + release_meta, + default_package, + github_client, + package_name, + ) + build.name = f"Build {package_name}" + publish = self.create_publish_workflow_tree_branch( + package.build, + package.publish, + package.meta, + release_meta, + default_package.publish, + github_client, + package_name, + ) + reset_package_state = ResetPackageStateGuarded( + "", + package, + default_package, + log_prefix=package_name, + ) + publish.name = f"Publish {package_name}" + package_release = Sequence( + f"Package Release {package_name}", + memory=False, + children=[reset_package_state, build, publish], + ) + return package_release + + def create_build_workflow_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + + build_workflow_args = create_PPA( + "Set Build Workflow Inputs", + self.create_build_workflow_inputs( + "Set Build Workflow Inputs", + package.build, + package.meta, + release_meta, + log_prefix=f"{package_name}.build", + ), + ) + + build_workflow = self.create_workflow_with_result_tree_branch( + "release_handle", + package.build, + package.meta, + release_meta, + github_client, + f"{package_name}.build", + trigger_preconditions=[build_workflow_args], + ) + assert isinstance(build_workflow, Selector) + + reset_package_state = RestartPackageGuarded( + "BuildRestartCondition", + package, + package.build, + default_package, + log_prefix=f"{package_name}.build", + ) + build_workflow.add_child(reset_package_state) + + return build_workflow + + def create_publish_workflow_tree_branch( + self, + build_workflow: Workflow, + publish_workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + default_publish_workflow: Workflow, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + attach_release_handle = create_attach_release_handle_ppa( + build_workflow, publish_workflow, log_prefix=f"{package_name}.publish" + ) + publish_workflow_args = create_PPA( + "Set Publish Workflow Inputs", + self.create_publish_workflow_inputs( + "Set Publish Workflow Inputs", + publish_workflow, + package_meta, + release_meta, + log_prefix=f"{package_name}.publish", + ), + ) + workflow_result = self.create_workflow_with_result_tree_branch( + "release_info", + publish_workflow, + package_meta, + release_meta, + github_client, + f"{package_name}.publish", + trigger_preconditions=[publish_workflow_args, attach_release_handle], + ) + not_need_to_publish = Inverter( + "Not", + NeedToPublishRelease( + "Need To Publish?", + package_meta, + release_meta, + log_prefix=f"{package_name}.publish", + ), + ) + reset_publish_workflow_state = RestartWorkflowGuarded( + "PublishRestartCondition", + publish_workflow, + package_meta, + default_publish_workflow, + log_prefix=f"{package_name}.publish", + ) + return Selector( + "Publish", + memory=False, + children=[ + not_need_to_publish, + workflow_result, + reset_publish_workflow_state, + ], + ) + + def create_workflow_with_result_tree_branch( + self, + artifact_name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + package_name: str, + trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, + ) -> Union[Selector, Sequence]: + """ + Creates a workflow process that succedes when the workflow + is successful and a result artifact is extracted and json decoded. + + Args: + trigger_preconditions: List of preconditions to add to the workflow trigger + """ + workflow_result = self.create_extract_result_tree_branch( + artifact_name, + workflow, + package_meta, + github_client, + package_name, + ) + workflow_complete = self.create_workflow_complete_tree_branch( + workflow, + package_meta, + release_meta, + github_client, + package_name, + trigger_preconditions, + ) + + latch_chains(workflow_result, workflow_complete) + + return workflow_result + + def create_extract_result_tree_branch( + self, + artifact_name: str, + workflow: Workflow, + package_meta: PackageMeta, + github_client: GitHubClientAsync, + log_prefix: str, + ) -> Union[Selector, Sequence]: + extract_artifact_result = create_extract_artifact_result_ppa( + artifact_name, + workflow, + package_meta, + github_client, + log_prefix, + ) + download_artifacts = create_download_artifacts_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + latch_chains(extract_artifact_result, download_artifacts) + return extract_artifact_result + + +class DockerFactory(GenericPackageFactory): + """Factory for Docker packages.""" + + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return DockerWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) + + def create_publish_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return DockerWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) + + +class DebianFactory(GenericPackageFactory): + pass + + +class RPMFactory(GenericPackageFactory): + pass + + +class HomebrewFactory(GenericPackageFactory): + def create_package_release_goal_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + package_release = self.create_package_release_tree_branch( + package, release_meta, default_package, github_client, package_name + ) + need_to_release = NeedToReleaseHomebrew( + "Need To Release?", + cast(HomebrewMeta, package.meta), + release_meta, + log_prefix=package_name, + ) + release_goal = Selector( + f"Release Workflows {package_name} Goal", + memory=False, + children=[Inverter("Not", need_to_release), package_release], + ) + reset_package_state = ResetPackageStateGuarded( + "", + package, + default_package, + log_prefix=package_name, + ) + return Sequence( + f"Release {package_name}", + memory=False, + children=[ + reset_package_state, + DetectHombrewReleaseAndChannel( + "Detect Homebrew Channel", + cast(HomebrewMeta, package.meta), + release_meta, + log_prefix=package_name, + ), + ClassifyHomebrewVersionGuarded( + "", + cast(HomebrewMeta, package.meta), + release_meta, + github_client, + log_prefix=package_name, + ), + release_goal, + ], + ) + + def create_package_release_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + build = self.create_build_workflow_tree_branch( + package, + release_meta, + default_package, + github_client, + package_name, + ) + build.name = f"Build {package_name}" + publish = self.create_publish_workflow_tree_branch( + package.build, + package.publish, + package.meta, + release_meta, + default_package.publish, + github_client, + package_name, + ) + publish.name = f"Publish {package_name}" + package_release = Sequence( + f"Execute Workflows {package_name}", + memory=False, + children=[build, publish], + ) + return package_release + + def create_workflow_complete_tree_branch( + self, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + log_prefix: str, + trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, + ) -> Union[Selector, Sequence]: + """ + + Args: + trigger_preconditions: List of preconditions to add to the workflow trigger + """ + workflow_complete = create_workflow_completion_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + find_workflow_by_uud = create_find_workflow_by_uuid_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + trigger_workflow = create_trigger_workflow_ppa( + workflow, + package_meta, + release_meta, + github_client, + log_prefix, + ) + if trigger_preconditions: + latch_chains(trigger_workflow, *trigger_preconditions) + + latch_chains( + workflow_complete, + find_workflow_by_uud, + trigger_workflow, + ) + return workflow_complete + + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + + return HomewbrewWorkflowInputs( + name, + workflow, + cast(HomebrewMeta, package_meta), + release_meta, + log_prefix=log_prefix, + ) + + +# Factory registry +_FACTORIES: Dict[PackageType, GenericPackageFactory] = { + PackageType.DOCKER: DockerFactory(), + PackageType.DEBIAN: DebianFactory(), + PackageType.RPM: RPMFactory(), + PackageType.HOMEBREW: HomebrewFactory(), +} + +_DEFAULT_FACTORY = GenericPackageFactory() + + +def get_factory(package_type: Optional[PackageType]) -> GenericPackageFactory: + """Get the factory for a given package type. + + Args: + package_type: The package type to get factory for + + Returns: + TreeFactory instance for the given type, or default factory if not found + """ + if package_type is None: + return _DEFAULT_FACTORY + return _FACTORIES.get(package_type, _DEFAULT_FACTORY) diff --git a/src/redis_release/cli.py b/src/redis_release/cli.py index aea49ef..6a068c9 100644 --- a/src/redis_release/cli.py +++ b/src/redis_release/cli.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import List, Optional +from typing import Dict, List, Optional import typer from py_trees.display import render_dot_tree, unicode_tree @@ -30,9 +30,56 @@ logger = logging.getLogger(__name__) +def parse_force_release_type( + force_release_type_list: Optional[List[str]], +) -> Dict[str, ReleaseType]: + """Parse force_release_type arguments from 'package_name:release_type' format. + + Args: + force_release_type_list: List of strings in format 'package_name:release_type' + + Returns: + Dictionary mapping package names to ReleaseType + + Raises: + typer.BadParameter: If format is invalid or release type is unknown + """ + if not force_release_type_list: + return {} + + result = {} + for item in force_release_type_list: + if ":" not in item: + raise typer.BadParameter( + f"Invalid format '{item}'. Expected 'package_name:release_type' (e.g., 'docker:internal')" + ) + + package_name, release_type_str = item.split(":", 1) + package_name = package_name.strip() + release_type_str = release_type_str.strip().lower() + + try: + release_type = ReleaseType(release_type_str) + except ValueError: + valid_types = ", ".join([rt.value for rt in ReleaseType]) + raise typer.BadParameter( + f"Invalid release type '{release_type_str}'. Valid types: {valid_types}" + ) + + result[package_name] = release_type + + return result + + @app.command() def release_print( release_tag: str = typer.Argument(..., help="Release tag (e.g., 8.4-m01-int1)"), + package_type: Optional[str] = typer.Option( + None, + "--package-type", + "-p", + help="Package type to use for creating the tree (default: docker)", + ), config_file: Optional[str] = typer.Option( None, "--config", "-c", help="Path to config file (default: config.yaml)" ), @@ -62,14 +109,14 @@ def release_print( if name: # Create TreeInspector and render the requested branch - inspector = TreeInspector(release_tag=release_tag) + inspector = TreeInspector(release_tag=release_tag, package_type=package_type) try: branch = inspector.create_by_name(name) render_dot_tree(branch) print(unicode_tree(branch)) except ValueError as e: - logger.error(f"[red]Error: {e}[/red]") + logger.error(f"[red]Error: {e}[/red]", exc_info=True) raise typer.Exit(1) else: # Print full release tree @@ -100,12 +147,12 @@ def release( help="Only process specific packages (can be specified multiple times)", ), tree_cutoff: int = typer.Option( - 2000, "--tree-cutoff", "-m", help="Max number of ticks to run the tree for" + 5000, "--tree-cutoff", "-m", help="Max number of ticks to run the tree for" ), - force_release_type: Optional[ReleaseType] = typer.Option( + force_release_type: Optional[List[str]] = typer.Option( None, "--force-release-type", - help="Force release type (public or internal)", + help="Force release type per package in format 'package_name:release_type' (e.g., 'docker:internal' or 'all:public'). Can be specified multiple times.", ), override_state_name: Optional[str] = typer.Option( None, @@ -133,7 +180,7 @@ def release( release_tag=release_tag, force_rebuild=force_rebuild or [], only_packages=only_packages or [], - force_release_type=force_release_type, + force_release_type=parse_force_release_type(force_release_type), override_state_name=override_state_name, slack_token=slack_token, slack_channel_id=slack_channel_id, @@ -150,6 +197,11 @@ def status( config_file: Optional[str] = typer.Option( None, "--config", "-c", help="Path to config file (default: config.yaml)" ), + override_state_name: Optional[str] = typer.Option( + None, + "--override-state-name", + help="Custom state name to use instead of release tag, to be able to make test runs without affecting production state", + ), slack: bool = typer.Option(False, "--slack", help="Post status to Slack"), slack_channel_id: Optional[str] = typer.Option( None, @@ -171,6 +223,7 @@ def status( args = ReleaseArgs( release_tag=release_tag, force_rebuild=[], + override_state_name=override_state_name, ) with StateManager( diff --git a/src/redis_release/config.py b/src/redis_release/config.py index acb03ff..141d774 100644 --- a/src/redis_release/config.py +++ b/src/redis_release/config.py @@ -6,7 +6,7 @@ import yaml from pydantic import BaseModel, Field -from .models import PackageType +from .models import HomebrewChannel, PackageType, SnapRiskLevel class PackageConfig(BaseModel): diff --git a/src/redis_release/github_client_async.py b/src/redis_release/github_client_async.py index 47508a9..55e39b6 100644 --- a/src/redis_release/github_client_async.py +++ b/src/redis_release/github_client_async.py @@ -1,5 +1,6 @@ """Async GitHub API client for workflow operations.""" +import base64 import io import json import logging @@ -603,6 +604,69 @@ def _extract_uuid(self, text: str) -> Optional[str]: uuid_match = re.search(uuid_pattern, text, re.IGNORECASE) return uuid_match.group() if uuid_match else None + async def download_file( + self, repo: str, file_path: str, ref: str = "main" + ) -> Optional[str]: + """Download a specific file from a repository at a specific branch/ref. + + Args: + repo: Repository name (e.g., "redis/redis") + file_path: Path to the file in the repository (e.g., "config.yaml", "src/main.py") + ref: Git reference (branch, tag, or commit SHA) to download from (default: "main") + + Returns: + File content as a string, or None if the file is not found or an error occurs + """ + url = f"https://api.github.com/repos/{repo}/contents/{file_path}" + headers = { + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + } + params = {"ref": ref} + + try: + logger.debug( + f"[blue]Downloading file[/blue] {file_path} from {repo} at ref {ref}" + ) + + data = await self.github_request( + url=url, + headers=headers, + method="GET", + params=params, + timeout=30, + error_context=f"download file {file_path}", + ) + + # GitHub API returns file content base64-encoded + if "content" in data and data.get("encoding") == "base64": + content = base64.b64decode(data["content"]).decode("utf-8") + logger.debug( + f"[green]Successfully downloaded {file_path}[/green] ({len(content)} bytes)" + ) + return content + else: + logger.error( + f"[red]Unexpected response format for file {file_path}[/red]" + ) + return None + + except aiohttp.ClientResponseError as e: + if e.status == 404: + logger.warning( + f"[yellow]File {file_path} not found in {repo} at ref {ref}[/yellow]" + ) + else: + logger.error(f"[red]Failed to download file {file_path}: {e}[/red]") + return None + except aiohttp.ClientError as e: + logger.error(f"[red]Failed to download file {file_path}: {e}[/red]") + return None + except Exception as e: + logger.error(f"[red]Error downloading file {file_path}: {e}[/red]") + return None + async def list_remote_branches( self, repo: str, pattern: Optional[str] = None ) -> List[str]: diff --git a/src/redis_release/models.py b/src/redis_release/models.py index 6ccf5c0..de7975c 100644 --- a/src/redis_release/models.py +++ b/src/redis_release/models.py @@ -1,8 +1,9 @@ """Data models for Redis release automation.""" +import functools import re from enum import Enum -from typing import List, Optional +from typing import Dict, List, Optional from pydantic import BaseModel, Field @@ -19,6 +20,37 @@ class PackageType(str, Enum): DOCKER = "docker" DEBIAN = "debian" + RPM = "rpm" + HOMEBREW = "homebrew" + SNAP = "snap" + + +class PackageSerializationType(str, Enum): + """Package serialization type for discriminated union. + + This enum is used as a discriminator field for Pydantic's discriminated union + to correctly deserialize different PackageMeta subclasses. + """ + + DEFAULT = "default" # For generic PackageMeta + HOMEBREW = "homebrew" # For HomebrewMeta + SNAP = "snap" # For SnapMeta + + +class HomebrewChannel(str, Enum): + """Homebrew channel enumeration.""" + + STABLE = "stable" + RC = "rc" + + +class SnapRiskLevel(str, Enum): + """Snap channel enumeration.""" + + STABLE = "stable" + CANDIDATE = "candidate" + BETA = "beta" + EDGE = "edge" class ReleaseType(str, Enum): @@ -55,6 +87,7 @@ class WorkflowRun(BaseModel): conclusion: Optional[WorkflowConclusion] = None +@functools.total_ordering class RedisVersion(BaseModel): """Represents a parsed Redis version. @@ -107,22 +140,46 @@ def is_eol(self) -> bool: """Check if this version is end-of-life.""" return self.suffix.lower().endswith("-eol") + @property + def is_rc(self) -> bool: + """Check if this version is a release candidate.""" + return self.suffix.lower().startswith("-rc") + + @property + def is_ga(self) -> bool: + """Check if this version is a general availability (GA) release.""" + return not self.is_milestone + + @property + def is_internal(self) -> bool: + """Check if this version is an internal release.""" + return bool(re.search(r"-int\d*$", self.suffix.lower())) + @property def mainline_version(self) -> str: """Get the mainline version string (major.minor).""" return f"{self.major}.{self.minor}" @property - def sort_key(self) -> str: - suffix_weight = 0 - if self.suffix.startswith("rc"): - suffix_weight = 100 - elif self.suffix.startswith("m"): - suffix_weight = 50 + def suffix_weight(self) -> str: + # warning: using lexicographic order, letters doesn't have any meaning except for ordering + suffix_weight = "" + if self.is_ga: + suffix_weight = "QQ" + if self.is_rc: + suffix_weight = "LL" + elif self.suffix.startswith("-m"): + suffix_weight = "II" + + # internal versions are always lower than their GA/rc/m counterparts + if self.is_internal: + suffix_weight = suffix_weight[:1] + "E" + + return suffix_weight - return ( - f"{self.major}.{self.minor}.{self.patch or 0}.{suffix_weight}.{self.suffix}" - ) + @property + def sort_key(self) -> str: + return f"{self.major}.{self.minor}.{self.patch or 0}.{self.suffix_weight}{self.suffix}" def __str__(self) -> str: """String representation of the version.""" @@ -136,21 +193,40 @@ def __lt__(self, other: "RedisVersion") -> bool: if not isinstance(other, RedisVersion): return NotImplemented - # Compare major.minor.patch first - self_tuple = (self.major, self.minor, self.patch or 0) - other_tuple = (other.major, other.minor, other.patch or 0) + return self.sort_key < other.sort_key - if self_tuple != other_tuple: - return self_tuple < other_tuple + def __le__(self, other: "RedisVersion") -> bool: + """Less than or equal comparison.""" + if not isinstance(other, RedisVersion): + return NotImplemented + return self < other or self == other - # If numeric parts are equal, compare suffixes - # Empty suffix (GA) comes after suffixes (milestones) - if not self.suffix and other.suffix: - return False - if self.suffix and not other.suffix: - return True + def __gt__(self, other: "RedisVersion") -> bool: + """Greater than comparison.""" + if not isinstance(other, RedisVersion): + return NotImplemented + return not self <= other + + def __ge__(self, other: "RedisVersion") -> bool: + """Greater than or equal comparison.""" + if not isinstance(other, RedisVersion): + return NotImplemented + return not self < other + + def __eq__(self, other: object) -> bool: + """Equality comparison.""" + if not isinstance(other, RedisVersion): + return NotImplemented + return ( + self.major == other.major + and self.minor == other.minor + and (self.patch or 0) == (other.patch or 0) + and self.suffix == other.suffix + ) - return self.suffix < other.suffix + def __hash__(self) -> int: + """Hash for use in sets and dicts.""" + return hash((self.major, self.minor, self.patch or 0, self.suffix)) class ReleaseArgs(BaseModel): @@ -159,7 +235,7 @@ class ReleaseArgs(BaseModel): release_tag: str force_rebuild: List[str] = Field(default_factory=list) only_packages: List[str] = Field(default_factory=list) - force_release_type: Optional[ReleaseType] = None + force_release_type: Dict[str, ReleaseType] = Field(default_factory=dict) override_state_name: Optional[str] = None slack_token: Optional[str] = None slack_channel_id: Optional[str] = None diff --git a/src/redis_release/state_display.py b/src/redis_release/state_display.py index 988475c..d551247 100644 --- a/src/redis_release/state_display.py +++ b/src/redis_release/state_display.py @@ -1,7 +1,7 @@ """Console display utilities for release state.""" from enum import Enum -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union from py_trees import common from py_trees.common import Status @@ -10,7 +10,15 @@ from redis_release.models import WorkflowConclusion -from .bht.state import Package, ReleaseState, Workflow +from .bht.state import ( + HomebrewMeta, + HomebrewMetaEphemeral, + Package, + ReleaseState, + SnapMeta, + SnapMetaEphemeral, + Workflow, +) # See WorkflowEphemeral for more details on the flags and steps @@ -120,6 +128,37 @@ def get_workflow_status( return (s, steps_status) return (StepStatus.SUCCEEDED, steps_status) + @staticmethod + def get_release_validation_status( + meta: Union[HomebrewMeta, SnapMeta], + ) -> Tuple[StepStatus, List[Tuple[StepStatus, str, Optional[str]]]]: + """Get release validation status for Homebrew or Snap packages. + + This method checks validation steps specific to Homebrew and Snap packages, + such as remote version classification. + + Args: + meta: The package metadata (HomebrewMeta or SnapMeta) + + Returns: + Tuple of (overall_status, list of (step_status, step_name, step_message)) + """ + steps_status: List[Tuple[StepStatus, str, Optional[str]]] = [] + steps = [ + ( + meta.remote_version is not None, + meta.ephemeral.classify_remote_versions, + "Classify remote versions", + None, + ), + ] + for result, status_flag, name, status_msg in steps: + s = DisplayModel.get_step_status(result, status_flag) + steps_status.append((s, name, status_msg)) + if s != StepStatus.SUCCEEDED: + return (s, steps_status) + return (StepStatus.SUCCEEDED, steps_status) + class ConsoleStatePrinter: """Handles printing of release state to console using Rich tables.""" @@ -161,7 +200,21 @@ def print_state_table(self, state: ReleaseState) -> None: # Process each package for package_name, package in sorted(state.packages.items()): # Determine build status - build_status = self.get_workflow_status_display(package, package.build) + build_status = "" + if ( + type(package.meta.ephemeral) == HomebrewMetaEphemeral + or type(package.meta.ephemeral) == SnapMetaEphemeral + ): + # to avoid creating new column validation status is counted as part of build workflow + status, _ = DisplayModel.get_release_validation_status(package.meta) # type: ignore + if status != StepStatus.SUCCEEDED: + build_status = self.get_step_status_display(status) + else: + build_status = self.get_workflow_status_display( + package, package.build + ) + else: + build_status = self.get_workflow_status_display(package, package.build) # Determine publish status publish_status = self.get_workflow_status_display(package, package.publish) @@ -193,43 +246,29 @@ def get_workflow_status_display(self, package: Package, workflow: Workflow) -> s Rich-formatted status string """ workflow_status = DisplayModel.get_workflow_status(package, workflow) - if workflow_status[0] == StepStatus.SUCCEEDED: + return self.get_step_status_display(workflow_status[0]) + + def get_step_status_display(self, step_status: StepStatus) -> str: + if step_status == StepStatus.SUCCEEDED: return "[bold green]✓ Success[/bold green]" - elif workflow_status[0] == StepStatus.RUNNING: + elif step_status == StepStatus.RUNNING: return "[bold yellow]⏳ In Progress[/bold yellow]" - elif workflow_status[0] == StepStatus.NOT_STARTED: + elif step_status == StepStatus.NOT_STARTED: return "[dim]Not Started[/dim]" - elif workflow_status[0] == StepStatus.INCORRECT: + elif step_status == StepStatus.INCORRECT: return "[bold red]✗ Invalid state![/bold red]" return "[bold red]✗ Failed[/bold red]" - def collect_workflow_details( - self, package: Package, workflow: Workflow, prefix: str + def collect_text_details( + self, steps: List[Tuple[StepStatus, str, Optional[str]]], prefix: str ) -> List[str]: - """Collect details from a workflow using bottom-up approach. - - Shows successes until the first failure, then stops. - Bottom-up means: trigger → identify → timeout → conclusion → artifacts → result - - Args: - package: The package containing the workflow - workflow: The workflow to check - prefix: Prefix for detail messages (e.g., "Build" or "Publish") - - Returns: - List of detail strings - """ details: List[str] = [] - workflow_status = DisplayModel.get_workflow_status(package, workflow) - if workflow_status[0] == StepStatus.NOT_STARTED: - return details - - details.append(f"{prefix} Workflow") + details.append(f"{prefix}") indent = " " * 2 - for step_status, step_name, step_message in workflow_status[1]: + for step_status, step_name, step_message in steps: if step_status == StepStatus.SUCCEEDED: details.append(f"{indent}[green]✓ {step_name}[/green]") elif step_status == StepStatus.RUNNING: @@ -254,10 +293,31 @@ def collect_details(self, package: Package) -> str: """ details: List[str] = [] - details.extend(self.collect_workflow_details(package, package.build, "Build")) - details.extend( - self.collect_workflow_details(package, package.publish, "Publish") - ) + build_status = DisplayModel.get_workflow_status(package, package.build) + if ( + type(package.meta.ephemeral) == HomebrewMetaEphemeral + or type(package.meta.ephemeral) == SnapMetaEphemeral + ): + validation_status, validation_steps = ( + DisplayModel.get_release_validation_status(package.meta) # type: ignore + ) + # Show any validation steps only when build has started or validation has failed + if ( + validation_status != StepStatus.NOT_STARTED + and build_status[0] != StepStatus.NOT_STARTED + ) or (validation_status == StepStatus.FAILED): + details.extend( + self.collect_text_details(validation_steps, "Release Validation") + ) + + build_status = DisplayModel.get_workflow_status(package, package.build) + if build_status[0] != StepStatus.NOT_STARTED: + details.extend(self.collect_text_details(build_status[1], "Build Workflow")) + publish_status = DisplayModel.get_workflow_status(package, package.publish) + if publish_status[0] != StepStatus.NOT_STARTED: + details.extend( + self.collect_text_details(publish_status[1], "Publish Workflow") + ) return "\n".join(details) diff --git a/src/redis_release/state_manager.py b/src/redis_release/state_manager.py index 7ac46e0..c24a381 100644 --- a/src/redis_release/state_manager.py +++ b/src/redis_release/state_manager.py @@ -13,7 +13,6 @@ from redis_release.bht.state import ReleaseState, logger from redis_release.config import Config -from redis_release.state_display import print_state_table from .bht.state import ReleaseState from .models import ReleaseArgs @@ -50,9 +49,6 @@ def __init__( self.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") self.aws_session_token = os.getenv("AWS_SESSION_TOKEN") - # local state cache for dry run mode - self._local_state_cache = {} - @property def s3_client(self) -> Optional[boto3.client]: """Lazy initialization of S3 client.""" @@ -245,7 +241,31 @@ def apply_args(self, state: ReleaseState) -> None: if self.args.force_release_type: logger.info(f"Force release type: {self.args.force_release_type}") - state.meta.release_type = self.args.force_release_type + # Handle "all" keyword to apply to all packages + if "all" in self.args.force_release_type: + release_type = self.args.force_release_type["all"] + for package_name in state.packages: + state.packages[package_name].meta.release_type = release_type + logger.info( + f"Set release type for package '{package_name}': {release_type}" + ) + else: + # Set release type for specific packages + for ( + package_name, + release_type, + ) in self.args.force_release_type.items(): + if package_name in state.packages: + state.packages[package_name].meta.release_type = ( + release_type + ) + logger.info( + f"Set release type for package '{package_name}': {release_type}" + ) + else: + logger.warning( + f"Package '{package_name}' not found in state, skipping release type override" + ) def load(self) -> Optional[ReleaseState]: """Load state from storage backend.""" @@ -264,12 +284,13 @@ def load(self) -> Optional[ReleaseState]: def _reset_ephemeral_fields(self, state: ReleaseState) -> None: """Reset ephemeral fields to defaults (except log_once_flags which are always reset).""" + # Reset release meta ephemeral state.meta.ephemeral = state.meta.ephemeral.__class__() # Reset package ephemeral fields for package in state.packages.values(): - package.meta.ephemeral = package.meta.ephemeral.__class__() + package.meta.ephemeral = package.meta.ephemeral.__class__() # type: ignore package.build.ephemeral = package.build.ephemeral.__class__() package.publish.ephemeral = package.publish.ephemeral.__class__() diff --git a/src/redis_release/state_slack.py b/src/redis_release/state_slack.py index 76157ee..4286c6a 100644 --- a/src/redis_release/state_slack.py +++ b/src/redis_release/state_slack.py @@ -11,7 +11,15 @@ from redis_release.state_display import DisplayModel, StepStatus -from .bht.state import Package, ReleaseState, Workflow +from .bht.state import ( + HomebrewMeta, + HomebrewMetaEphemeral, + Package, + ReleaseState, + SnapMeta, + SnapMetaEphemeral, + Workflow, +) logger = logging.getLogger(__name__) @@ -83,19 +91,19 @@ def __init__( reply_broadcast: If True and thread_ts is set, also show in main channel """ self.client = WebClient(token=slack_token) - self.channel_id = slack_channel_id + self.channel_id: str = slack_channel_id self.thread_ts = thread_ts self.reply_broadcast = reply_broadcast self.message_ts: Optional[str] = None self.last_blocks_json: Optional[str] = None self.started_at = datetime.now(timezone.utc) - def format_package_name(self, package_name: str, state: ReleaseState) -> str: + def format_package_name(self, package_name: str, package: Package) -> str: """Format package name with capital letter and release type. Args: package_name: The raw package name - state: The ReleaseState to get release type from + package: The Package to get release type from Returns: Formatted package name with capital letter and release type in parentheses @@ -104,8 +112,8 @@ def format_package_name(self, package_name: str, state: ReleaseState) -> str: formatted = package_name.capitalize() # Add release type if available - if state.meta.release_type: - release_type_str = state.meta.release_type.value + if package.meta.release_type: + release_type_str = package.meta.release_type.value formatted = f"{formatted} ({release_type_str})" return formatted @@ -149,7 +157,9 @@ def update_message(self, state: ReleaseState) -> bool: response = self.client.chat_postMessage(**kwargs) self.message_ts = response["ts"] # Update channel_id from response (authoritative) - self.channel_id = response["channel"] + channel = response.get("channel") + if isinstance(channel, str): + self.channel_id = channel logger.info( f"Posted Slack message ts={self.message_ts}" + (f" in thread {self.thread_ts}" if self.thread_ts else "") @@ -231,7 +241,7 @@ def _make_blocks(self, state: ReleaseState) -> List[Dict[str, Any]]: # Process each package for package_name, package in sorted(state.packages.items()): # Format package name with capital letter and release type - formatted_name = self.format_package_name(package_name, state) + formatted_name = self.format_package_name(package_name, package) # Get workflow statuses build_status_emoji = self._get_status_emoji(package, package.build) @@ -294,6 +304,8 @@ def _make_blocks(self, state: ReleaseState) -> List[Dict[str, Any]]: def _get_status_emoji(self, package: Package, workflow: Workflow) -> str: """Get emoji status for a workflow. + For build workflow of Homebrew/Snap packages, checks validation status first. + Args: package: The package containing the workflow workflow: The workflow to check @@ -301,9 +313,31 @@ def _get_status_emoji(self, package: Package, workflow: Workflow) -> str: Returns: Emoji status string """ + # For build workflow of Homebrew/Snap packages, check validation status first + if workflow == package.build and ( + type(package.meta.ephemeral) == HomebrewMetaEphemeral + or type(package.meta.ephemeral) == SnapMetaEphemeral + ): + # Check validation status first + validation_status, _ = DisplayModel.get_release_validation_status( + package.meta # type: ignore + ) + if validation_status != StepStatus.SUCCEEDED: + return self._get_step_status_emoji(validation_status) + + # Check workflow status workflow_status = DisplayModel.get_workflow_status(package, workflow) - status = workflow_status[0] + return self._get_step_status_emoji(workflow_status[0]) + + def _get_step_status_emoji(self, status: StepStatus) -> str: + """Convert step status to emoji string. + Args: + status: The step status + + Returns: + Emoji status string + """ if status == StepStatus.SUCCEEDED: return "✅ Success" elif status == StepStatus.RUNNING: @@ -320,6 +354,8 @@ def _collect_workflow_details_slack( ) -> str: """Collect workflow step details for Slack display. + For build workflow of Homebrew/Snap packages, includes validation details. + Args: package: The package containing the workflow workflow: The workflow to check @@ -327,13 +363,53 @@ def _collect_workflow_details_slack( Returns: Formatted string of workflow steps """ + details: List[str] = [] + workflow_status = DisplayModel.get_workflow_status(package, workflow) - if workflow_status[0] == StepStatus.NOT_STARTED: - return "" + # For build workflow of Homebrew/Snap packages, include validation details + if workflow == package.build and ( + type(package.meta.ephemeral) == HomebrewMetaEphemeral + or type(package.meta.ephemeral) == SnapMetaEphemeral + ): + validation_status, validation_steps = ( + DisplayModel.get_release_validation_status(package.meta) # type: ignore + ) + # Show any validation steps only when build has started or validation has failed + if ( + validation_status != StepStatus.NOT_STARTED + and workflow_status[0] != StepStatus.NOT_STARTED + ) or (validation_status == StepStatus.FAILED): + details.extend( + self._format_steps_for_slack(validation_steps, "Release Validation") + ) + + # Add workflow details + if workflow_status[0] != StepStatus.NOT_STARTED: + workflow_name = ( + "Build Workflow" if workflow == package.build else "Publish Workflow" + ) + details.extend( + self._format_steps_for_slack(workflow_status[1], workflow_name) + ) + + return "\n".join(details) + def _format_steps_for_slack( + self, steps: List[Tuple[StepStatus, str, Optional[str]]], prefix: str + ) -> List[str]: + """Format step details for Slack display. + + Args: + steps: List of (step_status, step_name, step_message) tuples + prefix: Section prefix/title + + Returns: + List of formatted step strings + """ details: List[str] = [] + details.append(f"*{prefix}*") - for step_status, step_name, step_message in workflow_status[1]: + for step_status, step_name, step_message in steps: if step_status == StepStatus.SUCCEEDED: details.append(f"• ✅ {step_name}") elif step_status == StepStatus.RUNNING: @@ -345,4 +421,4 @@ def _collect_workflow_details_slack( details.append(f"• ❌ {step_name}{msg}") break - return "\n".join(details) + return details diff --git a/src/tests/test_redis_version.py b/src/tests/test_redis_version.py new file mode 100644 index 0000000..bf781c9 --- /dev/null +++ b/src/tests/test_redis_version.py @@ -0,0 +1,164 @@ +"""Tests for data models.""" + +import pytest + +from redis_release.models import RedisVersion + + +class TestRedisVersion: + """Tests for RedisVersion model.""" + + def test_parse_basic_version(self): + """Test parsing basic version strings.""" + version = RedisVersion.parse("8.2.1") + assert version.major == 8 + assert version.minor == 2 + assert version.patch == 1 + assert version.suffix == "" + + def test_parse_version_with_v_prefix(self): + """Test parsing version with 'v' prefix.""" + version = RedisVersion.parse("v8.2.1") + assert version.major == 8 + assert version.minor == 2 + assert version.patch == 1 + assert version.suffix == "" + + def test_parse_version_with_suffix(self): + """Test parsing version with suffix.""" + version = RedisVersion.parse("8.2.1-m01") + assert version.major == 8 + assert version.minor == 2 + assert version.patch == 1 + assert version.suffix == "-m01" + + def test_parse_version_without_patch(self): + """Test parsing version without patch number.""" + version = RedisVersion.parse("8.2") + assert version.major == 8 + assert version.minor == 2 + assert version.patch is None + assert version.suffix == "" + + def test_parse_eol_version(self): + """Test parsing EOL version.""" + version = RedisVersion.parse("7.4.0-eol") + assert version.major == 7 + assert version.minor == 4 + assert version.patch == 0 + assert version.suffix == "-eol" + assert version.is_eol is True + + def test_parse_rc_internal_version(self): + """Test parsing RC internal version.""" + version = RedisVersion.parse("8.2.1-rc2-int3") + assert version.major == 8 + assert version.minor == 2 + assert version.patch == 1 + assert version.suffix == "-rc2-int3" + assert version.is_rc is True + assert version.is_internal is True + assert len(version.sort_key) > 0 + + version = RedisVersion.parse("8.4-int") + assert version.major == 8 + assert version.minor == 4 + assert version.patch == None + assert version.suffix == "-int" + assert version.is_internal is True + assert len(version.sort_key) > 0 + + def test_parse_invalid_version(self): + """Test parsing invalid version strings.""" + with pytest.raises(ValueError): + RedisVersion.parse("invalid") + + with pytest.raises(ValueError): + RedisVersion.parse("0.1.0") # Major version must be >= 1 + + def test_is_milestone(self): + """Test milestone detection.""" + ga_version = RedisVersion.parse("8.2.1") + milestone_version = RedisVersion.parse("8.2.1-m01") + + assert ga_version.is_milestone is False + assert milestone_version.is_milestone is True + + def test_mainline_version(self): + """Test mainline version property.""" + version = RedisVersion.parse("8.2.1-m01") + assert version.mainline_version == "8.2" + + def test_string_representation(self): + """Test string representation.""" + version1 = RedisVersion.parse("8.2.1") + version2 = RedisVersion.parse("8.2.1-m01") + version3 = RedisVersion.parse("8.2") + + assert str(version1) == "8.2.1" + assert str(version2) == "8.2.1-m01" + assert str(version3) == "8.2" + + def test_version_comparison(self): + """Test version comparison for sorting.""" + v8_2_1 = RedisVersion.parse("8.2.1") + v8_2_2 = RedisVersion.parse("8.2.2") + v8_2_1_m_01 = RedisVersion.parse("8.2.1-m01") + v8_2_1_rc_01 = RedisVersion.parse("8.2.1-rc01") + v8_2_1_rc_01_int_1 = RedisVersion.parse("8.2.1-rc01-int1") + v8_3_0 = RedisVersion.parse("8.3.0") + v8_3_0_rc_1 = RedisVersion.parse("8.3.0-rc1") + v8_3_0_rc_1_int_1 = RedisVersion.parse("8.3.0-rc1-int1") + v8_3_0_rc_1_int_2 = RedisVersion.parse("8.3.0-rc1-int2") + v8_4 = RedisVersion.parse("8.4") + v8_4_rc_1 = RedisVersion.parse("8.4-rc1") + v8_6_int = RedisVersion.parse("8.6-int") + + # Test numeric comparison + assert v8_2_1 < v8_2_2 + assert v8_2_2 < v8_3_0 + + # Test milestone vs GA (GA comes after milestone) + assert v8_2_1_m_01 < v8_2_1 + + assert v8_3_0_rc_1 < v8_3_0 + + assert v8_2_1_rc_01 > v8_2_1_m_01 + assert v8_2_1_rc_01_int_1 > v8_2_1_m_01 + assert v8_2_1_rc_01_int_1 < v8_2_1_rc_01 + + assert v8_3_0_rc_1_int_1 < v8_3_0_rc_1_int_2 + + assert v8_3_0_rc_1 > v8_3_0_rc_1_int_1 + assert v8_3_0_rc_1 > v8_3_0_rc_1_int_2 + + # Test sorting + versions = [ + v8_3_0, + v8_2_1, + v8_2_1_m_01, + v8_2_2, + v8_3_0_rc_1, + v8_3_0_rc_1_int_1, + v8_3_0_rc_1_int_2, + v8_6_int, + v8_4, + v8_4_rc_1, + v8_2_1_rc_01, + v8_2_1_rc_01_int_1, + ] + sorted_versions = sorted(versions) + assert sorted_versions == [ + v8_2_1_m_01, + v8_2_1_rc_01_int_1, + v8_2_1_rc_01, + v8_2_1, + v8_2_2, + v8_3_0_rc_1_int_1, + v8_3_0_rc_1_int_2, + v8_3_0_rc_1, + v8_3_0, + v8_4_rc_1, + v8_4, + v8_6_int, + ]