diff --git a/docs/reference/organization/repository/environment_secret.md b/docs/reference/organization/repository/environment_secret.md new file mode 100644 index 00000000..ae48e362 --- /dev/null +++ b/docs/reference/organization/repository/environment_secret.md @@ -0,0 +1,66 @@ +Definition of a `Secret` on environment level, the following properties are supported: + +| Key | Value | Description | Note | +|-------------------------|----------------|------------------------------------------------|------| +| _name_ | string | The name of the secret | | +| _value_ | string | The secret value | | + +The secret value can be resolved via a credential provider. The supported format is `:`. + +- Bitwarden: `bitwarden:@` + + ``` json + "secret": "bitwarden:118276ad-158c-4720-b68d-af8c00fe3481@secret" + ``` + +- Pass: `pass:` + + ``` json + "secret": "pass:path/to/repo/secret" + ``` + +!!! note + + After executing an `import` operation, the secret will be set to `********` as GitHub will not disclose the + secret value anymore via its API. You will need to update the configuration with the real secret value, either + by entering the secret value (not advised), or referencing it via a credential provider. + + Secrets which have a redacted value defined will be skipped during processing. + +## Jsonnet Function + +``` jsonnet +orgs.newEnvironmentSecret('') { + : +} +``` + +## Validation rules + +- redacted secret values (`********`) trigger a validation info and will skip the secret during processing + +## Example usage + +=== "jsonnet" + ``` jsonnet + orgs.newOrg('OtterdogTest') { + ... + _repositories+:: [ + ... + orgs.newRepo('test-repo') { + ... + + environments: [ + orgs.newEnvironment('linux') { + secrets+: [ + orgs.newEnvironmentSecret('LICENSE_KEY') { + value: "pass:path/to/secret", + },w + ] + }, + ] + + } + ] + } + ``` diff --git a/docs/reference/organization/repository/environment_variable.md b/docs/reference/organization/repository/environment_variable.md new file mode 100644 index 00000000..f8c7f3bb --- /dev/null +++ b/docs/reference/organization/repository/environment_variable.md @@ -0,0 +1,44 @@ +Definition of a `Variable` on environment level, the following properties are supported: + +| Key | Value | Description | Note | +|-------------------------|----------------|--------------------------|------| +| _name_ | string | The name of the variable | | +| _value_ | string | The variable value | | + +## Jsonnet Function + +``` jsonnet +orgs.newEnvironmentVariable('') { + : +} +``` + +## Validation rules + +- None + +## Example usage + +=== "jsonnet" + ``` jsonnet + orgs.newOrg('OtterdogTest') { + ... + _repositories+:: [ + ... + orgs.newRepo('test-repo') { + ... + + environments: [ + orgs.newEnvironment('linux') { + secrets+: [ + orgs.newEnvironmentVariable('LICENSE_SEREVER') { + value: "127.0.0.1", + },w + ] + }, + ] + + } + ] + } + ``` diff --git a/docs/reference/organization/repository/secret.md b/docs/reference/organization/repository/secret.md index 34908f8d..7299c7f9 100644 --- a/docs/reference/organization/repository/secret.md +++ b/docs/reference/organization/repository/secret.md @@ -50,7 +50,7 @@ orgs.newRepoSecret('') { orgs.newRepo('test-repo') { ... secrets+: [ - orgs.newRepoSecret('TEST_SECRET') { + orgs.newRepoSecret('REPO_SECRET') { value: "pass:path/to/secret", }, ], diff --git a/docs/reference/organization/repository/variable.md b/docs/reference/organization/repository/variable.md index dbb7d610..98894679 100644 --- a/docs/reference/organization/repository/variable.md +++ b/docs/reference/organization/repository/variable.md @@ -28,7 +28,7 @@ orgs.newRepoVariable('') { orgs.newRepo('test-repo') { ... variables+: [ - orgs.newRepoVariable('TEST_VARIABLE') { + orgs.newRepoVariable('REPO_VARIABLE') { value: "TESTVALUE", }, ], diff --git a/examples/template/otterdog-defaults.libsonnet b/examples/template/otterdog-defaults.libsonnet index 4c2ec91e..231ddc97 100644 --- a/examples/template/otterdog-defaults.libsonnet +++ b/examples/template/otterdog-defaults.libsonnet @@ -230,6 +230,12 @@ local newOrgSecret(name) = newRepoSecret(name) { selected_repositories: [], }; +# Function to create a new environment secret with default settings. +local newEnvironmentSecret(name) = { + name: name, + value: null, +}; + # Function to create a new repository variable with default settings. local newRepoVariable(name) = { name: name, @@ -242,6 +248,12 @@ local newOrgVariable(name) = newRepoVariable(name) { selected_repositories: [], }; +# Function to create a new environment variable with default settings. +local newEnvironmentVariable(name) = { + name: name, + value: null, +}; + # Function to create a new organization role with default settings. local newOrgRole(name) = { name: name, diff --git a/otterdog/jsonnet.py b/otterdog/jsonnet.py index 793942c7..6fe48524 100644 --- a/otterdog/jsonnet.py +++ b/otterdog/jsonnet.py @@ -42,6 +42,8 @@ class JsonnetConfig: create_branch_protection_rule = "newBranchProtectionRule" create_repo_ruleset = "newRepoRuleset" create_environment = "newEnvironment" + create_environment_secret = "newEnvironmentSecret" + create_environment_variable = "newEnvironmentVariable" create_pull_request = "newPullRequest" create_status_checks = "newStatusChecks" create_merge_queue = "newMergeQueue" diff --git a/otterdog/models/environment.py b/otterdog/models/environment.py index b79005c3..d365a496 100644 --- a/otterdog/models/environment.py +++ b/otterdog/models/environment.py @@ -23,7 +23,12 @@ from otterdog.utils import expect_type, is_set_and_valid, is_unset, unwrap if TYPE_CHECKING: + from collections.abc import Iterator + from otterdog.jsonnet import JsonnetConfig + from otterdog.models.environment_secret import EnvironmentSecret + from otterdog.models.environment_variable import EnvironmentVariable + from otterdog.models.repository import Repository from otterdog.providers.github import GitHubProvider @@ -41,10 +46,35 @@ class Environment(ModelObject): deployment_branch_policy: str branch_policies: list[str] + # internal parent tracking (model-only, not serialized) + parent_repository: Repository | None = dataclasses.field(default=None, metadata={"model_only": True}) + + # nested model fields + variables: list[EnvironmentVariable] = dataclasses.field(metadata={"nested_model": True}, default_factory=list) + secrets: list[EnvironmentSecret] = dataclasses.field(metadata={"nested_model": True}, default_factory=list) + @property def model_object_name(self) -> str: return "environment" + def add_variable(self, variable: EnvironmentVariable) -> None: + self.variables.append(variable) + + def get_variable(self, name: str) -> EnvironmentVariable | None: + return next(filter(lambda x: x.name == name, self.variables), None) + + def set_variables(self, variables: list[EnvironmentVariable]) -> None: + self.variables = variables + + def add_secret(self, secret: EnvironmentSecret) -> None: + self.secrets.append(secret) + + def get_secret(self, name: str) -> EnvironmentSecret | None: + return next(filter(lambda x: x.name == name, self.secrets), None) + + def set_secrets(self, secrets: list[EnvironmentSecret]) -> None: + self.secrets = secrets + def validate(self, context: ValidationContext, parent_object: Any) -> None: if not is_unset(self.wait_timer) and not (0 <= self.wait_timer <= 43200): context.add_failure( @@ -96,15 +126,17 @@ def include_existing_object_for_live_patch(self, org_id: str, parent_object: Mod @classmethod def get_mapping_from_provider(cls, org_id: str, data: dict[str, Any]) -> dict[str, Any]: + """Construct Environment object from json data returned by GitHub API.""" + mapping = super().get_mapping_from_provider(org_id, data) def transform_reviewers(x): match x["type"]: case "User": - return f'@{x["reviewer"]["login"]}' + return f"@{x['reviewer']['login']}" case "Team": - return f'@{org_id}/{x["reviewer"]["slug"]}' + return f"@{org_id}/{x['reviewer']['slug']}" case _: raise RuntimeError("unexpected review type '{x[\"type\"]}'") @@ -147,6 +179,23 @@ def transform_branch_policy(x): ) return mapping + @classmethod + def get_mapping_from_model(cls) -> dict[str, Any]: + # Overriding get_mapping_from_model is required to handle nested models + from otterdog.models.environment_secret import EnvironmentSecret + from otterdog.models.environment_variable import EnvironmentVariable + + mapping = super().get_mapping_from_model() + mapping.update( + { + "variables": OptionalS("variables", default=[]) + >> Forall(lambda x: EnvironmentVariable.from_model_data(x)), + "secrets": OptionalS("secrets", default=[]) >> Forall(lambda x: EnvironmentSecret.from_model_data(x)), + } + ) + + return mapping + @classmethod async def get_mapping_to_provider( cls, org_id: str, data: dict[str, Any], provider: GitHubProvider @@ -192,6 +241,22 @@ async def get_mapping_to_provider( def get_jsonnet_template_function(self, jsonnet_config: JsonnetConfig, extend: bool) -> str | None: return f"orgs.{jsonnet_config.create_environment}" + def get_model_objects(self) -> Iterator[tuple[ModelObject, ModelObject]]: + """ + Report any nested model objects for nested processing. + + Especially get_mapping_to_provider iterates over self, and not over the nested + model objects, so we need to report them here for processing. + """ + + for secret in self.secrets: + yield secret, self + yield from secret.get_model_objects() + + for variable in self.variables: + yield variable, self + yield from variable.get_model_objects() + @classmethod async def apply_live_patch(cls, patch: LivePatch[Environment], org_id: str, provider: GitHubProvider) -> None: from .repository import Repository diff --git a/otterdog/models/environment_secret.py b/otterdog/models/environment_secret.py new file mode 100644 index 00000000..b5437ddd --- /dev/null +++ b/otterdog/models/environment_secret.py @@ -0,0 +1,79 @@ +# ******************************************************************************* +# Copyright (c) 2023-2025 Eclipse Foundation and others. +# This program and the accompanying materials are made available +# under the terms of the Eclipse Public License 2.0 +# which is available at http://www.eclipse.org/legal/epl-v20.html +# SPDX-License-Identifier: EPL-2.0 +# ******************************************************************************* + +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING + +from otterdog.models import LivePatchType +from otterdog.models.environment import Environment +from otterdog.models.repository import Repository +from otterdog.models.secret import Secret +from otterdog.utils import expect_type, unwrap + +if TYPE_CHECKING: + from otterdog.jsonnet import JsonnetConfig + from otterdog.models import LivePatch + from otterdog.providers.github import GitHubProvider + + +@dataclasses.dataclass +class EnvironmentSecret(Secret): + """ + Represents a Secret defined on environment level. + """ + + @property + def model_object_name(self) -> str: + return "environment_secret" + + def get_jsonnet_template_function(self, jsonnet_config: JsonnetConfig, extend: bool) -> str | None: + return f"orgs.{jsonnet_config.create_environment_secret}" + + @classmethod + async def apply_live_patch( # type: ignore[override] + cls, + patch: LivePatch[EnvironmentSecret], + org_id: str, + provider: GitHubProvider, + ) -> None: + environment = expect_type(patch.parent_object, Environment) + repository = expect_type(environment.parent_repository, Repository) + + match patch.patch_type: + case LivePatchType.ADD: + new_object = unwrap(patch.expected_object) + data = await new_object.to_provider_data(org_id, provider) + await provider.create_environment_secret( + org_id, + repository.name, + environment.name, + data, + ) + + case LivePatchType.REMOVE: + remove_object = unwrap(patch.current_object) + await provider.delete_environment_secret( + org_id, + repository.name, + environment.name, + remove_object.name, + ) + + case LivePatchType.CHANGE: + current_obj: EnvironmentSecret = unwrap(patch.current_object) + expected_obj: EnvironmentSecret = unwrap(patch.expected_object) + data = await expected_obj.to_provider_data(org_id, provider) + await provider.update_environment_secret( + org_id, + repository.name, + environment.name, + current_obj.name, + data, + ) diff --git a/otterdog/models/environment_variable.py b/otterdog/models/environment_variable.py new file mode 100644 index 00000000..34e46e0a --- /dev/null +++ b/otterdog/models/environment_variable.py @@ -0,0 +1,65 @@ +# ******************************************************************************* +# Copyright (c) 2023-2025 Eclipse Foundation and others. +# This program and the accompanying materials are made available +# under the terms of the Eclipse Public License 2.0 +# which is available at http://www.eclipse.org/legal/epl-v20.html +# SPDX-License-Identifier: EPL-2.0 +# ******************************************************************************* + +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING + +from otterdog.models import LivePatchType +from otterdog.models.environment import Environment +from otterdog.models.repository import Repository +from otterdog.models.variable import Variable +from otterdog.utils import expect_type, unwrap + +if TYPE_CHECKING: + from otterdog.jsonnet import JsonnetConfig + from otterdog.models import LivePatch + from otterdog.providers.github import GitHubProvider + + +@dataclasses.dataclass +class EnvironmentVariable(Variable): + """ + Represents a Variable defined on environment level. + """ + + @property + def model_object_name(self) -> str: + return "environment_variable" + + def get_jsonnet_template_function(self, jsonnet_config: JsonnetConfig, extend: bool) -> str | None: + return f"orgs.{jsonnet_config.create_environment_variable}" + + @classmethod + async def apply_live_patch( # type: ignore[override] + cls, + patch: LivePatch[EnvironmentVariable], + org_id: str, + provider: GitHubProvider, + ) -> None: + environment = expect_type(patch.parent_object, Environment) + repository = expect_type(environment.parent_repository, Repository) + + match patch.patch_type: + case LivePatchType.ADD: + expected = unwrap(patch.expected_object) + data = await expected.to_provider_data(org_id, provider) + await provider.create_environment_variable(org_id, repository.name, environment.name, data) + + case LivePatchType.REMOVE: + current = unwrap(patch.current_object) + await provider.delete_environment_variable(org_id, repository.name, environment.name, current.name) + + case LivePatchType.CHANGE: + current = unwrap(patch.current_object) + expected = unwrap(patch.expected_object) + data = await cls.changes_to_provider(org_id, unwrap(patch.changes), provider) + await provider.update_environment_variable( + org_id, repository.name, environment.name, current.name, data + ) diff --git a/otterdog/models/github_organization.py b/otterdog/models/github_organization.py index 0e22a2e4..0ad5eb4e 100644 --- a/otterdog/models/github_organization.py +++ b/otterdog/models/github_organization.py @@ -32,6 +32,8 @@ from otterdog.models.branch_protection_rule import BranchProtectionRule from otterdog.models.custom_property import CustomProperty from otterdog.models.environment import Environment +from otterdog.models.environment_secret import EnvironmentSecret +from otterdog.models.environment_variable import EnvironmentVariable from otterdog.models.organization_role import OrganizationRole from otterdog.models.organization_ruleset import OrganizationRuleset from otterdog.models.organization_secret import OrganizationSecret @@ -750,7 +752,17 @@ async def _process_single_repo( # get environments of the repo environments = await rest_api.repo.get_environments(github_id, repo_name) for github_environment in environments: - repo.add_environment(Environment.from_provider_data(github_id, github_environment)) + env = Environment.from_provider_data(github_id, github_environment) + + env_variables = await rest_api.repo.get_variables(github_id, repo_name, env.name) + for github_variable in env_variables: + env.add_variable(EnvironmentVariable.from_provider_data(github_id, github_variable)) + + env_secrets = await rest_api.repo.get_secrets(github_id, repo_name, env.name) + for github_secret in env_secrets: + env.add_secret(EnvironmentSecret.from_provider_data(github_id, github_secret)) + + repo.add_environment(env) else: _logger.debug("not reading environments, no default config available") diff --git a/otterdog/models/repository.py b/otterdog/models/repository.py index 6f95cb75..d8f8a005 100644 --- a/otterdog/models/repository.py +++ b/otterdog/models/repository.py @@ -238,11 +238,9 @@ def set_variables(self, variables: list[RepositoryVariable]) -> None: self.variables = variables def add_environment(self, environment: Environment) -> None: + environment.parent_repository = self self.environments.append(environment) - def set_environments(self, environments: list[Environment]) -> None: - self.environments = environments - def coerce_from_org_settings(self, org_settings: OrganizationSettings, for_patch: bool = False) -> Repository: copy = dataclasses.replace(self) diff --git a/otterdog/providers/github/__init__.py b/otterdog/providers/github/__init__.py index ee2c8555..6f9f77da 100644 --- a/otterdog/providers/github/__init__.py +++ b/otterdog/providers/github/__init__.py @@ -21,6 +21,8 @@ if TYPE_CHECKING: from typing import Any + from aiohttp_retry import RetryClient + from otterdog.credentials import Credentials @@ -41,11 +43,11 @@ def is_org_settings_key_retrieved_via_web_ui(key: str) -> bool: class GitHubProvider: - def __init__(self, credentials: Credentials | None): + def __init__(self, credentials: Credentials | None, http_client: RetryClient | None = None): self._credentials = credentials if credentials is not None: - self._init_clients() + self._init_clients(http_client) async def __aenter__(self): return self @@ -61,7 +63,7 @@ async def close(self) -> None: with contextlib.suppress(CancelledError): await self.graphql_client.close() - def _init_clients(self): + def _init_clients(self, http_client: RetryClient | None) -> None: from otterdog.cache import get_github_cache from otterdog.providers.github.auth import token_auth @@ -69,9 +71,15 @@ def _init_clients(self): from .rest import RestApi from .web import WebClient - self.rest_api = RestApi(token_auth(self._credentials.github_token), get_github_cache()) + if not self._credentials: + raise RuntimeError("cannot initialize GitHubProvider without credentials") + + cache = get_github_cache() + auth = token_auth(self._credentials.github_token) + + self.rest_api = RestApi(auth, cache, http_client) self.web_client = WebClient(self._credentials) - self.graphql_client = GraphQLClient(token_auth(self._credentials.github_token), get_github_cache()) + self.graphql_client = GraphQLClient(auth, cache, http_client) async def get_content(self, org_id: str, repo_name: str, path: str, ref: str | None = None) -> str: return await self.rest_api.content.get_content(org_id, repo_name, path, ref) @@ -368,6 +376,7 @@ async def update_repo_workflow_settings( ) -> None: await self.rest_api.repo.update_workflow_settings(org_id, repo_name, workflow_settings) + # CRUD for Org Secrets async def get_org_secrets(self, org_id: str) -> list[dict[str, Any]]: return await self.rest_api.org.get_secrets(org_id) @@ -381,6 +390,7 @@ async def add_org_secret(self, org_id: str, data: dict[str, str]) -> None: async def delete_org_secret(self, org_id: str, secret_name: str) -> None: await self.rest_api.org.delete_secret(org_id, secret_name) + # CRUD for Org Variables async def get_org_variables(self, org_id: str) -> list[dict[str, Any]]: return await self.rest_api.org.get_variables(org_id) @@ -394,33 +404,70 @@ async def add_org_variable(self, org_id: str, data: dict[str, str]) -> None: async def delete_org_variable(self, org_id: str, variable_name: str) -> None: await self.rest_api.org.delete_variable(org_id, variable_name) + # CRUD for Repo Secrets async def get_repo_secrets(self, org_id: str, repo_name: str) -> list[dict[str, Any]]: - return await self.rest_api.repo.get_secrets(org_id, repo_name) + return await self.rest_api.repo.get_secrets(org_id, repo_name, None) async def update_repo_secret(self, org_id: str, repo_name: str, secret_name: str, secret: dict[str, Any]) -> None: if len(secret) > 0: - await self.rest_api.repo.update_secret(org_id, repo_name, secret_name, secret) + await self.rest_api.repo.update_secret(org_id, repo_name, None, secret_name, secret) async def add_repo_secret(self, org_id: str, repo_name: str, data: dict[str, str]) -> None: - await self.rest_api.repo.add_secret(org_id, repo_name, data) + await self.rest_api.repo.add_secret(org_id, repo_name, None, data) async def delete_repo_secret(self, org_id: str, repo_name: str, secret_name: str) -> None: - await self.rest_api.repo.delete_secret(org_id, repo_name, secret_name) + await self.rest_api.repo.delete_secret(org_id, repo_name, None, secret_name) async def get_repo_variables(self, org_id: str, repo_name: str) -> list[dict[str, Any]]: - return await self.rest_api.repo.get_variables(org_id, repo_name) + return await self.rest_api.repo.get_variables(org_id, repo_name, None) async def update_repo_variable( self, org_id: str, repo_name: str, variable_name: str, variable: dict[str, Any] ) -> None: if len(variable) > 0: - await self.rest_api.repo.update_variable(org_id, repo_name, variable_name, variable) + await self.rest_api.repo.update_variable(org_id, repo_name, None, variable_name, variable) async def add_repo_variable(self, org_id: str, repo_name: str, data: dict[str, str]) -> None: - await self.rest_api.repo.add_variable(org_id, repo_name, data) + await self.rest_api.repo.add_variable(org_id, repo_name, None, data) async def delete_repo_variable(self, org_id: str, repo_name: str, variable_name: str) -> None: - await self.rest_api.repo.delete_variable(org_id, repo_name, variable_name) + await self.rest_api.repo.delete_variable(org_id, repo_name, None, variable_name) + + # CRUD for Environment Variables + async def create_environment_variable( + self, org_id: str, repo_name: str, env_name: str, data: dict[str, str] + ) -> None: + await self.rest_api.repo.add_variable(org_id, repo_name, env_name, data) + + async def get_environment_variables(self, org_id: str, repo_name: str, env_name: str) -> list[dict[str, Any]]: + return await self.rest_api.repo.get_variables(org_id, repo_name, env_name) + + async def update_environment_variable( + self, org_id: str, repo_name: str, env_name: str, variable_name: str, variable: dict[str, Any] + ) -> None: + if len(variable) > 0: + await self.rest_api.repo.update_variable(org_id, repo_name, env_name, variable_name, variable) + + async def delete_environment_variable(self, org_id: str, repo_name: str, env_name: str, variable_name: str) -> None: + await self.rest_api.repo.delete_variable(org_id, repo_name, env_name, variable_name) + + # CRUD for Environment Secrets + async def create_environment_secret(self, org_id: str, repo_name: str, env_name: str, data: dict[str, str]) -> None: + await self.rest_api.repo.add_secret(org_id, repo_name, env_name, data) + + async def get_environment_secrets(self, org_id: str, repo_name: str, env_name: str) -> list[dict[str, Any]]: + return await self.rest_api.repo.get_secrets(org_id, repo_name, env_name) + + async def update_environment_secret( + self, org_id: str, repo_name: str, env_name: str, secret_name: str, secret: dict[str, Any] + ) -> None: + if len(secret) > 0: + await self.rest_api.repo.update_secret(org_id, repo_name, env_name, secret_name, secret) + + async def delete_environment_secret(self, org_id: str, repo_name: str, env_name: str, secret_name: str) -> None: + await self.rest_api.repo.delete_secret(org_id, repo_name, env_name, secret_name) + + # ... async def dispatch_workflow(self, org_id: str, repo_name: str, workflow_name: str) -> bool: return await self.rest_api.repo.dispatch_workflow(org_id, repo_name, workflow_name) diff --git a/otterdog/providers/github/graphql.py b/otterdog/providers/github/graphql.py index 529f17bf..1dcff2ab 100644 --- a/otterdog/providers/github/graphql.py +++ b/otterdog/providers/github/graphql.py @@ -28,10 +28,22 @@ _logger = get_logger(__name__) +def create_http_client(session: ClientSession) -> RetryClient: + return RetryClient( + retry_options=ExponentialRetry(3, exceptions={Exception}), + client_session=session, + ) + + class GraphQLClient: _GH_GRAPHQL_URL_ROOT = "api.github.com/graphql" - def __init__(self, auth_strategy: AuthStrategy, cache_strategy: CacheStrategy | None = None): + def __init__( + self, + auth_strategy: AuthStrategy, + cache_strategy: CacheStrategy | None = None, + http_client: RetryClient | None = None, + ): self._auth = auth_strategy.get_auth() self._headers = { @@ -54,10 +66,7 @@ def __init__(self, auth_strategy: AuthStrategy, cache_strategy: CacheStrategy | connector=TCPConnector(limit=10), ) - self._client = RetryClient( - retry_options=ExponentialRetry(3, exceptions={Exception}), - client_session=self._session, - ) + self._client = http_client or create_http_client(self._session) async def __aenter__(self): return self diff --git a/otterdog/providers/github/rest/__init__.py b/otterdog/providers/github/rest/__init__.py index 2bddd162..526bcbaf 100644 --- a/otterdog/providers/github/rest/__init__.py +++ b/otterdog/providers/github/rest/__init__.py @@ -17,6 +17,8 @@ from .requester import Requester if TYPE_CHECKING: + from aiohttp_retry import RetryClient + from otterdog.providers.github.auth import AuthStrategy from otterdog.providers.github.cache import CacheStrategy from otterdog.providers.github.stats import RequestStatistics @@ -33,10 +35,13 @@ def __init__( self, auth_strategy: AuthStrategy | None = None, cache_strategy: CacheStrategy = _DEFAULT_CACHE_STRATEGY, + http_client: RetryClient | None = None, ): self._auth_strategy = auth_strategy self._cache_strategy = cache_strategy - self._requester = Requester(auth_strategy, cache_strategy, self._GH_API_URL_ROOT, self._GH_API_VERSION) + self._requester = Requester( + auth_strategy, cache_strategy, self._GH_API_URL_ROOT, self._GH_API_VERSION, http_client=http_client + ) async def __aenter__(self): return self @@ -161,7 +166,18 @@ def encrypt_value(public_key: str, secret_value: str) -> str: public_key_obj = public.PublicKey(public_key.encode("utf-8"), encoding.Base64Encoder) sealed_box = public.SealedBox(public_key_obj) encrypted = sealed_box.encrypt(secret_value.encode("utf-8")) - return b64encode(encrypted).decode("utf-8") + encrypted_str = b64encode(encrypted).decode("utf-8") + + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print(f"Encrypted value for secret '{secret_value}' with public key '{public_key}': {encrypted_str}") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + + return encrypted_str def parse_iso_date_string(date: str) -> datetime: diff --git a/otterdog/providers/github/rest/org_client.py b/otterdog/providers/github/rest/org_client.py index 0d39598f..73f6ecb0 100644 --- a/otterdog/providers/github/rest/org_client.py +++ b/otterdog/providers/github/rest/org_client.py @@ -9,10 +9,11 @@ import json from typing import Any +import otterdog.providers.github.rest as github_rest # for monkeypatching encrypt_value from otterdog.providers.github.exception import GitHubException from otterdog.utils import get_logger -from . import RestApi, RestClient, encrypt_value +from . import RestApi, RestClient _logger = get_logger(__name__) @@ -371,7 +372,7 @@ async def _encrypt_secret_inplace(self, org_id: str, data: dict[str, Any]) -> No if "value" in data: value = data.pop("value") key_id, public_key = await self.get_public_key(org_id) - data["encrypted_value"] = encrypt_value(public_key, value) + data["encrypted_value"] = github_rest.encrypt_value(public_key, value) data["key_id"] = key_id async def delete_secret(self, org_id: str, secret_name: str) -> None: diff --git a/otterdog/providers/github/rest/repo_client.py b/otterdog/providers/github/rest/repo_client.py index b560741f..2fba2130 100644 --- a/otterdog/providers/github/rest/repo_client.py +++ b/otterdog/providers/github/rest/repo_client.py @@ -18,9 +18,10 @@ import aiofiles import chevron +import otterdog.providers.github.rest as github_rest # for monkeypatching encrypt_value from otterdog.logging import is_trace_enabled from otterdog.providers.github.exception import GitHubException -from otterdog.providers.github.rest import RestApi, RestClient, encrypt_value +from otterdog.providers.github.rest import RestApi, RestClient from otterdog.utils import ( associate_by_key, get_logger, @@ -31,6 +32,22 @@ _logger = get_logger(__name__) +def _scope_str(org_id: str, repo_name: str, env_name: str | None) -> str: + scope = f"{org_id}/{repo_name}" + if env_name: + scope += f" (environment: {env_name})" + return scope + + +def _repo_actions_or_environment_endpoint(org_id: str, repo_name: str, env_name: str | None) -> str: + """Compute the base endpoint for repo or environment scoped operations.""" + + if env_name: + return f"/repos/{org_id}/{repo_name}/environments/{env_name}" + else: + return f"/repos/{org_id}/{repo_name}/actions" + + class RepoClient(RestClient): def __init__(self, rest_api: RestApi): super().__init__(rest_api) @@ -813,126 +830,154 @@ async def _delete_deployment_branch_policy( _logger.debug("deleted deployment branch policy for env '%s'", env_name) - async def get_secrets(self, org_id: str, repo_name: str) -> list[dict[str, Any]]: - _logger.debug("retrieving secrets for repo '%s/%s'", org_id, repo_name) + async def get_secrets(self, org_id: str, repo_name: str, env_name: str | None) -> list[dict[str, Any]]: + scope = _scope_str(org_id, repo_name, env_name) + _logger.debug("retrieving secrets for %s", scope) + + # This maps to githubs "list secrets" endpoint and not the "get a secret" endpoint, + # therefore no secret name is provided. + endpoint = _repo_actions_or_environment_endpoint(org_id, repo_name, env_name) + "/secrets" try: - status, body = await self.requester.request_raw("GET", f"/repos/{org_id}/{repo_name}/actions/secrets") + status, body = await self.requester.request_raw("GET", endpoint) if status == 200: return json.loads(body)["secrets"] else: return [] except GitHubException as ex: - raise RuntimeError(f"failed retrieving secrets for repo '{org_id}/{repo_name}':\n{ex}") from ex + raise RuntimeError(f"failed retrieving secrets for '{scope}':\n{ex}") from ex - async def update_secret(self, org_id: str, repo_name: str, secret_name: str, secret: dict[str, Any]) -> None: - _logger.debug("updating repo secret '%s' for repo '%s/%s'", secret_name, org_id, repo_name) + async def update_secret( + self, org_id: str, repo_name: str, env_name: str | None, secret_name: str, secret: dict[str, Any] + ) -> None: + scope = _scope_str(org_id, repo_name, env_name) + _logger.debug("updating secret '%s' in %s", secret_name, scope) if "name" in secret: secret.pop("name") - await self._encrypt_secret_inplace(org_id, repo_name, secret) + await self._encrypt_secret_inplace(org_id, repo_name, secret, env_name) - status, _ = await self.requester.request_raw( - "PUT", - f"/repos/{org_id}/{repo_name}/actions/secrets/{secret_name}", - json.dumps(secret), - ) + endpoint = _repo_actions_or_environment_endpoint(org_id, repo_name, env_name) + f"/secrets/{secret_name}" + status, _ = await self.requester.request_raw("PUT", endpoint, json.dumps(secret)) if status != 204: raise RuntimeError(f"failed to update repo secret '{secret_name}'") _logger.debug("updated repo secret '%s'", secret_name) - async def add_secret(self, org_id: str, repo_name: str, data: dict[str, str]) -> None: + async def add_secret(self, org_id: str, repo_name: str, env_name: str | None, data: dict[str, str]) -> None: secret_name = data.pop("name") - _logger.debug("adding repo secret '%s' for repo '%s/%s'", secret_name, org_id, repo_name) + scope = _scope_str(org_id, repo_name, env_name) + _logger.debug("adding secret '%s' for %s", secret_name, scope) - await self._encrypt_secret_inplace(org_id, repo_name, data) + await self._encrypt_secret_inplace(org_id, repo_name, data, env_name) - status, _ = await self.requester.request_raw( - "PUT", - f"/repos/{org_id}/{repo_name}/actions/secrets/{secret_name}", - json.dumps(data), - ) + endpoint = _repo_actions_or_environment_endpoint(org_id, repo_name, env_name) + f"/secrets/{secret_name}" + + status, _ = await self.requester.request_raw("PUT", endpoint, json.dumps(data)) if status != 201: - raise RuntimeError(f"failed to add repo secret '{secret_name}'") + raise RuntimeError(f"failed to add repo secret '{secret_name}' to {scope}") - _logger.debug("added repo secret '%s'", secret_name) + _logger.debug("added secret '%s' to %s", secret_name, scope) - async def _encrypt_secret_inplace(self, org_id: str, repo_name: str, data: dict[str, Any]) -> None: + async def _encrypt_secret_inplace( + self, org_id: str, repo_name: str, data: dict[str, Any], env_name: str | None + ) -> None: value = data.pop("value") - key_id, public_key = await self.get_public_key(org_id, repo_name) - data["encrypted_value"] = encrypt_value(public_key, value) + key_id, public_key = await self._get_public_key(org_id, repo_name, env_name) + data["encrypted_value"] = github_rest.encrypt_value(public_key, value) data["key_id"] = key_id - async def delete_secret(self, org_id: str, repo_name: str, secret_name: str) -> None: - _logger.debug("deleting repo secret '%s' for repo '%s/%s'", secret_name, org_id, repo_name) + async def delete_secret(self, org_id: str, repo_name: str, env_name: str | None, secret_name: str) -> None: + scope = _scope_str(org_id, repo_name, env_name) + _logger.debug("deleting secret '%s' from %s", secret_name, scope) - status, _ = await self.requester.request_raw( - "DELETE", f"/repos/{org_id}/{repo_name}/actions/secrets/{secret_name}" - ) + endpoint = _repo_actions_or_environment_endpoint(org_id, repo_name, env_name) + f"/secrets/{secret_name}" + status, _ = await self.requester.request_raw("DELETE", endpoint) if status != 204: raise RuntimeError(f"failed to delete repo secret '{secret_name}'") - _logger.debug("removed repo secret '%s'", secret_name) + _logger.debug("removed secret '%s' from %s", secret_name, scope) + + async def get_variables(self, org_id: str, repo_name: str, env_name: str | None) -> list[dict[str, Any]]: + """ + Retrieve variables for a repository or an environment within a repository. + + When `env_name` is provided, the variables for that specific environment are retrieved. + If `env_name` is None, the repository-level variables are retrieved. + """ + scope = _scope_str(org_id, repo_name, env_name) + + _logger.debug("retrieving variables for %s", scope) - async def get_variables(self, org_id: str, repo_name: str) -> list[dict[str, Any]]: - _logger.debug("retrieving variables for repo '%s/%s'", org_id, repo_name) + # This maps to githubs "list variables" endpoint and not the "get a variable" endpoint, + # therefore no variable name is provided. + endpoint = _repo_actions_or_environment_endpoint(org_id, repo_name, env_name) + "/variables" try: - status, body = await self.requester.request_raw("GET", f"/repos/{org_id}/{repo_name}/actions/variables") + status, body = await self.requester.request_raw("GET", endpoint) if status == 200: return json.loads(body)["variables"] else: return [] except GitHubException as ex: - raise RuntimeError(f"failed retrieving variables for repo '{org_id}/{repo_name}':\n{ex}") from ex + raise RuntimeError(f"failed retrieving variables for {scope}:\n{ex}") from ex - async def update_variable(self, org_id: str, repo_name: str, variable_name: str, variable: dict[str, Any]) -> None: - _logger.debug("updating repo variable '%s' for repo '%s/%s'", variable_name, org_id, repo_name) + async def update_variable( + self, org_id: str, repo_name: str, env_name: str | None, variable_name: str, variable: dict[str, Any] + ) -> None: + scope = _scope_str(org_id, repo_name, env_name) + + _logger.debug("updating variable '%s' in %s", variable_name, scope) if "name" in variable: variable.pop("name") + endpoint = _repo_actions_or_environment_endpoint(org_id, repo_name, env_name) + f"/variables/{variable_name}" + status, body = await self.requester.request_raw( "PATCH", - f"/repos/{org_id}/{repo_name}/actions/variables/{variable_name}", + endpoint, json.dumps(variable), ) if status != 204: - raise RuntimeError(f"failed to update repo variable '{variable_name}': {body}") + raise RuntimeError(f"failed to update {scope} variable '{variable_name}': {body}") - _logger.debug("updated repo variable '%s'", variable_name) + _logger.debug("updated variable '%s' in %s", variable_name, scope) - async def add_variable(self, org_id: str, repo_name: str, data: dict[str, str]) -> None: + async def add_variable(self, org_id: str, repo_name: str, env_name: str | None, data: dict[str, str]) -> None: variable_name = data.get("name") - _logger.debug("adding repo variable '%s' for repo '%s/%s'", variable_name, org_id, repo_name) + scope = _scope_str(org_id, repo_name, env_name) + _logger.debug("adding variable '%s' in %s", variable_name, scope) + + endpoint = _repo_actions_or_environment_endpoint(org_id, repo_name, env_name) + "/variables" status, body = await self.requester.request_raw( "POST", - f"/repos/{org_id}/{repo_name}/actions/variables", + endpoint, json.dumps(data), ) if status != 201: - raise RuntimeError(f"failed to add repo variable '{variable_name}': {body}") + raise RuntimeError(f"failed to add {scope} variable '{variable_name}': {body}") - _logger.debug("added repo variable '%s'", variable_name) + _logger.debug("added variable '%s' in %s", variable_name, scope) - async def delete_variable(self, org_id: str, repo_name: str, variable_name: str) -> None: - _logger.debug("deleting repo variable '%s' for repo '%s/%s'", variable_name, org_id, repo_name) + async def delete_variable(self, org_id: str, repo_name: str, env_name: str | None, variable_name: str) -> None: + scope = _scope_str(org_id, repo_name, env_name) + _logger.debug("deleting variable '%s' in %s", variable_name, scope) - status, _ = await self.requester.request_raw( - "DELETE", f"/repos/{org_id}/{repo_name}/actions/variables/{variable_name}" - ) + endpoint = _repo_actions_or_environment_endpoint(org_id, repo_name, env_name) + f"/variables/{variable_name}" + + status, _ = await self.requester.request_raw("DELETE", endpoint) if status != 204: - raise RuntimeError(f"failed to delete repo variable '{variable_name}'") + raise RuntimeError(f"failed to delete {scope} variable '{variable_name}'") - _logger.debug("removed repo variable '%s'", variable_name) + _logger.debug("removed variable '%s' in %s", variable_name, scope) async def get_workflow_settings(self, org_id: str, repo_name: str) -> dict[str, Any]: _logger.debug("retrieving workflow settings for repo '%s/%s'", org_id, repo_name) @@ -1034,16 +1079,21 @@ async def _update_default_workflow_permissions(self, org_id: str, repo_name: str _logger.debug("updated default workflow permissions for repo '%s/%s'", org_id, repo_name) - async def get_public_key(self, org_id: str, repo_name: str) -> tuple[str, str]: - _logger.debug("retrieving repo public key for repo '%s/%s'", org_id, repo_name) + async def _get_public_key(self, org_id: str, repo_name: str, env_name: str | None) -> tuple[str, str]: + scope = _scope_str(org_id, repo_name, env_name) + + _logger.debug("retrieving public key for %s", scope) + + if env_name: + endpoint = f"/repos/{org_id}/{repo_name}/environments/{env_name}/secrets/public-key" + else: + endpoint = f"/repos/{org_id}/{repo_name}/actions/secrets/public-key" try: - response = await self.requester.request_json( - "GET", f"/repos/{org_id}/{repo_name}/actions/secrets/public-key" - ) + response = await self.requester.request_json("GET", endpoint) return response["key_id"], response["key"] except GitHubException as ex: - raise RuntimeError(f"failed retrieving repo public key:\n{ex}") from ex + raise RuntimeError(f"failed retrieving public key for {scope}:\n{ex}") from ex async def dispatch_workflow(self, org_id: str, repo_name: str, workflow_name: str) -> bool: _logger.debug("dispatching workflow for repo '%s/%s'", org_id, repo_name) diff --git a/otterdog/providers/github/rest/requester.py b/otterdog/providers/github/rest/requester.py index e90f21ed..e5ec0aab 100644 --- a/otterdog/providers/github/rest/requester.py +++ b/otterdog/providers/github/rest/requester.py @@ -28,6 +28,13 @@ _logger = get_logger(__name__) +def create_http_client(session: ClientSession) -> RetryClient: + return RetryClient( + retry_options=ExponentialRetry(3, exceptions={Exception}), + client_session=session, + ) + + class Requester: def __init__( self, @@ -35,6 +42,7 @@ def __init__( cache_strategy: CacheStrategy, base_url: str, api_version: str, + http_client: RetryClient | None = None, ): self._auth = auth_strategy.get_auth() if auth_strategy is not None else None @@ -60,10 +68,7 @@ def __init__( ), ) - self._client = RetryClient( - retry_options=ExponentialRetry(3, exceptions={Exception}), - client_session=self._session, - ) + self._client = http_client or create_http_client(self._session) @property def statistics(self) -> RequestStatistics: diff --git a/pyproject.toml b/pyproject.toml index 6d624015..02ec7ef4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,7 +153,7 @@ build-backend = "poetry_dynamic_versioning.backend" minversion = "7.2" testpaths = ["tests"] pythonpath = [".", "otterdog"] -addopts = ["--cov=otterdog", "--cov-report", "term", "--cov-report", "html"] +# addopts = ["--cov=otterdog", "--cov-report", "term", "--cov-report", "html"] log_cli = true log_cli_level = "INFO" diff --git a/tests/conftest.py b/tests/conftest.py index 88c04af4..57ec842c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,3 +72,7 @@ def mock_github_provider(): mock_provider.setup_org_advisories({"published": [advisory1, advisory2]}) """ return MockGitHubProvider + + +# Load `github` fixture +pytest_plugins = ["tests.providers.github.github_provider_mock"] diff --git a/tests/models/resources/otterdog-environment.json b/tests/models/resources/otterdog-environment.json index 0a45b052..2bef4622 100644 --- a/tests/models/resources/otterdog-environment.json +++ b/tests/models/resources/otterdog-environment.json @@ -9,5 +9,25 @@ "branch_policies": [ "main", "develop/*" + ], + "variables": [ + { + "name": "TEST_VAR", + "value": "test_value" + }, + { + "name": "EXTRA_VAR", + "value": "extra_value" + } + ], + "secrets": [ + { + "name": "TEST_SECRET", + "value": "pass:path/to/secret" + }, + { + "name": "EXTRA_SECRET", + "value": "bitwarden:118276ad-158c-4720-b68d-af8c00fe3481@secret" + } ] } diff --git a/tests/models/test_environment.py b/tests/models/test_environment.py index e027dcec..444133db 100644 --- a/tests/models/test_environment.py +++ b/tests/models/test_environment.py @@ -12,7 +12,9 @@ from otterdog.jsonnet import JsonnetConfig from otterdog.models import ModelObject from otterdog.models.environment import Environment -from otterdog.utils import UNSET, query_json +from otterdog.models.environment_secret import EnvironmentSecret +from otterdog.models.environment_variable import EnvironmentVariable +from otterdog.utils import UNSET, Change, query_json from . import ModelTest @@ -44,6 +46,41 @@ def test_load_from_model(self): assert env.deployment_branch_policy == "selected" assert env.branch_policies == ["main", "develop/*"] + # Variables and secrets are properly loaded with expected values + assert any(v.name == "TEST_VAR" and v.value == "test_value" for v in env.variables) + assert any(s.name == "TEST_SECRET" and s.value == "pass:path/to/secret" for s in env.secrets) + # ensure the TEST_SECRET is not redacted + test_secret = next((s for s in env.secrets if s.name == "TEST_SECRET"), None) + assert test_secret is not None and not test_secret.has_dummy_secret() + + def test_secret_redaction_behavior(self): + """Test that secrets with redacted values are properly handled""" + # Secret with redacted value should be detected as dummy + env_data = self.model_data.copy() + env_data["secrets"] = [{"name": "REDACTED_SECRET", "value": "********"}] + env = Environment.from_model_data(env_data) + + assert len(env.secrets) == 1 + assert env.secrets[0].has_dummy_secret() + + def test_add_retrieve_environment_variables(self): + """Test that variables and secrets can be added/retrieved from environment""" + # Start from JSON, then extend JSON to add a variable and reload + env = Environment.from_model_data(self.model_data) + + # Test getter methods work + assert env.get_variable("TEST_VAR") is not None + assert env.get_variable("TEST_VAR").value == "test_value" + assert env.get_secret("TEST_SECRET") is not None + assert env.get_secret("TEST_SECRET").value == "pass:path/to/secret" + + # Add a variable via JSON to keep tests black-box + extended = self.model_data.copy() + extended_vars = [*list(extended.get("variables", [])), {"name": "NEW_VAR", "value": "new"}] + extended["variables"] = extended_vars + env2 = Environment.from_model_data(extended) + assert env2.get_variable("NEW_VAR") is not None + def test_load_from_provider(self): env = Environment.from_provider_data(self.org_id, self.provider_data) @@ -68,3 +105,158 @@ async def test_to_provider(self): assert query_json("deployment_branch_policy.protected_branches", provider_data) is False assert query_json("deployment_branch_policy.custom_branch_policies", provider_data) is True + + async def test_to_provider_with_variables_and_secrets(self): + """Test converting environment with variables and secrets to provider data""" + env = Environment.from_model_data(self.model_data) + + # Variables and secrets are nested models, should not appear in top-level provider_data + provider_data = await env.to_provider_data(self.org_id, self.provider) + + # They should be handled separately via get_model_objects + assert "variables" not in provider_data + assert "secrets" not in provider_data + + def test_patch_from_json(self): + """Test get_patch_to - comparing two Environment instances""" + current = Environment.from_model_data(self.model_data) + default = Environment.from_model_data(self.model_data) + + default.wait_timer = 30 + + patch = current.get_patch_to(default) + + assert len(patch) >= 1 + assert patch["wait_timer"] == current.wait_timer + + def test_difference_from_json(self): + """Test get_difference_from - finding differences between two instances""" + current = Environment.from_model_data(self.model_data) + other = Environment.from_model_data(self.model_data) + + other.wait_timer = 30 + + diff = current.get_difference_from(other) + + assert len(diff) >= 1 + assert diff["wait_timer"] == Change(other.wait_timer, current.wait_timer) + + def test_environment_with_multiple_variables(self): + """Test environment with multiple variables""" + env_data = self.model_data.copy() + env_data["variables"] = [ + {"name": "VAR_1", "value": "value1"}, + {"name": "VAR_2", "value": "value2"}, + {"name": "VAR_3", "value": "value3"}, + ] + + env = Environment.from_model_data(env_data) + + assert len(env.variables) == 3 + assert env.get_variable("VAR_1").value == "value1" + assert env.get_variable("VAR_2").value == "value2" + assert env.get_variable("VAR_3").value == "value3" + + def test_environment_with_multiple_secrets(self): + """Test environment with multiple secrets""" + env_data = self.model_data.copy() + env_data["secrets"] = [ + {"name": "SECRET_1", "value": "pass:path/to/secret1"}, + {"name": "SECRET_2", "value": "bitwarden:id1@field"}, + {"name": "SECRET_3", "value": "pass:path/to/secret3"}, + ] + + env = Environment.from_model_data(env_data) + + assert len(env.secrets) == 3 + assert env.get_secret("SECRET_1").value == "pass:path/to/secret1" + assert env.get_secret("SECRET_2").value == "bitwarden:id1@field" + assert env.get_secret("SECRET_3").value == "pass:path/to/secret3" + + def test_github_environment_with_no_variables_or_secrets(self): + """Test loading environment from GitHub that has no variables or secrets""" + provider_data = self.load_json_resource("github-environment.json") + + env = Environment.from_provider_data(self.org_id, provider_data) + + assert env.name == "linux" + assert env.variables == [] + assert env.secrets == [] + + def test_environment_get_model_objects(self): + """Test get_model_objects includes variables and secrets for processing""" + env = Environment.from_model_data(self.model_data) + + model_objects = list(env.get_model_objects()) + + # Should include both variable and secret + assert len(model_objects) >= 2 + # Variables and secrets should be in the results + object_types = [type(obj[0]).__name__ for obj in model_objects] + assert "EnvironmentSecret" in object_types + assert "EnvironmentVariable" in object_types + + # Note: nested variable/secret diffs are handled via nested model processing + # (see get_model_objects test) rather than environment-level diff keys. + + def test_set_variables_replaces_list(self): + """Test that set_variables replaces the entire list""" + env = Environment.from_model_data(self.model_data) + + original_count = len(env.variables) + assert original_count >= 1 + + new_vars = [ + EnvironmentVariable.from_model_data({"name": "NEW_VAR_1", "value": "val1"}), + EnvironmentVariable.from_model_data({"name": "NEW_VAR_2", "value": "val2"}), + ] + + env.set_variables(new_vars) + + assert len(env.variables) == 2 + assert env.get_variable("NEW_VAR_1") is not None + assert env.get_variable("TEST_VAR") is None + + def test_set_secrets_replaces_list(self): + """Test that set_secrets replaces the entire list""" + env = Environment.from_model_data(self.model_data) + + original_count = len(env.secrets) + assert original_count >= 1 + + new_secrets = [ + EnvironmentSecret.from_model_data({"name": "NEW_SEC_1", "value": "pass:p1"}), + EnvironmentSecret.from_model_data({"name": "NEW_SEC_2", "value": "pass:p2"}), + ] + + env.set_secrets(new_secrets) + + assert len(env.secrets) == 2 + assert env.get_secret("NEW_SEC_1") is not None + assert env.get_secret("TEST_SECRET") is None + + def test_environment_variables_not_found(self): + """Test retrieving non-existent variable returns None""" + env = Environment.from_model_data(self.model_data) + + assert env.get_variable("NONEXISTENT") is None + assert env.get_secret("NONEXISTENT") is None + + async def test_from_to_github_with_variables_and_secrets(self): + """Test environment roundtrip includes variables and secrets""" + # Create environment with variables and secrets + env_data = self.model_data.copy() + env_data["variables"] = [{"name": "VAR", "value": "val"}] + env_data["secrets"] = [{"name": "SEC", "value": "pass:sec"}] + + env = Environment.from_model_data(env_data) + + # Convert to GitHub format + provider_data = await env.to_provider_data(self.org_id, self.provider) + + # Variables and secrets are handled separately + assert "variables" not in provider_data + assert "secrets" not in provider_data + + # But the environment itself should be convertible + assert provider_data["wait_timer"] == 15 diff --git a/tests/providers/github/github_provider_mock.py b/tests/providers/github/github_provider_mock.py new file mode 100644 index 00000000..06c6f432 --- /dev/null +++ b/tests/providers/github/github_provider_mock.py @@ -0,0 +1,274 @@ +""" +Allows tests to define expected HTTP interactions and verify that the GitHubProvider +uses the GitHub API as intended. + +This mock allows specifying expected HTTP requests together with their responses, +and verifies that all expected requests were actually made. It is intentionally +strict and is designed to validate correct API usage. + +Most of the code is dedicated to detailed and actionable error reporting when an +unexpected request is made, as a simple "unexpected call" error is usually not +sufficient to understand what went wrong. + +Alternatives considered: aioresponses and pytest-aiohttp. While both are useful +libraries, they do not match the required contract-testing use case closely enough. +Adapting them to provide the same level of request validation and diagnostics would +require even more code and add additional complexity. +""" + +import json as jsonlib +from collections.abc import Mapping +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +import pytest +import pytest_asyncio + +import otterdog.providers.github.rest as github_rest +from otterdog.credentials import Credentials +from otterdog.providers.github import GitHubProvider + +if TYPE_CHECKING: + from aiohttp_retry import RetryClient + + +def _pretty_json(obj: Mapping[str, object] | None) -> str | None: + """Stable, pretty JSON encoding for comparisons and diagnostics.""" + return jsonlib.dumps(obj, sort_keys=True, indent=2, ensure_ascii=False) if obj is not None else None + + +class FakeHttpResponse: + """Fake aiohttp response object that the Requester expects.""" + + def __init__(self, status: int, text: str, headers: dict | None = None): + if not headers: + headers = {} + if "x-ratelimit-remaining" not in headers: + headers["x-ratelimit-remaining"] = "5000" + + self.status = status + self.headers = headers + self.links = None + self.from_cache = False + + self._text = text + + async def text(self): + return self._text + + +@dataclass(frozen=True) +class HttpRequest: + """Used for expected and actual HTTP requests.""" + + method: str + url: str + # Encoded as str for easier comparison and hashing + params_str: str | None + json_str: str | None + + +class HttpClientMock: + """ + Mocks aiohttp_retry.RetryClient, which is used internally by the GitHubProvider. + The method of interest is 'request', which is an async context manager. + """ + + def __init__(self) -> None: + self.expected: dict[HttpRequest, FakeHttpResponse] = {} + self.served_requests: list[HttpRequest] = [] + + def _format_unexpected_request_details(self, actual_request: HttpRequest, debug_info) -> str: + for expected_request in self.expected: + if actual_request.method == expected_request.method and actual_request.url == expected_request.url: + msg = ( + f"Found matching method and URL for {actual_request.method} {actual_request.url}, " + f"but request details did not match exactly.\n" + ) + + if expected_request.params_str != actual_request.params_str: + msg += "\nExpected params:\n" + msg += f"{expected_request.params_str or '∅'}\n" + msg += "Actual params:\n" + msg += f"{actual_request.params_str or '∅'}\n" + + if expected_request.json_str != actual_request.json_str: + msg += "\nExpected JSON:\n" + msg += f"{expected_request.json_str or '∅'}\n" + msg += "Actual JSON:\n" + msg += f"{actual_request.json_str or '∅'}\n" + + if debug_info: + msg += "\nOther request details (actual):\n" + for key, value in debug_info.items(): + msg += f" {key}: {value}\n" + + return msg + + msg = f"No matching response found for {actual_request.method} {actual_request.url}.\n" + if self.expected: + msg += "\nAvailable expected requests:\n" + msg += "\n".join(f"{r.method} {r.url}" for r in self.expected) + if self.served_requests: + msg += "\nServed requests so far:\n" + msg += "\n".join(f"{r.method} {r.url}" for r in self.served_requests) + + return msg + + def _retrieve_matching_response(self, actual_request: HttpRequest, debug_info) -> FakeHttpResponse: + if actual_request in self.expected: + return self.expected.pop(actual_request) + else: + msg = self._format_unexpected_request_details(actual_request, debug_info) + pytest.fail(msg, pytrace=False) + + def verify_all_called(self) -> None: + if warnings := [f"Expected request not made: {exp.method} {exp.url}" for exp in self.expected]: + pytest.fail("\n".join(warnings), pytrace=False) + + @staticmethod + def _normalize_json_and_data( + json: dict[str, str | int | bool] | None, + data: str | None, + ) -> str | None: + # Mirror aiohttp semantics: json and data must not both be set. + if json is not None and data is not None: + raise ValueError("Otterdog has specified both json and data in the same request") + + # If data is present, interpret it as JSON (Otterdog uses JSON APIs for GitHub). + if data is not None: + if data == "": + raise ValueError("Otterdog has specified an empty string as data in the request body") + + try: + json = jsonlib.loads(data) + except Exception: + raise ValueError(f"Otterdog has specified non-JSON data in the request body: {data!r}") from None + + # Enforce invariant: empty JSON bodies are not allowed + if json == {}: + raise ValueError("Otterdog has specified an empty JSON object as the request body") + + # Store as stable pretty JSON string + return _pretty_json(json) + + @asynccontextmanager + async def request( + self, + method: str, + url: str, + json: dict[str, str | int | bool] | None = None, + data: str | None = None, + params: dict[str, str | int | bool] | None = None, + **kwargs, + ): + """ + This is called by GitHubProvider (through one of its clients) to make requests. + It's an async context manager that yields a FakeHttpResponse. + """ + + actual_request = HttpRequest( + method=method.upper(), + url=urlparse(url).path, + params_str=_pretty_json(params), + json_str=HttpClientMock._normalize_json_and_data(json, data), + ) + + self.served_requests.append(actual_request) + yield self._retrieve_matching_response(actual_request, kwargs) + + def expect( + self, + method: str, + url: str, + *, + response_status: int = 200, + response_text: str = "", + request_json: dict[str, object] | None = None, + request_params: dict[str, str | int | bool] | None = None, + response_json: dict[str, object] | None = None, + ) -> None: + expected = HttpRequest( + method=method.upper(), + url=url, + params_str=_pretty_json(request_params), + json_str=_pretty_json(request_json), + ) + + if response_json is not None: + if response_text: + raise ValueError("Cannot specify both response_text and response_json") + response_text = jsonlib.dumps(response_json, ensure_ascii=False) + + self.expected[expected] = FakeHttpResponse(status=response_status, text=response_text) + + +class GitHubProviderTestKit: + """ + Combines HttpClientMock and a preconfigured GitHubProvider for easier use in tests. + Also provides small domain helpers for common GitHub API patterns (e.g. secrets encryption). + """ + + def __init__(self, monkeypatch: pytest.MonkeyPatch): + self._monkeypatch = monkeypatch + + self.client = HttpClientMock() + credentials = Credentials( + "fake-user", "fake-password", "fake-totp-secret", "fake-github-token", "fake-last-totp" + ) + http_client: RetryClient = self.client # type: ignore + self.provider = GitHubProvider(credentials, http_client) + + def expect( + self, + method: str, + url: str, + *, + request_json: dict[str, object] | None = None, + request_params: dict[str, str | int | bool] | None = None, + response_json: dict[str, object] | None = None, + response_status: int = 200, + response_text: str = "", + ) -> None: + self.client.expect( + method=method, + url=url, + request_json=request_json, + request_params=request_params, + response_json=response_json, + response_status=response_status, + response_text=response_text, + ) + + def fake_encryption(self, params: tuple[str, str], ciphertext: str) -> None: + """ + Make encryption deterministic in tests. + + Encrypting the same value with the same public key produces different ciphertexts + due to randomization. To keep tests deterministic, patch github_rest.encrypt_value + to return a constant ciphertext for the given (public_key, plaintext) input. + + Usage: + github_mock.fake_encryption((public_key, plaintext), ciphertext) + """ + + def encrypt_value(pk: str, value: str) -> str: + assert pk == params[0], f"unexpected public key: {pk!r}" + assert value == params[1], f"unexpected secret value: {value!r}" + return ciphertext + + self._monkeypatch.setattr(github_rest, "encrypt_value", encrypt_value) + + +# Last, but not least, this is the fixture that tests will use. +@pytest_asyncio.fixture +async def github(monkeypatch: pytest.MonkeyPatch): + """Fixture that provides a GitHubProviderTestKit instance for testing, verifying no warnings after use.""" + mock = GitHubProviderTestKit(monkeypatch) + try: + yield mock + mock.client.verify_all_called() + finally: + await mock.provider.close() diff --git a/tests/providers/github/live_patch_helpers.py b/tests/providers/github/live_patch_helpers.py new file mode 100644 index 00000000..92a65e90 --- /dev/null +++ b/tests/providers/github/live_patch_helpers.py @@ -0,0 +1,50 @@ +from otterdog.models import LivePatch, LivePatchContext, ModelObject + + +def determine_model_object( + a: ModelObject | None, + b: ModelObject | None, +) -> type[ModelObject]: + """ + Determine the model object type from the given objects. + + At least one of a or b must be not None. + If both are provided, they must be of the same type. + """ + if a and b: + assert type(a) == type(b), "Both objects must be of the same type" # noqa: E721 + return type(a) + elif a: + return type(a) + elif b: + return type(b) + else: + raise ValueError("At least one of a or b must be provided") + + +def generate_live_patch( + old: ModelObject | None, + new: ModelObject | None, + parent_object: ModelObject | None, + context: LivePatchContext, +) -> LivePatch: + """Generate exactly one LivePatch for a given model class. + + Collects the patch by calling model_cls.generate_live_patch and ensures exactly one is produced. + + !old & new => Create + old & new => Update + old & !new => Delete + """ + model_cls = determine_model_object(old, new) + + patches: list[LivePatch] = [] + model_cls.generate_live_patch( + expected_object=new, + current_object=old, + parent_object=parent_object, + context=context, + handler=lambda p: patches.append(p), # pyright: ignore[reportArgumentType] + ) + assert len(patches) == 1, f"Expected exactly one patch, got {len(patches)}" + return patches[0] diff --git a/tests/providers/github/test_variable_and_secret.py b/tests/providers/github/test_variable_and_secret.py new file mode 100644 index 00000000..adf5cc1d --- /dev/null +++ b/tests/providers/github/test_variable_and_secret.py @@ -0,0 +1,424 @@ +""" +Integration tests for repository, environment, and organization secrets and variables. + +Tests start from two model states (`old` → `new`) and treat the full flow from diff (LivePatch) +generation to GitHub REST API calls as the system under test. + +Because the relevant logic is spread across many units and files, small isolated unit tests +tend to provide limited value. These tests focus instead on validating that the system behaves +correctly when all parts interact. + +The goal is stability: as long as the externally observable behavior is unchanged, these tests +should continue to pass even if internal code is refactored or redesigned. Assertions are +therefore limited to the rather stable model objects and the extremely stable GitHub HTTP API. + +The tests do not validate internal steps or intermediate state. This makes failures +harder to localize, but keeps the suite robust and focused on user-visible behavior. +""" + +import pytest + +from otterdog.models import LivePatch, LivePatchContext, ModelObject +from otterdog.models.environment import Environment +from otterdog.models.environment_secret import EnvironmentSecret +from otterdog.models.environment_variable import EnvironmentVariable +from otterdog.models.organization_secret import OrganizationSecret +from otterdog.models.organization_settings import OrganizationSettings +from otterdog.models.organization_variable import OrganizationVariable +from otterdog.models.repo_secret import RepositorySecret +from otterdog.models.repo_variable import RepositoryVariable +from otterdog.models.repository import Repository +from otterdog.providers.github import GitHubProvider +from tests.providers.github.github_provider_mock import GitHubProviderTestKit +from tests.providers.github.live_patch_helpers import determine_model_object, generate_live_patch + +# Constants +ORG_ID = "test-org" +REPO_NAME = "test-repo" +ENV_NAME = "production" + +# Shared crypto constants +GITHUB_SERVER_PUBLIC_KEY = "/Iag6O/YqKnJ8a1TuxcW4bMsIYs2LJ5RrOPVt9M0yUU=" +KEY_ID = "test_key_id" +PLAINTEXT_SECRET = "my_secret_value" +CIPHERTEXT = "FAKE_CIPHERTEXT" + + +def build_test_context(org_id: str, repo_name: str, env_name: str): + """Create LivePatchContext, Repository, and Environment for tests. + + Mirrors the minimal setup used in provider tests. + """ + org_settings = OrganizationSettings.from_model_data( + { + "name": org_id, + "plan": "free", + "two_factor_requirement": True, + } + ) + + context = LivePatchContext( + org_id=org_id, + repo_filter="", + update_webhooks=False, + update_secrets=True, + update_filter="*", + current_org_settings=org_settings, + expected_org_settings=org_settings, + ) + + repository = Repository.from_model_data( + { + "id": 123456, + "node_id": "R_12345", + "name": repo_name, + "description": "Test repository", + "private": False, + "has_discussions": False, + "has_issues": True, + "has_projects": True, + "has_wiki": True, + "is_template": False, + "topics": [], + "default_branch": "main", + "allow_rebase_merge": True, + "allow_merge_commit": True, + "allow_squash_merge": True, + "allow_auto_merge": False, + "delete_branch_on_merge": False, + "allow_update_branch": False, + "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", + "squash_merge_commit_message": "COMMIT_MESSAGES", + "merge_commit_title": "MERGE_MESSAGE", + "merge_commit_message": "PR_TITLE", + "archived": False, + "allow_forking": True, + "web_commit_signoff_required": False, + } + ) + + environment = Environment.from_model_data( + { + "name": env_name, + "wait_timer": 0, + "prevent_self_review": False, + "reviewers": [], + "deployment_branch_policy": None, + } + ) + environment.parent_repository = repository + + return context, repository, environment + + +async def generate_patch_and_run_it( + github_provider: GitHubProvider, + *, + new: ModelObject | None, + old: ModelObject | None, +): + """Generate and apply a LivePatch in one call.""" + + def _generate_patch(new: ModelObject | None, old: ModelObject | None): + """ + Creates the local default test_context, and uses that to generate a LivePatch for the given model objects. + The trick is reconstructing the parent object from the test context, since LivePatch generation needs it. + (The model objects themselves do not store their parents directly.) + """ + + # Objects do not store their parents directly, so we need to reconstruct them here. + def parent_from_test_context() -> ModelObject | None: + model_cls = determine_model_object(old, new) + if model_cls in {RepositorySecret, RepositoryVariable}: + return repository + if model_cls in {EnvironmentSecret, EnvironmentVariable}: + return environment + if model_cls in {OrganizationSecret, OrganizationVariable}: + return None # Organization-level, no parent object + raise ValueError(f"Unknown model class for parent: {model_cls}") + + context, repository, environment = build_test_context(ORG_ID, REPO_NAME, ENV_NAME) + + return generate_live_patch( + old=old, + new=new, + parent_object=parent_from_test_context(), + context=context, + ) + + async def _apply_patch(github_provider: GitHubProvider, patch: LivePatch): + """Generate and apply a LivePatch in one call.""" + return await patch.apply(ORG_ID, github_provider) + + patch = _generate_patch(new, old) + await _apply_patch(github_provider, patch) + + +# --------------------------------------------------------------------------- +# Repository secrets +# --------------------------------------------------------------------------- + + +# Test: add or update repository secret (parametrized) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "old", + [ + pytest.param(None, id="create"), + pytest.param(RepositorySecret(name="TEST_SECRET", value="old_value"), id="update"), + ], +) +async def test_add_or_update_repository_secret(github: GitHubProviderTestKit, old: RepositorySecret | None): + github.fake_encryption((GITHUB_SERVER_PUBLIC_KEY, PLAINTEXT_SECRET), CIPHERTEXT) + + github.expect( + "GET", + f"/repos/{ORG_ID}/{REPO_NAME}/actions/secrets/public-key", + response_json={"key_id": KEY_ID, "key": GITHUB_SERVER_PUBLIC_KEY}, + ) + + github.expect( + "PUT", + f"/repos/{ORG_ID}/{REPO_NAME}/actions/secrets/TEST_SECRET", + request_json={"key_id": KEY_ID, "encrypted_value": CIPHERTEXT}, + response_status=204 if old else 201, + ) + + await generate_patch_and_run_it( + github.provider, + old=old, + new=RepositorySecret(name="TEST_SECRET", value=PLAINTEXT_SECRET), + ) + + +@pytest.mark.asyncio +async def test_delete_repository_secret(github: GitHubProviderTestKit): + github.expect( + "DELETE", + f"/repos/{ORG_ID}/{REPO_NAME}/actions/secrets/TEST_SECRET", + response_status=204, + ) + + await generate_patch_and_run_it( + github.provider, + old=RepositorySecret(name="TEST_SECRET", value="secret_value"), + new=None, + ) + + +# --------------------------------------------------------------------------- +# Repository variables +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_repository_variable(github: GitHubProviderTestKit): + github.expect( + "POST", + f"/repos/{ORG_ID}/{REPO_NAME}/actions/variables", + request_json={"name": "TEST_VAR", "value": "variable_value"}, + response_status=201, + ) + + await generate_patch_and_run_it( + github.provider, + old=None, + new=RepositoryVariable(name="TEST_VAR", value="variable_value"), + ) + + +@pytest.mark.asyncio +async def test_update_repository_variable(github: GitHubProviderTestKit): + github.expect( + "PATCH", + f"/repos/{ORG_ID}/{REPO_NAME}/actions/variables/TEST_VAR", + request_json={"value": "new_value"}, + response_status=204, + ) + + await generate_patch_and_run_it( + github.provider, + old=RepositoryVariable(name="TEST_VAR", value="old_value"), + new=RepositoryVariable(name="TEST_VAR", value="new_value"), + ) + + +@pytest.mark.asyncio +async def test_delete_repository_variable(github: GitHubProviderTestKit): + github.expect( + "DELETE", + f"/repos/{ORG_ID}/{REPO_NAME}/actions/variables/TEST_VAR", + response_status=204, + ) + + await generate_patch_and_run_it( + github.provider, + old=RepositoryVariable(name="TEST_VAR", value="variable_value"), + new=None, + ) + + +# --------------------------------------------------------------------------- +# Environment secrets +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "old", + [ + pytest.param(None, id="create"), + pytest.param(EnvironmentSecret(name="ENV_SECRET", value="old_value"), id="update"), + ], +) +async def test_add_or_update_environment_secret(github: GitHubProviderTestKit, old: EnvironmentSecret | None): + github.fake_encryption((GITHUB_SERVER_PUBLIC_KEY, PLAINTEXT_SECRET), CIPHERTEXT) + + github.expect( + "GET", + f"/repos/{ORG_ID}/{REPO_NAME}/environments/{ENV_NAME}/secrets/public-key", + response_json={"key_id": KEY_ID, "key": GITHUB_SERVER_PUBLIC_KEY}, + ) + + github.expect( + "PUT", + f"/repos/{ORG_ID}/{REPO_NAME}/environments/{ENV_NAME}/secrets/ENV_SECRET", + request_json={"key_id": KEY_ID, "encrypted_value": CIPHERTEXT}, + response_status=204 if old else 201, + ) + + await generate_patch_and_run_it( + github.provider, + old=old, + new=EnvironmentSecret(name="ENV_SECRET", value=PLAINTEXT_SECRET), + ) + + +@pytest.mark.asyncio +async def test_delete_environment_secret(github: GitHubProviderTestKit): + github.expect( + "DELETE", + f"/repos/{ORG_ID}/{REPO_NAME}/environments/{ENV_NAME}/secrets/ENV_SECRET", + response_status=204, + ) + + await generate_patch_and_run_it( + github.provider, + old=EnvironmentSecret(name="ENV_SECRET", value="secret_value"), + new=None, + ) + + +# --------------------------------------------------------------------------- +# Environment variables +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_environment_variable(github: GitHubProviderTestKit): + github.expect( + "POST", + f"/repos/{ORG_ID}/{REPO_NAME}/environments/{ENV_NAME}/variables", + request_json={"name": "ENV_VAR", "value": "env_var_value"}, + response_status=201, + ) + + await generate_patch_and_run_it( + github.provider, + new=EnvironmentVariable(name="ENV_VAR", value="env_var_value"), + old=None, + ) + + +@pytest.mark.asyncio +async def test_update_environment_variable(github: GitHubProviderTestKit): + github.expect( + "PATCH", + f"/repos/{ORG_ID}/{REPO_NAME}/environments/{ENV_NAME}/variables/ENV_VAR", + request_json={"value": "new_value"}, + response_status=204, + ) + + await generate_patch_and_run_it( + github.provider, + old=EnvironmentVariable(name="ENV_VAR", value="old_value"), + new=EnvironmentVariable(name="ENV_VAR", value="new_value"), + ) + + +@pytest.mark.asyncio +async def test_delete_environment_variable(github: GitHubProviderTestKit): + github.expect( + "DELETE", + f"/repos/{ORG_ID}/{REPO_NAME}/environments/{ENV_NAME}/variables/ENV_VAR", + response_status=204, + ) + + await generate_patch_and_run_it( + github.provider, + old=EnvironmentVariable(name="ENV_VAR", value="variable_value"), + new=None, + ) + + +# --------------------------------------------------------------------------- +# Organization secrets / variables +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "old", + [ + pytest.param(None, id="create"), + pytest.param( + OrganizationSecret(name="ORG_SECRET", value="old_value", visibility="private", selected_repositories=[]), + id="update", + ), + ], +) +async def test_add_or_update_organization_secret(github: GitHubProviderTestKit, old: OrganizationSecret | None): + github.fake_encryption((GITHUB_SERVER_PUBLIC_KEY, PLAINTEXT_SECRET), CIPHERTEXT) + + github.expect( + "GET", + f"/orgs/{ORG_ID}/actions/secrets/public-key", + response_json={"key_id": KEY_ID, "key": GITHUB_SERVER_PUBLIC_KEY}, + ) + + github.expect( + "PUT", + f"/orgs/{ORG_ID}/actions/secrets/ORG_SECRET", + request_json={ + "key_id": KEY_ID, + "encrypted_value": CIPHERTEXT, + "selected_repository_ids": [], + "visibility": "private", + }, + response_status=204 if old else 201, + ) + + await generate_patch_and_run_it( + github.provider, + old=old, + new=OrganizationSecret( + name="ORG_SECRET", value=PLAINTEXT_SECRET, visibility="private", selected_repositories=[] + ), + ) + + +@pytest.mark.asyncio +async def test_delete_organization_variable(github: GitHubProviderTestKit): + github.expect( + "DELETE", + f"/orgs/{ORG_ID}/actions/variables/ORG_VAR", + response_status=204, + ) + + await generate_patch_and_run_it( + github.provider, + old=OrganizationVariable( + name="ORG_VAR", value="variable_value", visibility="private", selected_repositories=[] + ), + new=None, + )