diff --git a/build_config/__init__.py b/build_config/__init__.py new file mode 100644 index 0000000..de96a99 --- /dev/null +++ b/build_config/__init__.py @@ -0,0 +1,23 @@ +"""Shared CustomBuild YAML config generation and validation.""" + +from build_config.config import ( + CONFIG_VERSION, + CONFIG_FILENAME, + build_config_dict, + config_dict_from_build_info, + dump_config_yaml, + schema_path_for_version, + validate_config_dict, + write_config_yaml, +) + +__all__ = [ + "CONFIG_VERSION", + "CONFIG_FILENAME", + "build_config_dict", + "config_dict_from_build_info", + "dump_config_yaml", + "schema_path_for_version", + "validate_config_dict", + "write_config_yaml", +] diff --git a/build_config/config.py b/build_config/config.py new file mode 100644 index 0000000..6bdce95 --- /dev/null +++ b/build_config/config.py @@ -0,0 +1,88 @@ +"""Serialize and validate CustomBuild config YAML (schemas/config).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping, Sequence + +import yaml +from jsonschema import Draft202012Validator + +CONFIG_VERSION = "0.0.1" +CONFIG_FILENAME = "custombuild.yaml" + +_SCHEMAS_DIR = Path(__file__).resolve().parent.parent / "schemas" / "config" + + +def schema_path_for_version(version: str = CONFIG_VERSION) -> Path: + path = _SCHEMAS_DIR / f"{version}.json" + if not path.is_file(): + raise FileNotFoundError(f"No config schema for version '{version}' at {path}") + return path + + +def validate_config_dict(config: Mapping[str, Any]) -> None: + version = config.get("config_version") + if not isinstance(version, str): + raise ValueError("Invalid or missing config_version") + schema = json.loads(schema_path_for_version(version).read_text(encoding="utf-8")) + Draft202012Validator(schema).validate(dict(config)) + + +def build_config_dict( + *, + vehicle_id: str, + vehicle_name: str, + version_id: str, + version_name: str, + version_type: str, + remote_name: str, + board_id: str, + board_name: str, + selected_features: Sequence[str], + config_version: str = CONFIG_VERSION, +) -> dict[str, Any]: + """Build a schema-compliant config dict (selected_features are API labels).""" + return { + "config_version": config_version, + "vehicle": {"id": vehicle_id, "name": vehicle_name}, + "version": { + "id": version_id, + "name": version_name, + "type": version_type, + "remote_name": remote_name, + }, + "board": {"id": board_id, "name": board_name}, + "selected_features": sorted(selected_features), + } + + +def dump_config_yaml(config: Mapping[str, Any]) -> str: + validate_config_dict(config) + return yaml.safe_dump( + dict(config), + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + + +def write_config_yaml(path: Path | str, config: Mapping[str, Any]) -> None: + path = Path(path) + path.write_text(dump_config_yaml(config), encoding="utf-8") + + +def config_dict_from_build_info(build_info: Any) -> dict[str, Any]: + """Build config from BuildInfo fields set at submit time.""" + return build_config_dict( + vehicle_id=build_info.vehicle_id, + vehicle_name=build_info.vehicle_name, + version_id=build_info.version_id, + version_name=build_info.version_name, + version_type=build_info.version_type, + remote_name=build_info.remote_info.name, + board_id=build_info.board, + board_name=build_info.board_name, + selected_features=list(build_info.selected_features), + ) diff --git a/build_manager/manager.py b/build_manager/manager.py index 9a623dd..50af92d 100644 --- a/build_manager/manager.py +++ b/build_manager/manager.py @@ -48,7 +48,11 @@ def __init__(self, remote_info: RemoteInfo, git_hash: str, board: str, - selected_features: set) -> None: + selected_features: set, + vehicle_name: str, + board_name: str, + version_name: str, + version_type: str) -> None: """ Initialize build information object including vehicle, remote, git hash, selected features, and progress of the build. @@ -62,6 +66,10 @@ def __init__(self, git_hash (str): The git commit hash to build on. board (str): Board to build for. selected_features (set): Set of feature API labels/IDs for the build. + vehicle_name (str): Display name for rebuild config YAML. + board_name (str): Display name for rebuild config YAML. + version_name (str): Display name for rebuild config YAML. + version_type (str): Release type for rebuild config YAML. """ self.vehicle_id = vehicle_id self.version_id = version_id @@ -69,6 +77,10 @@ def __init__(self, self.git_hash = git_hash self.board = board self.selected_features = selected_features + self.vehicle_name = vehicle_name + self.board_name = board_name + self.version_name = version_name + self.version_type = version_type self.progress = BuildProgress( state=BuildState.PENDING, percent=0 @@ -84,9 +96,13 @@ def to_dict(self) -> dict: 'git_hash': self.git_hash, 'board': self.board, 'selected_features': list(self.selected_features), + 'vehicle_name': self.vehicle_name, + 'board_name': self.board_name, + 'version_name': self.version_name, + 'version_type': self.version_type, 'progress': self.progress.to_dict(), 'time_created': self.time_created, - 'time_started': getattr(self, 'time_started', None), + 'time_started': self.time_started, } diff --git a/builder/builder.py b/builder/builder.py index dced6d2..9c9edf3 100644 --- a/builder/builder.py +++ b/builder/builder.py @@ -13,6 +13,11 @@ VehiclesManager as vehm ) from pathlib import Path +from build_config import ( + CONFIG_FILENAME, + config_dict_from_build_info, + write_config_yaml, +) CBS_BUILD_TIMEOUT_SEC = int(os.getenv('CBS_BUILD_TIMEOUT_SEC', 900)) @@ -286,6 +291,17 @@ def __generate_archive(self, build_id: str) -> None: ) files_to_include.append(extra_hwdef_path_abs) + # include rebuild config YAML (Builder is sole canonical author) + config_path = Path( + self.__get_path_to_build_dir(build_id) + ) / CONFIG_FILENAME + + try: + write_config_yaml(config_path, config_dict_from_build_info(build_info)) + files_to_include.append(str(config_path.resolve())) + except Exception: + self.logger.exception(f"Could not write {CONFIG_FILENAME} for {build_id}") + # create archive (inner folder matches download basename) folder_name = Path(archive_path).name.removesuffix(".tar.gz") with tarfile.open(archive_path, "w:gz") as tar: diff --git a/builder/requirements.txt b/builder/requirements.txt index 3c8b6d3..d238f48 100644 --- a/builder/requirements.txt +++ b/builder/requirements.txt @@ -3,3 +3,4 @@ redis==5.2.1 dill==0.3.8 requests==2.31.0 packaging==25.0 +PyYAML==6.0.2 diff --git a/schemas/config/0.0.1.json b/schemas/config/0.0.1.json new file mode 100644 index 0000000..9685d42 --- /dev/null +++ b/schemas/config/0.0.1.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "BuildConfig", + "description": "CustomBuild configuration file schema v0.0.1", + "type": "object", + "required": ["config_version", "vehicle", "version", "board", "selected_features"], + "additionalProperties": false, + "properties": { + "config_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Semver config format version" + }, + "vehicle": { + "type": "object", + "required": ["id", "name"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "version": { + "type": "object", + "required": ["id", "name", "type", "remote_name"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "type": { "type": "string" }, + "remote_name": { "type": "string" } + } + }, + "board": { + "type": "object", + "required": ["id", "name"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "selected_features": { + "type": "array", + "items": { "type": "string" } + }, + "use_default_features": { + "type": "boolean" + } + } +} diff --git a/tests/build_config/test_build_config.py b/tests/build_config/test_build_config.py new file mode 100644 index 0000000..0220c73 --- /dev/null +++ b/tests/build_config/test_build_config.py @@ -0,0 +1,101 @@ +"""Tests for shared build_config packaging helpers.""" +import tarfile +from pathlib import Path + +import pytest +import yaml + +from build_config import ( + CONFIG_FILENAME, + CONFIG_VERSION, + build_config_dict, + config_dict_from_build_info, + dump_config_yaml, + validate_config_dict, + write_config_yaml, +) +from build_manager import BuildInfo +from metadata_manager import RemoteInfo + + +def _valid_config(**overrides): + base = build_config_dict( + vehicle_id="copter", + vehicle_name="Copter", + version_id="ardupilot-Copter-4.5.0-abc", + version_name="4.5.0", + version_type="stable", + remote_name="ardupilot", + board_id="CubeOrange", + board_name="CubeOrange", + selected_features={"HAL_LOGGING_ENABLED"}, + ) + base.update(overrides) + return base + + +def test_validate_accepts_schema_compliant_config(): + validate_config_dict(_valid_config()) + + +def test_validate_rejects_missing_required_field(): + bad = _valid_config() + del bad["board"] + with pytest.raises(Exception): + validate_config_dict(bad) + + +def test_dump_and_write_roundtrip(tmp_path: Path): + config = _valid_config() + text = dump_config_yaml(config) + parsed = yaml.safe_load(text) + assert parsed["config_version"] == CONFIG_VERSION + assert parsed["vehicle"]["id"] == "copter" + + out = tmp_path / CONFIG_FILENAME + write_config_yaml(out, config) + assert out.is_file() + assert yaml.safe_load(out.read_text())["board"]["id"] == "CubeOrange" + + +def test_config_dict_from_build_info_uses_labels_and_names(): + info = BuildInfo( + vehicle_id="copter", + version_id="ver-1", + remote_info=RemoteInfo( + name="ardupilot", + url="https://github.com/ArduPilot/ardupilot.git", + ), + git_hash="abc123", + board="MatekH743", + selected_features={"HAL_LOGGING_ENABLED"}, + vehicle_name="Copter", + board_name="MatekH743", + version_name="4.5.0", + version_type="stable", + ) + config = config_dict_from_build_info(info) + validate_config_dict(config) + assert config["selected_features"] == ["HAL_LOGGING_ENABLED"] + assert config["version"]["name"] == "4.5.0" + assert config["version"]["remote_name"] == "ardupilot" + + +def test_packaged_yaml_inside_tar(tmp_path: Path): + """Simulate Builder archive membership for custombuild.yaml.""" + config = _valid_config() + config_path = tmp_path / CONFIG_FILENAME + write_config_yaml(config_path, config) + + archive = tmp_path / "copter-MatekH743-build1.tar.gz" + folder_name = archive.name.removesuffix(".tar.gz") + with tarfile.open(archive, "w:gz") as tar: + tar.add(config_path, arcname=f"{folder_name}/{CONFIG_FILENAME}") + + with tarfile.open(archive, "r:gz") as tar: + names = [m.name for m in tar.getmembers()] + assert f"{folder_name}/{CONFIG_FILENAME}" in names + extracted = tar.extractfile(f"{folder_name}/{CONFIG_FILENAME}") + assert extracted is not None + loaded = yaml.safe_load(extracted.read()) + assert loaded["vehicle"]["id"] == "copter" diff --git a/tests/web/test_builds_api.py b/tests/web/test_builds_api.py index cbd7a83..6cf4769 100644 --- a/tests/web/test_builds_api.py +++ b/tests/web/test_builds_api.py @@ -444,3 +444,34 @@ def test_get_artifact_method_not_allowed(self, client): for method in [client.post, client.put, client.patch, client.delete]: response = method("/api/v1/builds/build-abc123/artifact") assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + def test_get_config_returns_200_when_available(self, client): + """Returns YAML when the service provides packaged config.""" + mock_service = Mock() + mock_service.get_build_config_yaml.return_value = ( + "config_version: \"0.0.1\"\nvehicle:\n id: copter\n name: Copter\n", + "custombuild-copter-MatekH743-build-abc123.yaml", + ) + with self.override_builds_service(client, mock_service): + response = client.get("/api/v1/builds/build-abc123/config") + + assert response.status_code == status.HTTP_200_OK + assert "copter" in response.text + assert "attachment" in response.headers.get("content-disposition", "") + + def test_get_config_returns_404_when_not_available(self, client): + mock_service = Mock() + mock_service.get_build_config_yaml.return_value = None + with self.override_builds_service(client, mock_service): + response = client.get("/api/v1/builds/some-build-id/config") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "some-build-id" in response.json()["detail"] + + def test_get_config_service_called_with_correct_build_id(self, client): + mock_service = Mock() + mock_service.get_build_config_yaml.return_value = None + with self.override_builds_service(client, mock_service): + client.get("/api/v1/builds/target-build/config") + + mock_service.get_build_config_yaml.assert_called_once_with("target-build") diff --git a/tests/web/test_builds_service.py b/tests/web/test_builds_service.py index bbe6aa4..3361b9a 100644 --- a/tests/web/test_builds_service.py +++ b/tests/web/test_builds_service.py @@ -70,6 +70,10 @@ def make_build_info( git_hash="abc123def456", board="MatekH743", selected_features=None, + vehicle_name=None, + board_name=None, + version_name="4.5.0", + version_type="stable", state=bm.BuildState.PENDING, percent=0, ): @@ -80,6 +84,10 @@ def make_build_info( git_hash=git_hash, board=board, selected_features=set(selected_features) if selected_features is not None else set(), + vehicle_name=vehicle_name or vehicle_id.capitalize(), + board_name=board_name or board, + version_name=version_name, + version_type=version_type, ) info.progress = bm.BuildProgress(state=state, percent=percent) return info @@ -161,6 +169,21 @@ def test_create_build_raises_value_error_for_missing_vehicle_id(self, service): with pytest.raises(ValueError, match="vehicle_id is required"): service.create_build(request) + def test_create_build_raises_value_error_for_invalid_vehicle( + self, service, mock_vehicles_manager + ): + """ValueError is raised when vehicle_id is not a known vehicle.""" + mock_vehicles_manager.get_vehicle_by_id.return_value = None + request = BuildRequest( + vehicle_id="unknown-vehicle", + board_id="MatekH743", + version_id="copter-4.5.0-stable", + selected_features=[], + ) + + with pytest.raises(ValueError, match="Invalid vehicle_id"): + service.create_build(request) + def test_create_build_raises_value_error_for_missing_board_id( self, service ): @@ -216,9 +239,9 @@ def test_create_build_raises_value_error_when_remote_not_found( """ValueError is raised when the remote is not found.""" mock_versions_manager.get_remote_info.return_value = None request = BuildRequest( - vehicle_id="some-vehicle", - board_id="some-board", - version_id="some-version", + vehicle_id="copter", + board_id="MatekH743", + version_id="copter-4.5.0-stable", selected_features=[], ) @@ -725,3 +748,135 @@ def test_get_artifact_path_uses_correct_build_id( service.get_artifact_path("my-target-build") mock_build_manager.build_exists.assert_called_once_with("my-target-build") + + def test_get_build_includes_version_name_and_type( + self, + service, + mock_build_manager, + mock_versions_manager, + ): + """version.name and version.type come from versions_manager.""" + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info() + mock_versions_manager.get_version_info.return_value = make_version_info( + release_type="stable", + version_number="4.5.0", + ) + + result = service.get_build("build-abc123") + + assert result.version.name == "4.5.0" + assert result.version.type == "stable" + mock_versions_manager.get_version_info.assert_called_with( + vehicle_id="copter", + version_id="copter-4.5.0-stable", + ) + + def test_get_build_maps_latest_version_name_to_master( + self, + service, + mock_build_manager, + mock_versions_manager, + ): + """latest release_type is exposed as display name 'master'.""" + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info( + version_id="copter-latest", + ) + mock_versions_manager.get_version_info.return_value = make_version_info( + release_type="latest", + version_number="NA", + ) + + result = service.get_build("build-abc123") + + assert result.version.name == "master" + assert result.version.type == "latest" + + def test_get_build_falls_back_to_stored_version_when_unknown( + self, + service, + mock_build_manager, + mock_versions_manager, + ): + """Missing live version info uses names stored on BuildInfo.""" + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info( + version_name="4.5.0", + version_type="stable", + ) + mock_versions_manager.get_version_info.return_value = None + + result = service.get_build("build-abc123") + + assert result.version.name == "4.5.0" + assert result.version.type == "stable" + + def test_get_build_falls_back_to_stored_vehicle_name_when_unknown( + self, + service, + mock_build_manager, + mock_vehicles_manager, + ): + """Missing live vehicle uses display name stored on BuildInfo, not id.""" + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info( + vehicle_id="retired-vehicle", + vehicle_name="Retired Vehicle", + ) + mock_vehicles_manager.get_vehicle_by_id.side_effect = lambda _vid: None + + result = service.get_build("build-abc123") + + assert result.vehicle.id == "retired-vehicle" + assert result.vehicle.name == "Retired Vehicle" + + def test_get_build_config_yaml_generates_from_build_info( + self, + service, + mock_build_manager, + ): + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = make_build_info( + state=bm.BuildState.SUCCESS, + vehicle_id="copter", + board="MatekH743", + vehicle_name="Copter", + selected_features=["HAL_LOGGING_ENABLED"], + ) + + result = service.get_build_config_yaml("build-abc123") + + assert result is not None + yaml_text, filename = result + assert "HAL_LOGGING_ENABLED" in yaml_text + assert filename.endswith(".yaml") + + def test_get_build_config_yaml_returns_none_when_build_missing( + self, service, mock_build_manager + ): + mock_build_manager.build_exists.return_value = False + assert service.get_build_config_yaml("missing") is None + + def test_get_build_config_yaml_returns_none_when_info_missing( + self, service, mock_build_manager + ): + mock_build_manager.build_exists.return_value = True + mock_build_manager.get_build_info.return_value = None + assert service.get_build_config_yaml("build-abc123") is None + + def test_create_build_stores_display_metadata( + self, service, mock_build_manager + ): + service.create_build( + BuildRequest( + vehicle_id="copter", + board_id="MatekH743", + version_id="copter-4.5.0-stable", + selected_features=["HAL_LOGGING_ENABLED"], + ) + ) + submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"] + assert submitted.selected_features == {"HAL_LOGGING_ENABLED"} + assert submitted.vehicle_name == "Copter" + assert submitted.version_type == "stable" diff --git a/web/api/v1/builds.py b/web/api/v1/builds.py index 9f33077..cd8e651 100644 --- a/web/api/v1/builds.py +++ b/web/api/v1/builds.py @@ -9,7 +9,7 @@ Depends, Request ) -from fastapi.responses import FileResponse, PlainTextResponse +from fastapi.responses import FileResponse, PlainTextResponse, Response from web.schemas import ( BuildRequest, @@ -222,3 +222,51 @@ async def download_artifact( media_type='application/gzip', filename=os.path.basename(artifact_path) ) + + +@router.get( + "/{build_id}/config", + responses={ + 404: { + "description": ( + "Build not found or config could not be generated" + ) + } + } +) +async def download_config( + build_id: str = Path(..., description="Unique build identifier"), + service: BuildsService = Depends(get_builds_service) +): + """ + Download the CustomBuild config YAML for a build. + + Generated from build metadata via the shared build_config module + (same shape as the YAML packed into the archive by Builder). + + Args: + build_id: The unique build identifier + + Returns: + YAML config file + + Raises: + 404: Build not found or config could not be generated + """ + result = service.get_build_config_yaml(build_id) + if not result: + raise HTTPException( + status_code=404, + detail=( + f"Config not available for build '{build_id}'. " + "Build may not exist or metadata is incomplete." + ) + ) + yaml_text, filename = result + return Response( + content=yaml_text, + media_type="application/yaml", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }, + ) diff --git a/web/requirements.txt b/web/requirements.txt index 1862bbd..3f28e21 100644 --- a/web/requirements.txt +++ b/web/requirements.txt @@ -9,3 +9,4 @@ packaging==25.0 jinja2==3.1.2 python-multipart==0.0.6 slowapi==0.1.9 +PyYAML==6.0.2 diff --git a/web/schemas/builds.py b/web/schemas/builds.py index fc7e8a3..b654210 100644 --- a/web/schemas/builds.py +++ b/web/schemas/builds.py @@ -1,4 +1,4 @@ -from typing import List, Literal +from typing import List, Literal, Optional from pydantic import BaseModel, Field from web.schemas.vehicles import VehicleBase, BoardBase, RemoteInfo @@ -47,6 +47,10 @@ class BuildSubmitResponse(BaseModel): class BuildVersionInfo(BaseModel): """Version information for a build.""" id: str = Field(..., description="Version ID used for this build") + name: Optional[str] = Field(None, description="Version display name") + type: Optional[Literal["beta", "stable", "latest", "tag"]] = Field( + None, description="Version type classification" + ) remote_info: RemoteInfo = Field( ..., description="Source repository information" ) diff --git a/web/services/builds.py b/web/services/builds.py index 53bdfa1..3efdb9f 100644 --- a/web/services/builds.py +++ b/web/services/builds.py @@ -15,6 +15,7 @@ BuildVersionInfo, ) from web.schemas.vehicles import VehicleBase, BoardBase +from build_config import config_dict_from_build_info, dump_config_yaml # Import external modules # pylint: disable=wrong-import-position @@ -65,6 +66,10 @@ def create_build( if not vehicle_id: raise ValueError("vehicle_id is required") + vehicle = self.vehicles_manager.get_vehicle_by_id(vehicle_id) + if vehicle is None: + raise ValueError("Invalid vehicle_id") + # Get version info using version_id version_info = self.versions_manager.get_version_info( vehicle_id=vehicle_id, @@ -103,6 +108,11 @@ def create_build( commit_ref=commit_ref ) + if version_info.release_type == "latest": + version_display_name = "master" + else: + version_display_name = version_info.version_number + # Create build info build_info = build_manager.BuildInfo( vehicle_id=vehicle_id, @@ -111,6 +121,10 @@ def create_build( git_hash=git_hash, board=board_name, selected_features=set(build_request.selected_features), + vehicle_name=vehicle.name, + board_name=board_name, + version_name=version_display_name, + version_type=version_info.release_type, ) # Submit build @@ -258,6 +272,37 @@ def get_artifact_path(self, build_id: str) -> Optional[str]: return None + def get_build_config_yaml(self, build_id: str) -> Optional[tuple]: + """ + Generate custombuild.yaml from BuildInfo. + + Args: + build_id: The unique build identifier + + Returns: + (yaml_text, download_filename) or None if unavailable + """ + if not self.manager.build_exists(build_id): + return None + + build_info = self.manager.get_build_info(build_id) + if build_info is None: + return None + + try: + yaml_text = dump_config_yaml(config_dict_from_build_info(build_info)) + except Exception as e: + logger.error( + f"Error generating config YAML for build {build_id}: {e}" + ) + return None + + filename = ( + f"custombuild-{build_info.vehicle_id}-" + f"{build_info.board}-{build_id}.yaml" + ) + return yaml_text, filename + def _build_info_to_output( self, build_id: str, @@ -288,19 +333,39 @@ def _build_info_to_output( vehicle = self.vehicles_manager.get_vehicle_by_id( build_info.vehicle_id ) + if vehicle is not None: + vehicle_name = vehicle.name + else: + vehicle_name = build_info.vehicle_name or "" + + v_info = self.versions_manager.get_version_info( + vehicle_id=build_info.vehicle_id, + version_id=build_info.version_id + ) + if v_info is not None: + version_type = v_info.release_type + if v_info.release_type == "latest": + version_name = "master" + else: + version_name = v_info.version_number + else: + version_name = build_info.version_name + version_type = build_info.version_type return BuildOut( build_id=build_id, vehicle=VehicleBase( id=build_info.vehicle_id, - name=vehicle.name + name=vehicle_name ), board=BoardBase( id=build_info.board, - name=build_info.board # Board name is same as board ID for now + name=build_info.board_name ), version=BuildVersionInfo( id=build_info.version_id, + name=version_name, + type=version_type, remote_info=remote_info, git_hash=build_info.git_hash ),