diff --git a/Makefile b/Makefile index 0514f7fc..468bd7cb 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ deep-clean: clean test: cd packages/get_repo_status && COVERAGE_FILE=coverage/.coverage poetry run python -m pytest + COVERAGE_FILE=packages/setup_github_repo/coverage/.coverage poetry run python -m pytest -c packages/setup_github_repo/pytest.ini packages/setup_github_repo lint: lint-black lint-flake8 diff --git a/packages/setup_github_repo/.coveragerc b/packages/setup_github_repo/.coveragerc new file mode 100644 index 00000000..1e795ad5 --- /dev/null +++ b/packages/setup_github_repo/.coveragerc @@ -0,0 +1,3 @@ +[run] +data_file = packages/setup_github_repo/coverage/.coverage +omit = */__init__.py diff --git a/packages/setup_github_repo/__init__.py b/packages/setup_github_repo/__init__.py new file mode 100644 index 00000000..3a5d391f --- /dev/null +++ b/packages/setup_github_repo/__init__.py @@ -0,0 +1 @@ +"""Utilities for setting up GitHub repositories for EPS projects.""" diff --git a/packages/setup_github_repo/__main__.py b/packages/setup_github_repo/__main__.py new file mode 100644 index 00000000..f55a17e1 --- /dev/null +++ b/packages/setup_github_repo/__main__.py @@ -0,0 +1,6 @@ +"""Module entrypoint for running setup_github_repo with python -m.""" + +from .app.cli import main + +if __name__ == "__main__": + main() diff --git a/packages/setup_github_repo/app/__init__.py b/packages/setup_github_repo/app/__init__.py new file mode 100644 index 00000000..e5c4de58 --- /dev/null +++ b/packages/setup_github_repo/app/__init__.py @@ -0,0 +1 @@ +"""Application modules for setup_github_repo.""" diff --git a/packages/setup_github_repo/app/aws_exports.py b/packages/setup_github_repo/app/aws_exports.py new file mode 100644 index 00000000..21be24a8 --- /dev/null +++ b/packages/setup_github_repo/app/aws_exports.py @@ -0,0 +1,81 @@ +"""AWS CloudFormation export helpers used to resolve role values by environment.""" + +from typing import Any + +import boto3 + +from .models import Roles + + +class AwsExportsService: + """Load CloudFormation exports and map them to the roles used by repo setup.""" + + def get_named_export(self, all_exports: list[dict[str, Any]], export_name: str, required: bool) -> str | None: + export_value = None + + for export in all_exports: + if export["Name"] == export_name: + export_value = export["Value"] + break + + if required and export_value is None: + raise ValueError(f"export {export_name} is required but not found") + return export_value + + def get_all_exports(self, profile_name: str) -> list[dict[str, Any]]: + print(f"Getting exports for profile {profile_name}") + session = boto3.Session(profile_name=profile_name) + cloudformation_client = session.client("cloudformation") + + all_exports: list[dict[str, Any]] = [] + next_token = None + + while True: + if next_token: + response = cloudformation_client.list_exports(NextToken=next_token) + else: + response = cloudformation_client.list_exports() + + all_exports.extend(response.get("Exports", [])) + + next_token = response.get("NextToken") + if not next_token: + break + return all_exports + + def get_role_exports(self, all_exports: list[dict[str, Any]]) -> Roles: + role_exports = [ + { + "variable_name": "cloud_formation_deploy_role", + "export_name": "ci-resources:CloudFormationDeployRole", + "required": True, + }, + { + "variable_name": "cloud_formation_check_version_role", + "export_name": "ci-resources:CloudFormationCheckVersionRole", + "required": True, + }, + { + "variable_name": "cloud_formation_prepare_changeset_role", + "export_name": "ci-resources:CloudFormationPrepareChangesetRole", + "required": True, + }, + { + "variable_name": "release_notes_execute_lambda_role", + "export_name": "ci-resources:ReleaseNotesExecuteLambdaRole", + "required": False, + }, + { + "variable_name": "artillery_runner_role", + "export_name": "ci-resources:ArtilleryRunnerRole", + "required": False, + }, + ] + all_roles: dict[str, str | None] = {} + for role_export in role_exports: + all_roles[role_export["variable_name"]] = self.get_named_export( + all_exports, + export_name=role_export["export_name"], + required=role_export["required"], + ) + return Roles(**all_roles) diff --git a/packages/setup_github_repo/app/cli.py b/packages/setup_github_repo/app/cli.py new file mode 100644 index 00000000..cddc528b --- /dev/null +++ b/packages/setup_github_repo/app/cli.py @@ -0,0 +1,111 @@ +"""CLI entrypoint that validates auth prerequisites and runs repository setup.""" + +import argparse +import subprocess + +from .constants import AWS_PROFILE_BY_ENV +from .runner import SetupGithubRepoRunner + + +def _read_gh_auth_token() -> str | None: + try: + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: + raise RuntimeError("GitHub CLI (gh) is not installed or not available on PATH.") from exc + + if result.returncode != 0: + return None + + token = result.stdout.strip() + if not token: + return None + return token + + +def _get_or_create_gh_auth_token() -> str: + existing_token = _read_gh_auth_token() + if existing_token: + return existing_token + + print("No GitHub token found. Running gh auth login to obtain one...") + subprocess.run(["gh", "auth", "login"], check=True) + + token_after_login = _read_gh_auth_token() + if token_after_login: + return token_after_login + + raise RuntimeError("Unable to retrieve GitHub token after running 'gh auth login'.") + + +def resolve_gh_auth_token(explicit_token: str | None) -> str: + if explicit_token: + return explicit_token + return _get_or_create_gh_auth_token() + + +def _has_valid_aws_credentials_for_profile(profile_name: str) -> bool: + try: + result = subprocess.run( + ["aws", "sts", "get-caller-identity", "--profile", profile_name], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: + raise RuntimeError("AWS CLI (aws) is not installed or not available on PATH.") from exc + + return result.returncode == 0 + + +def _get_invalid_aws_profiles() -> list[str]: + required_profiles = sorted(set(AWS_PROFILE_BY_ENV.values())) + return [ + profile_name for profile_name in required_profiles if not _has_valid_aws_credentials_for_profile(profile_name) + ] + + +def ensure_aws_credentials() -> None: + invalid_profiles = _get_invalid_aws_profiles() + if not invalid_profiles: + return + + invalid_profiles_text = ", ".join(invalid_profiles) + print( + f"AWS credentials missing or expired for profiles: {invalid_profiles_text}. " + "Running make aws-login to refresh credentials..." + ) + try: + subprocess.run(["make", "aws-login"], check=True) + except FileNotFoundError as exc: + raise RuntimeError("make is not installed or not available on PATH.") from exc + + remaining_invalid_profiles = _get_invalid_aws_profiles() + if remaining_invalid_profiles: + remaining_profiles_text = ", ".join(remaining_invalid_profiles) + raise RuntimeError( + "AWS credentials are still missing or expired after running make aws-login for profiles: " + f"{remaining_profiles_text}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--gh_auth_token", + required=False, + help=( + "Please provide a github auth token. If authenticated with github cli this can be " + "retrieved using 'gh auth token'. If omitted, this script will try to retrieve one automatically." + ), + ) + + arguments = parser.parse_args() + ensure_aws_credentials() + github_auth_token = resolve_gh_auth_token(arguments.gh_auth_token) + runner = SetupGithubRepoRunner(gh_auth_token=github_auth_token) + runner.run() diff --git a/packages/setup_github_repo/app/constants.py b/packages/setup_github_repo/app/constants.py new file mode 100644 index 00000000..eafdfc3f --- /dev/null +++ b/packages/setup_github_repo/app/constants.py @@ -0,0 +1,31 @@ +"""Static constants for AWS profiles, app IDs, and target service hostnames.""" + +AWS_PROFILE_BY_ENV: dict[str, str] = { + "dev": "prescription-dev", + "qa": "prescription-qa", + "ref": "prescription-ref", + "int": "prescription-int", + "prod": "prescription-prod-readonly", + "recovery": "prescription-recovery", +} + +AUTOMERGE_APP_ID = "420347" +CREATE_PULL_REQUEST_APP_ID = "3182106" + +TARGET_SPINE_SERVERS: dict[str, str] = { + "dev": "msg.veit07.devspineservices.nhs.uk", + "int": "msg.intspineservices.nhs.uk", + "prod": "prescriptions.spineservices.nhs.uk", + "qa": "msg.intspineservices.nhs.uk", + "ref": "prescriptions.refspineservices.nhs.uk", + "recovery": "msg.veit07.devspineservices.nhs.uk", +} + +TARGET_SERVICE_SEARCH_SERVERS: dict[str, str] = { + "dev": "int.api.service.nhs.uk", + "int": "api.service.nhs.uk", + "prod": "api.service.nhs.uk", + "qa": "int.api.service.nhs.uk", + "ref": "api.service.nhs.uk", + "recovery": "api.service.nhs.uk", +} diff --git a/packages/setup_github_repo/app/github_access.py b/packages/setup_github_repo/app/github_access.py new file mode 100644 index 00000000..d260c3a8 --- /dev/null +++ b/packages/setup_github_repo/app/github_access.py @@ -0,0 +1,26 @@ +"""Repository access permission management for standard EPS GitHub teams.""" + +from .github_base import GithubOperationBase +from .models import RepoConfig + + +class GithubAccessManager(GithubOperationBase): + """Manage repository team access permissions.""" + + def setup_access(self, repo_config: RepoConfig) -> None: + repo_url = repo_config.repoUrl + if not self._confirm_action(f"Setting access in repo {repo_url}. Do you want to continue? (y/N): "): + return + + org = self._github.get_organization("NHSDigital") + repo = self._github.get_repo(repo_url) + team_permissions = [ + (self._github_teams.eps_team, "Write_View_Dependabot_Alerts"), + (self._github_teams.eps_administrator_team, "admin"), + ] + + for team_id, permission in team_permissions: + team = org.get_team(int(team_id)) + print(f"Granting team {team.slug} access to repo {repo_url} with role {permission}") + team.update_team_repository(repo, permission) + self._sleep_for_rate_limit() diff --git a/packages/setup_github_repo/app/github_base.py b/packages/setup_github_repo/app/github_base.py new file mode 100644 index 00000000..31e03932 --- /dev/null +++ b/packages/setup_github_repo/app/github_base.py @@ -0,0 +1,38 @@ +"""Shared utilities for prompting and API pacing in GitHub setup operations.""" + +import time + +from github import Github + +from .models import GithubTeams + + +class GithubOperationBase: + """Shared behavior for interactive prompts and API rate-limit pacing.""" + + def __init__( + self, + github: Github, + github_teams: GithubTeams, + interactive: bool = True, + rate_limit_delay_seconds: float = 1.0, + ): + self._github = github + self._github_teams = github_teams + self._interactive = interactive + self._rate_limit_delay_seconds = rate_limit_delay_seconds + + def _confirm_action(self, prompt: str) -> bool: + if not self._interactive: + return True + + response = input(prompt) + if response.lower() == "y": + print("Continuing...") + return True + + print("Returning.") + return False + + def _sleep_for_rate_limit(self) -> None: + time.sleep(self._rate_limit_delay_seconds) diff --git a/packages/setup_github_repo/app/github_environments.py b/packages/setup_github_repo/app/github_environments.py new file mode 100644 index 00000000..fe1403cb --- /dev/null +++ b/packages/setup_github_repo/app/github_environments.py @@ -0,0 +1,107 @@ +"""GitHub environment creation rules for EPS repositories.""" + +from github.EnvironmentDeploymentBranchPolicy import EnvironmentDeploymentBranchPolicyParams +from github.EnvironmentProtectionRuleReviewer import ReviewerParams +from github.Repository import Repository + +from .github_base import GithubOperationBase +from .models import RepoConfig, RepoEnvironment + + +class GithubEnvironmentManager(GithubOperationBase): + """Manage GitHub environments for EPS repositories.""" + + def setup_environments(self, repo_config: RepoConfig) -> None: + repo_url = repo_config.repoUrl + set_account_resources_environments = repo_config.isAccountResources + is_echo_repo = repo_config.isEchoRepo + if not self._confirm_action(f"Setting environments in repo {repo_url}. Do you want to continue? (y/N): "): + return + + repo = self._github.get_repo(repo_url) + eps_administrator_team_reviewer = ReviewerParams("Team", self._github_teams.eps_administrator_team) + eps_deployments_team_reviewer = ReviewerParams("Team", self._github_teams.eps_deployments_team) + eps_team_reviewer = ReviewerParams("Team", self._github_teams.eps_team) + deployment_branch_policy = EnvironmentDeploymentBranchPolicyParams( + protected_branches=True, + custom_branch_policies=False, + ) + + create_pull_request_environment = RepoEnvironment("create_pull_request", [], deployment_branch_policy) + self._setup_repo_environment(repo, create_pull_request_environment) + + if not repo_config.inWeeklyRelease: + print(f"{repo_url} is not in weekly release, so not creating release environments") + return + + int_deployment_branch_policy = deployment_branch_policy + if repo.full_name == "NHSDigital/electronic-prescription-service-api-regression-tests": + int_deployment_branch_policy = None + + common_environments: list[RepoEnvironment] = [ + RepoEnvironment("dev"), + RepoEnvironment("ref", [eps_administrator_team_reviewer, eps_team_reviewer]), + RepoEnvironment("int", [eps_administrator_team_reviewer, eps_team_reviewer], int_deployment_branch_policy), + ] + + if set_account_resources_environments: + environments = common_environments + [ + RepoEnvironment("recovery", [eps_administrator_team_reviewer, eps_team_reviewer]), + RepoEnvironment("qa", [eps_administrator_team_reviewer, eps_team_reviewer], deployment_branch_policy), + RepoEnvironment( + "prod", + [eps_administrator_team_reviewer, eps_deployments_team_reviewer], + deployment_branch_policy, + ), + ] + for environment in environments: + self._setup_account_resources_environments(repo=repo, environment=environment) + return + + if not is_echo_repo: + environments = common_environments + [ + RepoEnvironment("dev-pr"), + RepoEnvironment("recovery", [eps_administrator_team_reviewer, eps_team_reviewer]), + RepoEnvironment("qa", [eps_administrator_team_reviewer, eps_team_reviewer], deployment_branch_policy), + RepoEnvironment( + "prod", + [eps_administrator_team_reviewer, eps_deployments_team_reviewer], + deployment_branch_policy, + ), + ] + else: + environments = common_environments + [ + RepoEnvironment("veit"), + RepoEnvironment("dep", [eps_administrator_team_reviewer, eps_team_reviewer], deployment_branch_policy), + RepoEnvironment("live", [eps_administrator_team_reviewer], deployment_branch_policy), + ] + + for environment in environments: + self._setup_repo_environment(repo, environment) + + def _setup_account_resources_environments(self, repo: Repository, environment: RepoEnvironment) -> None: + print(f"Setting up account-resources environments in repo {repo.name}") + print(f"Creating {environment.name} environment") + repo.create_environment( + f"{environment.name}", + reviewers=environment.reviewers, + deployment_branch_policy=environment.deployment_branch_policy, + ) + self._sleep_for_rate_limit() + for suffix in ["ci", "account", "lambda"]: + print(f"Creating {environment.name}-{suffix} environment") + repo.create_environment( + f"{environment.name}-{suffix}", + reviewers=environment.reviewers, + deployment_branch_policy=environment.deployment_branch_policy, + ) + self._sleep_for_rate_limit() + + def _setup_repo_environment(self, repo: Repository, environment: RepoEnvironment) -> None: + print(f"Creating {environment.name} environment in repo {repo.name}") + repo.create_environment( + environment.name, + reviewers=environment.reviewers, + deployment_branch_policy=environment.deployment_branch_policy, + ) + self._sleep_for_rate_limit() diff --git a/packages/setup_github_repo/app/github_repo_settings.py b/packages/setup_github_repo/app/github_repo_settings.py new file mode 100644 index 00000000..a315fcd8 --- /dev/null +++ b/packages/setup_github_repo/app/github_repo_settings.py @@ -0,0 +1,80 @@ +"""Repository-level settings management, including pull request merge options.""" + +from typing import Any + +from .github_base import GithubOperationBase +from .models import RepoConfig + + +class GithubRepoSettingsManager(GithubOperationBase): + """Handles repository settings that are not access, environments, or secrets.""" + + def setup_general_settings(self, repo_config: RepoConfig) -> None: + repo_url = repo_config.repoUrl + main_branch = repo_config.mainBranch + if not self._confirm_action(f"Setting general settings in repo {repo_url}. Do you want to continue? (y/N): "): + return + + print(f"Applying general repository settings in {repo_url}") + repo = self._github.get_repo(repo_url) + repo.edit( + allow_merge_commit=False, + allow_squash_merge=True, + allow_rebase_merge=False, + allow_auto_merge=True, + delete_branch_on_merge=True, + squash_merge_commit_title="PR_TITLE", + squash_merge_commit_message="PR_BODY", + ) + + self._set_actions_permissions(repo=repo, repo_url=repo_url) + + print(f"Applying branch protection to {repo_url} branch {main_branch}") + branch = repo.get_branch(main_branch) + checks = self._get_existing_required_checks(branch) + branch.edit_protection( + strict=True, + required_approving_review_count=1, + dismiss_stale_reviews=True, + require_last_push_approval=True, + checks=checks, + ) + + if not branch.get_required_signatures(): + branch.add_required_signatures() + + self._sleep_for_rate_limit() + print(f"General repository settings applied in {repo_url}") + + def _set_actions_permissions(self, repo: Any, repo_url: str) -> None: + workflow_permissions_endpoint = f"/repos/{repo_url}/actions/permissions/workflow" + _headers, workflow_permissions = repo._requester.requestJsonAndCheck("GET", workflow_permissions_endpoint) + default_workflow_permissions = str(workflow_permissions.get("default_workflow_permissions") or "read") + + repo._requester.requestJsonAndCheck( + "PUT", + workflow_permissions_endpoint, + input={ + "default_workflow_permissions": default_workflow_permissions, + "can_approve_pull_request_reviews": True, + }, + ) + + if not getattr(repo, "private", True): + repo._requester.requestJsonAndCheck( + "PUT", + f"/repos/{repo_url}/actions/permissions/fork-pr-contributor-approval", + input={"approval_policy": "all_external_contributors"}, + ) + + def _get_existing_required_checks(self, branch: Any) -> list[str | tuple[str, int]]: + try: + required_status_checks = branch.get_required_status_checks() + except Exception: + return [] + + checks = getattr(required_status_checks, "checks", None) + if checks: + return [(check.context, check.app_id) if check.app_id is not None else check.context for check in checks] + + return list(getattr(required_status_checks, "contexts", [])) diff --git a/packages/setup_github_repo/app/github_secrets.py b/packages/setup_github_repo/app/github_secrets.py new file mode 100644 index 00000000..d0af420e --- /dev/null +++ b/packages/setup_github_repo/app/github_secrets.py @@ -0,0 +1,249 @@ +"""Secret and environment-secret provisioning for EPS repository automation.""" + +import os + +from github.Repository import Repository + +from .constants import AUTOMERGE_APP_ID, CREATE_PULL_REQUEST_APP_ID +from .github_base import GithubOperationBase +from .models import RepoConfig, Roles, Secrets + + +class GithubSecretManager(GithubOperationBase): + """Manage Actions/Dependabot/environment secrets for EPS repositories.""" + + def set_all_secrets(self, repo_config: RepoConfig, secrets: Secrets) -> None: + repo_url = repo_config.repoUrl + if not self._confirm_action(f"Setting secrets in repo {repo_url}. Do you want to continue? (y/N): "): + return + + repo = self._github.get_repo(repo_url) + + self._set_environment_secret( + repo=repo, + environment_name="create_pull_request", + secret_name="AUTOMERGE_PEM", + secret_value=secrets.automerge_pem, + ) + self._set_environment_secret( + repo=repo, + environment_name="create_pull_request", + secret_name="AUTOMERGE_APP_ID", + secret_value=AUTOMERGE_APP_ID, + ) + self._set_secret( + repo=repo, + secret_name="DEPENDABOT_TOKEN", + secret_value=secrets.dependabot_token, + set_dependabot=True, + ) + self._set_environment_secret( + repo=repo, + environment_name="create_pull_request", + secret_name="CREATE_PULL_REQUEST_PEM", + secret_value=secrets.create_pull_request_pem, + ) + self._set_environment_secret( + repo=repo, + environment_name="create_pull_request", + secret_name="CREATE_PULL_REQUEST_APP_ID", + secret_value=CREATE_PULL_REQUEST_APP_ID, + ) + + if not repo_config.inWeeklyRelease: + print(f"Repo {repo_url} is not in weekly release, so not creating additional secrets.") + return + + self._set_secret( + repo=repo, + secret_name="DEV_CLOUD_FORMATION_EXECUTE_LAMBDA_ROLE", + secret_value=secrets.dev_roles.release_notes_execute_lambda_role, + set_dependabot=False, + ) + + if repo_config.isEchoRepo: + print(f"All required secrets set for echo repo {repo_url}.") + return + + self._set_secret( + repo=repo, + secret_name="REGRESSION_TESTS_PEM", + secret_value=secrets.regression_test_pem, + set_dependabot=True, + ) + self._set_secret( + repo=repo, + secret_name="APIM_STATUS_API_KEY", + secret_value=os.environ.get("apim_status_api_key"), + set_dependabot=True, + ) + + self._set_secret( + repo=repo, + secret_name="PROXYGEN_PTL_ROLE", + secret_value=secrets.proxygen_ptl_role, + set_dependabot=True, + ) + self._set_secret( + repo=repo, + secret_name="PROXYGEN_PROD_ROLE", + secret_value=secrets.proxygen_prod_role, + set_dependabot=True, + ) + + self._set_secret( + repo=repo, + secret_name="DEV_ARTILLERY_RUNNER_ROLE", + secret_value=secrets.dev_roles.artillery_runner_role, + set_dependabot=True, + ) + self._set_secret( + repo=repo, + secret_name="REF_ARTILLERY_RUNNER_ROLE", + secret_value=secrets.ref_roles.artillery_runner_role, + set_dependabot=False, + ) + + self._set_role_secrets(repo=repo, roles=secrets.dev_roles, env_name="DEV", set_dependabot=True) + self._set_role_secrets(repo=repo, roles=secrets.int_roles, env_name="INT", set_dependabot=False) + self._set_role_secrets(repo=repo, roles=secrets.prod_roles, env_name="PROD", set_dependabot=False) + self._set_role_secrets(repo=repo, roles=secrets.qa_roles, env_name="QA", set_dependabot=False) + self._set_role_secrets(repo=repo, roles=secrets.ref_roles, env_name="REF", set_dependabot=False) + self._set_role_secrets( + repo=repo, + roles=secrets.recovery_roles, + env_name="RECOVERY", + set_dependabot=False, + ) + + if repo_config.setTargetSpineServers: + self._set_secret( + repo=repo, + secret_name="DEV_TARGET_SPINE_SERVER", + secret_value=secrets.dev_target_spine_server, + set_dependabot=True, + ) + self._set_secret( + repo=repo, + secret_name="REF_TARGET_SPINE_SERVER", + secret_value=secrets.ref_target_spine_server, + set_dependabot=False, + ) + self._set_secret( + repo=repo, + secret_name="QA_TARGET_SPINE_SERVER", + secret_value=secrets.qa_target_spine_server, + set_dependabot=False, + ) + self._set_secret( + repo=repo, + secret_name="INT_TARGET_SPINE_SERVER", + secret_value=secrets.int_target_spine_server, + set_dependabot=False, + ) + self._set_secret( + repo=repo, + secret_name="PROD_TARGET_SPINE_SERVER", + secret_value=secrets.prod_target_spine_server, + set_dependabot=False, + ) + self._set_secret( + repo=repo, + secret_name="RECOVERY_TARGET_SPINE_SERVER", + secret_value=secrets.recovery_target_spine_server, + set_dependabot=False, + ) + + if repo_config.setTargetServiceSearchServers: + self._set_secret( + repo=repo, + secret_name="DEV_TARGET_SERVICE_SEARCH_SERVER", + secret_value=secrets.dev_target_service_search_server, + set_dependabot=True, + ) + self._set_secret( + repo=repo, + secret_name="INT_TARGET_SERVICE_SEARCH_SERVER", + secret_value=secrets.int_target_service_search_server, + set_dependabot=False, + ) + self._set_secret( + repo=repo, + secret_name="REF_TARGET_SERVICE_SEARCH_SERVER", + secret_value=secrets.ref_target_service_search_server, + set_dependabot=False, + ) + self._set_secret( + repo=repo, + secret_name="QA_TARGET_SERVICE_SEARCH_SERVER", + secret_value=secrets.qa_target_service_search_server, + set_dependabot=False, + ) + self._set_secret( + repo=repo, + secret_name="PROD_TARGET_SERVICE_SEARCH_SERVER", + secret_value=secrets.prod_target_service_search_server, + set_dependabot=False, + ) + self._set_secret( + repo=repo, + secret_name="RECOVERY_TARGET_SERVICE_SEARCH_SERVER", + secret_value=secrets.recovery_target_service_search_server, + set_dependabot=False, + ) + + def _set_secret( + self, + repo: Repository, + secret_name: str, + secret_value: str | None, + set_dependabot: bool, + ) -> None: + if secret_value is None: + print(f"Secret value for {secret_name} in repo {repo.full_name} is not set. Not setting") + return + + print(f"Setting value for {secret_name} in repo {repo.full_name}") + repo.create_secret(secret_name=secret_name, unencrypted_value=secret_value, secret_type="actions") + self._sleep_for_rate_limit() + + if set_dependabot: + print(f"Setting value for {secret_name} in repo {repo.full_name} for dependabot") + repo.create_secret(secret_name=secret_name, unencrypted_value=secret_value, secret_type="dependabot") + self._sleep_for_rate_limit() + + def _set_environment_secret( + self, + repo: Repository, + environment_name: str, + secret_name: str, + secret_value: str | None, + ) -> None: + if secret_value is None: + print(f"Secret value for {secret_name} in repo {repo.full_name} is not set. Not setting") + return + + environment = repo.get_environment(environment_name) + print(f"Setting value for {secret_name} in repo {repo.full_name} for environment {environment_name}") + environment.create_secret(secret_name=secret_name, unencrypted_value=secret_value) + self._sleep_for_rate_limit() + + def _set_role_secrets(self, repo: Repository, roles: Roles, env_name: str, set_dependabot: bool) -> None: + self._set_secret( + repo=repo, + secret_name=f"{env_name}_CLOUD_FORMATION_DEPLOY_ROLE", + secret_value=roles.cloud_formation_deploy_role, + set_dependabot=set_dependabot, + ) + self._set_secret( + repo=repo, + secret_name=f"{env_name}_CLOUD_FORMATION_CHECK_VERSION_ROLE", + secret_value=roles.cloud_formation_check_version_role, + set_dependabot=set_dependabot, + ) + self._set_secret( + repo=repo, + secret_name=f"{env_name}_CLOUD_FORMATION_CREATE_CHANGESET_ROLE", + secret_value=roles.cloud_formation_prepare_changeset_role, + set_dependabot=set_dependabot, + ) diff --git a/packages/setup_github_repo/app/github_setup.py b/packages/setup_github_repo/app/github_setup.py new file mode 100644 index 00000000..941ee951 --- /dev/null +++ b/packages/setup_github_repo/app/github_setup.py @@ -0,0 +1,67 @@ +"""Facade for orchestrating repository access, environment, and secret setup.""" + +from github import Github + +from .github_access import GithubAccessManager +from .github_environments import GithubEnvironmentManager +from .github_repo_settings import GithubRepoSettingsManager +from .github_secrets import GithubSecretManager +from .models import GithubTeams, RepoConfig, Secrets + + +class GithubSetupService: + """Facade that coordinates repo access, environment, and secret setup.""" + + def __init__( + self, + github: Github, + github_teams: GithubTeams, + interactive: bool = True, + rate_limit_delay_seconds: float = 1.0, + ): + self._access_manager = GithubAccessManager( + github=github, + github_teams=github_teams, + interactive=interactive, + rate_limit_delay_seconds=rate_limit_delay_seconds, + ) + self._environment_manager = GithubEnvironmentManager( + github=github, + github_teams=github_teams, + interactive=interactive, + rate_limit_delay_seconds=rate_limit_delay_seconds, + ) + self._secret_manager = GithubSecretManager( + github=github, + github_teams=github_teams, + interactive=interactive, + rate_limit_delay_seconds=rate_limit_delay_seconds, + ) + self._repo_settings_manager = GithubRepoSettingsManager( + github=github, + github_teams=github_teams, + interactive=interactive, + rate_limit_delay_seconds=rate_limit_delay_seconds, + ) + + @staticmethod + def get_github_teams(github: Github) -> GithubTeams: + print("Getting github teams") + org = github.get_organization("NHSDigital") + eps_administrator_team = org.get_team_by_slug("eps-administrators") + eps_testers_team = org.get_team_by_slug("eps-testers") + eps_team = org.get_team_by_slug("eps") + eps_deployments_team = org.get_team_by_slug("eps-deployments") + + return GithubTeams( + eps_administrator_team=eps_administrator_team.id, + eps_testers_team=eps_testers_team.id, + eps_team=eps_team.id, + eps_deployments_team=eps_deployments_team.id, + ) + + def setup_repo(self, repo_config: RepoConfig, secrets: Secrets) -> None: + self._repo_settings_manager.setup_general_settings(repo_config=repo_config) + self._access_manager.setup_access(repo_config=repo_config) + self._environment_manager.setup_environments(repo_config=repo_config) + self._secret_manager.set_all_secrets(repo_config=repo_config, secrets=secrets) diff --git a/packages/setup_github_repo/app/models.py b/packages/setup_github_repo/app/models.py new file mode 100644 index 00000000..ceaca456 --- /dev/null +++ b/packages/setup_github_repo/app/models.py @@ -0,0 +1,70 @@ +"""Typed models shared across setup_github_repo services and orchestration.""" + +from dataclasses import dataclass, field + +from github.EnvironmentDeploymentBranchPolicy import EnvironmentDeploymentBranchPolicyParams +from github.EnvironmentProtectionRuleReviewer import ReviewerParams + + +@dataclass +class Roles: + cloud_formation_deploy_role: str | None + cloud_formation_check_version_role: str | None + cloud_formation_prepare_changeset_role: str | None + release_notes_execute_lambda_role: str | None + artillery_runner_role: str | None + + +@dataclass +class Secrets: + regression_test_pem: str + automerge_pem: str + create_pull_request_pem: str + eps_multi_repo_deployment_pem: str + dev_roles: Roles + int_roles: Roles + prod_roles: Roles + qa_roles: Roles + ref_roles: Roles + recovery_roles: Roles + proxygen_prod_role: str + proxygen_ptl_role: str + dev_target_spine_server: str + int_target_spine_server: str + prod_target_spine_server: str + qa_target_spine_server: str + ref_target_spine_server: str + recovery_target_spine_server: str + dev_target_service_search_server: str + int_target_service_search_server: str + prod_target_service_search_server: str + qa_target_service_search_server: str + ref_target_service_search_server: str + recovery_target_service_search_server: str + dependabot_token: str | None + + +@dataclass +class GithubTeams: + eps_administrator_team: int + eps_testers_team: int + eps_team: int + eps_deployments_team: int + + +@dataclass +class RepoConfig: + repoUrl: str + mainBranch: str + setTargetSpineServers: bool + isAccountResources: bool + setTargetServiceSearchServers: bool + isEchoRepo: bool + inWeeklyRelease: bool + + +@dataclass +class RepoEnvironment: + name: str + reviewers: list[ReviewerParams] = field(default_factory=list) + deployment_branch_policy: EnvironmentDeploymentBranchPolicyParams | None = None diff --git a/packages/setup_github_repo/app/repo_status.py b/packages/setup_github_repo/app/repo_status.py new file mode 100644 index 00000000..73759add --- /dev/null +++ b/packages/setup_github_repo/app/repo_status.py @@ -0,0 +1,86 @@ +"""Repository status parsing and loading utilities for eps-repo-status data.""" + +import json +from pathlib import Path +from typing import Any + +from .models import RepoConfig + + +def _as_bool(entry: dict[str, Any], camel_key: str, snake_key: str, default: bool = False) -> bool: + value = entry.get(camel_key) + if value is None: + value = entry.get(snake_key) + if value is None: + return default + return bool(value) + + +def _normalise_repo_entry(entry: Any, fallback_repo_url: str | None = None) -> RepoConfig: + if isinstance(entry, str): + repo_url = entry + entry_dict: dict[str, Any] = {} + elif isinstance(entry, dict): + entry_dict = dict(entry) + repo_url = entry_dict.get("repoUrl") or entry_dict.get("repo") or fallback_repo_url + else: + raise ValueError("Unsupported repo entry type in repos.json") + + if not repo_url: + raise ValueError("Repo entry missing repoUrl") + + repo_url = repo_url.strip() + if not repo_url: + raise ValueError("Repo entry contains empty repoUrl") + + return RepoConfig( + repoUrl=repo_url, + mainBranch=str(entry_dict.get("mainBranch") or entry_dict.get("main_branch") or "main"), + setTargetSpineServers=_as_bool( + entry_dict, + camel_key="setTargetSpineServers", + snake_key="set_target_spine_servers", + ), + isAccountResources=_as_bool( + entry_dict, + camel_key="isAccountResources", + snake_key="is_account_resources", + ), + setTargetServiceSearchServers=_as_bool( + entry_dict, + camel_key="setTargetServiceSearchServers", + snake_key="set_target_service_search_servers", + ), + isEchoRepo=_as_bool( + entry_dict, + camel_key="isEchoRepo", + snake_key="is_echo_repo", + ), + inWeeklyRelease=_as_bool( + entry_dict, + camel_key="inWeeklyRelease", + snake_key="in_weekly_release", + ), + ) + + +def _parse_repos_payload(payload: Any) -> list[RepoConfig]: + if isinstance(payload, list): + return [_normalise_repo_entry(entry) for entry in payload] + if isinstance(payload, dict): + repos_section = payload.get("repos") + if isinstance(repos_section, list): + return [_normalise_repo_entry(entry) for entry in repos_section] + if isinstance(repos_section, dict): + return [_normalise_repo_entry(entry, fallback_repo_url=key) for key, entry in repos_section.items()] + raise ValueError("repos.json must contain either a list of repos or a 'repos' section") + + +class RepoStatusLoader: + """Load repository setup configuration from the local repos.json file.""" + + def load_repo_configs(self) -> list[RepoConfig]: + repos_path = Path(__file__).resolve().parents[3] / "repos.json" + print(f"Loading repo configuration from local file: {repos_path}") + payload = json.loads(repos_path.read_text(encoding="utf-8")) + return _parse_repos_payload(payload) diff --git a/packages/setup_github_repo/app/runner.py b/packages/setup_github_repo/app/runner.py new file mode 100644 index 00000000..4524dca4 --- /dev/null +++ b/packages/setup_github_repo/app/runner.py @@ -0,0 +1,46 @@ +"""High-level orchestration for end-to-end GitHub repository setup tasks.""" + +import json +from dataclasses import asdict + +from github import Github + +from .aws_exports import AwsExportsService +from .github_setup import GithubSetupService +from .repo_status import RepoStatusLoader +from .secrets_builder import SecretsBuilder + + +class SetupGithubRepoRunner: + """Coordinate all setup steps for target GitHub repositories.""" + + def __init__(self, gh_auth_token: str): + self._github = Github(gh_auth_token) + self._aws_exports = AwsExportsService() + self._repo_status_loader = RepoStatusLoader() + + github_teams = GithubSetupService.get_github_teams(github=self._github) + self._github_setup = GithubSetupService( + github=self._github, + github_teams=github_teams, + ) + self._secrets_builder = SecretsBuilder(self._aws_exports) + self._github_teams = github_teams + + def run(self) -> None: + secrets = self._secrets_builder.build() + + self._print_setup_summary(secrets_keys=sorted(asdict(secrets).keys())) + + repos = self._repo_status_loader.load_repo_configs() + for repo in repos: + self._github_setup.setup_repo(repo_config=repo, secrets=secrets) + + def _print_setup_summary(self, secrets_keys: list[str]) -> None: + print("\n\n************************************************") + print("************************************************") + print(f"github_teams: {json.dumps(asdict(self._github_teams), indent=2)}") + print("************************************************") + print(f"secrets keys only: {json.dumps(secrets_keys, indent=2)}") + print("************************************************") + print("\n\n************************************************") diff --git a/packages/setup_github_repo/app/secrets_builder.py b/packages/setup_github_repo/app/secrets_builder.py new file mode 100644 index 00000000..1b4439a8 --- /dev/null +++ b/packages/setup_github_repo/app/secrets_builder.py @@ -0,0 +1,78 @@ +"""Builds the consolidated secrets payload from files, exports, and environment values.""" + +import os +from pathlib import Path +from typing import Any + +from .aws_exports import AwsExportsService +from .constants import AWS_PROFILE_BY_ENV, TARGET_SERVICE_SEARCH_SERVERS, TARGET_SPINE_SERVERS +from .models import Roles, Secrets + + +class SecretsBuilder: + """Build the complete secrets payload from AWS exports, local files, and env vars.""" + + def __init__(self, aws_exports: AwsExportsService, secrets_directory: Path | None = None): + self._aws_exports = aws_exports + self._secrets_directory = secrets_directory or Path(".secrets") + + def build(self) -> Secrets: + exports_by_env: dict[str, list[dict[str, Any]]] = { + env_name: self._aws_exports.get_all_exports(profile_name) + for env_name, profile_name in AWS_PROFILE_BY_ENV.items() + } + + roles_by_env: dict[str, Roles] = { + env_name: self._to_roles(self._aws_exports.get_role_exports(exports)) + for env_name, exports in exports_by_env.items() + } + + prod_exports = exports_by_env["prod"] + proxygen_ptl_role = self._aws_exports.get_named_export( + all_exports=prod_exports, + export_name="ci-resources:ProxygenPTLRole", + required=True, + ) + proxygen_prod_role = self._aws_exports.get_named_export( + all_exports=prod_exports, + export_name="ci-resources:ProxygenProdRole", + required=True, + ) + + return Secrets( + regression_test_pem=self._read_secret_file("regression_test_app.pem"), + eps_multi_repo_deployment_pem=self._read_secret_file("eps_multi_repo_deployment.pem"), + automerge_pem=self._read_secret_file("automerge.pem"), + create_pull_request_pem=self._read_secret_file("create_pull_request.pem"), + dev_roles=roles_by_env["dev"], + int_roles=roles_by_env["int"], + prod_roles=roles_by_env["prod"], + qa_roles=roles_by_env["qa"], + ref_roles=roles_by_env["ref"], + recovery_roles=roles_by_env["recovery"], + proxygen_prod_role=proxygen_prod_role, + proxygen_ptl_role=proxygen_ptl_role, + dev_target_spine_server=TARGET_SPINE_SERVERS["dev"], + int_target_spine_server=TARGET_SPINE_SERVERS["int"], + prod_target_spine_server=TARGET_SPINE_SERVERS["prod"], + qa_target_spine_server=TARGET_SPINE_SERVERS["qa"], + ref_target_spine_server=TARGET_SPINE_SERVERS["ref"], + recovery_target_spine_server=TARGET_SPINE_SERVERS["recovery"], + dev_target_service_search_server=TARGET_SERVICE_SEARCH_SERVERS["dev"], + int_target_service_search_server=TARGET_SERVICE_SEARCH_SERVERS["int"], + prod_target_service_search_server=TARGET_SERVICE_SEARCH_SERVERS["prod"], + qa_target_service_search_server=TARGET_SERVICE_SEARCH_SERVERS["qa"], + ref_target_service_search_server=TARGET_SERVICE_SEARCH_SERVERS["ref"], + recovery_target_service_search_server=TARGET_SERVICE_SEARCH_SERVERS["recovery"], + dependabot_token=os.environ.get("dependabot_token"), + ) + + @staticmethod + def _to_roles(role_exports: Roles | dict[str, str | None]) -> Roles: + if isinstance(role_exports, Roles): + return role_exports + return Roles(**role_exports) + + def _read_secret_file(self, file_name: str) -> str: + file_path = self._secrets_directory / file_name + return file_path.read_text(encoding="utf-8") diff --git a/packages/setup_github_repo/pytest.ini b/packages/setup_github_repo/pytest.ini new file mode 100644 index 00000000..0e331c87 --- /dev/null +++ b/packages/setup_github_repo/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +addopts = --cov=setup_github_repo.app --cov-report=xml:packages/setup_github_repo/coverage/coverage.xml --cov-config=packages/setup_github_repo/.coveragerc diff --git a/packages/setup_github_repo/tests/__init__.py b/packages/setup_github_repo/tests/__init__.py new file mode 100644 index 00000000..b5f55d3e --- /dev/null +++ b/packages/setup_github_repo/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for setup_github_repo package.""" diff --git a/packages/setup_github_repo/tests/test_aws_exports.py b/packages/setup_github_repo/tests/test_aws_exports.py new file mode 100644 index 00000000..07365590 --- /dev/null +++ b/packages/setup_github_repo/tests/test_aws_exports.py @@ -0,0 +1,99 @@ +"""Unit tests for AWS CloudFormation export resolution helpers.""" + +from unittest.mock import MagicMock, call, patch + +import pytest + +from setup_github_repo.app.aws_exports import AwsExportsService +from setup_github_repo.app.models import Roles + + +def test_get_named_export_returns_export_value_when_present(): + service = AwsExportsService() + all_exports = [ + {"Name": "ci-resources:CloudFormationDeployRole", "Value": "deploy-role-arn"}, + {"Name": "ci-resources:CloudFormationCheckVersionRole", "Value": "check-role-arn"}, + ] + + result = service.get_named_export(all_exports, "ci-resources:CloudFormationDeployRole", required=True) + + assert result == "deploy-role-arn" + + +def test_get_named_export_returns_none_when_optional_export_missing(): + service = AwsExportsService() + + result = service.get_named_export([], "ci-resources:ArtilleryRunnerRole", required=False) + + assert result is None + + +def test_get_named_export_raises_when_required_export_missing(): + service = AwsExportsService() + + with pytest.raises(ValueError, match="export ci-resources:CloudFormationDeployRole is required but not found"): + service.get_named_export([], "ci-resources:CloudFormationDeployRole", required=True) + + +@patch("setup_github_repo.app.aws_exports.boto3.Session") +def test_get_all_exports_fetches_all_pages(mock_session: MagicMock): + cloudformation_client = MagicMock() + cloudformation_client.list_exports.side_effect = [ + { + "Exports": [{"Name": "ExportA", "Value": "ValueA"}], + "NextToken": "next-token", + }, + { + "Exports": [{"Name": "ExportB", "Value": "ValueB"}], + }, + ] + + session = MagicMock() + session.client.return_value = cloudformation_client + mock_session.return_value = session + + service = AwsExportsService() + + result = service.get_all_exports(profile_name="prescription-dev") + + mock_session.assert_called_once_with(profile_name="prescription-dev") + session.client.assert_called_once_with("cloudformation") + assert cloudformation_client.list_exports.call_args_list == [ + call(), + call(NextToken="next-token"), + ] + assert result == [ + {"Name": "ExportA", "Value": "ValueA"}, + {"Name": "ExportB", "Value": "ValueB"}, + ] + + +def test_get_role_exports_maps_required_and_optional_roles(): + service = AwsExportsService() + all_exports = [ + {"Name": "ci-resources:CloudFormationDeployRole", "Value": "deploy-role-arn"}, + {"Name": "ci-resources:CloudFormationCheckVersionRole", "Value": "check-role-arn"}, + {"Name": "ci-resources:CloudFormationPrepareChangesetRole", "Value": "changeset-role-arn"}, + {"Name": "ci-resources:ReleaseNotesExecuteLambdaRole", "Value": "release-notes-role-arn"}, + ] + + result = service.get_role_exports(all_exports) + + assert result == Roles( + cloud_formation_deploy_role="deploy-role-arn", + cloud_formation_check_version_role="check-role-arn", + cloud_formation_prepare_changeset_role="changeset-role-arn", + release_notes_execute_lambda_role="release-notes-role-arn", + artillery_runner_role=None, + ) + + +def test_get_role_exports_raises_when_required_role_missing(): + service = AwsExportsService() + all_exports = [ + {"Name": "ci-resources:CloudFormationCheckVersionRole", "Value": "check-role-arn"}, + {"Name": "ci-resources:CloudFormationPrepareChangesetRole", "Value": "changeset-role-arn"}, + ] + + with pytest.raises(ValueError, match="export ci-resources:CloudFormationDeployRole is required but not found"): + service.get_role_exports(all_exports) diff --git a/packages/setup_github_repo/tests/test_cli.py b/packages/setup_github_repo/tests/test_cli.py new file mode 100644 index 00000000..7816b925 --- /dev/null +++ b/packages/setup_github_repo/tests/test_cli.py @@ -0,0 +1,110 @@ +"""Unit tests for CLI auth/bootstrap behavior and runner invocation.""" + +import importlib +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +cli = importlib.import_module("setup_github_repo.app.cli") + + +def test_resolve_gh_auth_token_returns_explicit_value(): + token = cli.resolve_gh_auth_token("explicit-token") + + assert token == "explicit-token" + + +@patch("setup_github_repo.app.cli._read_gh_auth_token", return_value="existing-token") +@patch("setup_github_repo.app.cli.subprocess.run") +def test_get_or_create_uses_existing_token(mock_subprocess_run: MagicMock, _mock_read_token: MagicMock): + token = cli._get_or_create_gh_auth_token() + + assert token == "existing-token" + mock_subprocess_run.assert_not_called() + + +@patch("setup_github_repo.app.cli._read_gh_auth_token", side_effect=[None, "new-token"]) +@patch("setup_github_repo.app.cli.subprocess.run") +def test_get_or_create_runs_login_when_token_missing( + mock_subprocess_run: MagicMock, + _mock_read_token: MagicMock, +): + token = cli._get_or_create_gh_auth_token() + + assert token == "new-token" + mock_subprocess_run.assert_called_once_with(["gh", "auth", "login"], check=True) + + +@patch("setup_github_repo.app.cli._read_gh_auth_token", side_effect=[None, None]) +@patch("setup_github_repo.app.cli.subprocess.run") +def test_get_or_create_raises_if_login_does_not_provide_token( + _mock_subprocess_run: MagicMock, + _mock_read_token: MagicMock, +): + with pytest.raises(RuntimeError): + cli._get_or_create_gh_auth_token() + + +@patch("setup_github_repo.app.cli._has_valid_aws_credentials_for_profile", return_value=True) +def test_get_invalid_aws_profiles_returns_empty_when_all_valid(_mock_has_valid_profile: MagicMock): + invalid_profiles = cli._get_invalid_aws_profiles() + + assert invalid_profiles == [] + + +@patch("setup_github_repo.app.cli._get_invalid_aws_profiles", return_value=[]) +@patch("setup_github_repo.app.cli.subprocess.run") +def test_ensure_aws_credentials_skips_login_when_valid( + mock_subprocess_run: MagicMock, + _mock_get_invalid_profiles: MagicMock, +): + cli.ensure_aws_credentials() + + mock_subprocess_run.assert_not_called() + + +@patch("setup_github_repo.app.cli._get_invalid_aws_profiles", side_effect=[["prescription-dev"], []]) +@patch("setup_github_repo.app.cli.subprocess.run") +def test_ensure_aws_credentials_runs_make_login_when_invalid( + mock_subprocess_run: MagicMock, + _mock_get_invalid_profiles: MagicMock, +): + cli.ensure_aws_credentials() + + mock_subprocess_run.assert_called_once_with(["make", "aws-login"], check=True) + + +@patch( + "setup_github_repo.app.cli._get_invalid_aws_profiles", + side_effect=[["prescription-dev"], ["prescription-dev"]], +) +@patch("setup_github_repo.app.cli.subprocess.run") +def test_ensure_aws_credentials_raises_if_still_invalid_after_login( + _mock_subprocess_run: MagicMock, + _mock_get_invalid_profiles: MagicMock, +): + with pytest.raises(RuntimeError): + cli.ensure_aws_credentials() + + +@patch("setup_github_repo.app.cli.SetupGithubRepoRunner") +@patch("setup_github_repo.app.cli.ensure_aws_credentials") +@patch("setup_github_repo.app.cli.resolve_gh_auth_token", return_value="resolved-token") +@patch("setup_github_repo.app.cli.argparse.ArgumentParser.parse_args") +def test_main_resolves_token_and_runs( + mock_parse_args: MagicMock, + mock_resolve_token: MagicMock, + mock_ensure_aws_credentials: MagicMock, + mock_runner_class: MagicMock, +): + mock_parse_args.return_value = SimpleNamespace(gh_auth_token=None) + mock_runner_instance = MagicMock() + mock_runner_class.return_value = mock_runner_instance + + cli.main() + + mock_ensure_aws_credentials.assert_called_once_with() + mock_resolve_token.assert_called_once_with(None) + mock_runner_class.assert_called_once_with(gh_auth_token="resolved-token") + mock_runner_instance.run.assert_called_once() diff --git a/packages/setup_github_repo/tests/test_github_access.py b/packages/setup_github_repo/tests/test_github_access.py new file mode 100644 index 00000000..cf86cf70 --- /dev/null +++ b/packages/setup_github_repo/tests/test_github_access.py @@ -0,0 +1,81 @@ +"""Unit tests for repository access management in GithubAccessManager.""" + +from unittest.mock import MagicMock, call, patch + +from setup_github_repo.app.github_access import GithubAccessManager +from setup_github_repo.app.models import GithubTeams, RepoConfig + + +def _repo_config() -> RepoConfig: + return RepoConfig( + repoUrl="NHSDigital/example-repo", + mainBranch="main", + setTargetSpineServers=False, + isAccountResources=False, + setTargetServiceSearchServers=False, + isEchoRepo=False, + inWeeklyRelease=False, + ) + + +def _github_teams() -> GithubTeams: + return GithubTeams( + eps_administrator_team=1, + eps_testers_team=2, + eps_team=3, + eps_deployments_team=4, + ) + + +def test_setup_access_grants_expected_team_permissions(capsys): + fake_repo = MagicMock() + fake_org = MagicMock() + + eps_team = MagicMock() + eps_team.slug = "eps" + admins_team = MagicMock() + admins_team.slug = "eps-admin" + fake_org.get_team.side_effect = [eps_team, admins_team] + + fake_github = MagicMock() + fake_github.get_organization.return_value = fake_org + fake_github.get_repo.return_value = fake_repo + + manager = GithubAccessManager( + github=fake_github, + github_teams=_github_teams(), + interactive=False, + rate_limit_delay_seconds=0, + ) + manager._sleep_for_rate_limit = MagicMock() + + manager.setup_access(_repo_config()) + + fake_github.get_organization.assert_called_once_with("NHSDigital") + fake_github.get_repo.assert_called_once_with("NHSDigital/example-repo") + fake_org.get_team.assert_has_calls([call(3), call(1)]) + + eps_team.update_team_repository.assert_called_once_with(fake_repo, "Write_View_Dependabot_Alerts") + admins_team.update_team_repository.assert_called_once_with(fake_repo, "admin") + assert manager._sleep_for_rate_limit.call_count == 2 + + output = capsys.readouterr().out + assert "Granting team eps access to repo NHSDigital/example-repo with role Write_View_Dependabot_Alerts" in output + assert "Granting team eps-admin access to repo NHSDigital/example-repo with role admin" in output + + +@patch("setup_github_repo.app.github_access.GithubAccessManager._confirm_action", return_value=False) +def test_setup_access_skips_when_not_confirmed(_mock_confirm_action: MagicMock): + fake_github = MagicMock() + + manager = GithubAccessManager( + github=fake_github, + github_teams=_github_teams(), + interactive=True, + rate_limit_delay_seconds=0, + ) + + manager.setup_access(_repo_config()) + + fake_github.get_organization.assert_not_called() + fake_github.get_repo.assert_not_called() diff --git a/packages/setup_github_repo/tests/test_github_environments.py b/packages/setup_github_repo/tests/test_github_environments.py new file mode 100644 index 00000000..24b5c0c3 --- /dev/null +++ b/packages/setup_github_repo/tests/test_github_environments.py @@ -0,0 +1,94 @@ +"""Unit tests for environment setup behavior in GithubEnvironmentManager.""" + +from unittest.mock import MagicMock + +from setup_github_repo.app.github_environments import GithubEnvironmentManager +from setup_github_repo.app.models import GithubTeams, RepoConfig + + +def _repo_config( + *, + is_account_resources: bool = False, + is_echo_repo: bool = False, + in_weekly_release: bool = False, +) -> RepoConfig: + return RepoConfig( + repoUrl="NHSDigital/example-repo", + mainBranch="main", + setTargetSpineServers=False, + isAccountResources=is_account_resources, + setTargetServiceSearchServers=False, + isEchoRepo=is_echo_repo, + inWeeklyRelease=in_weekly_release, + ) + + +def _github_teams() -> GithubTeams: + return GithubTeams( + eps_administrator_team=1, + eps_testers_team=2, + eps_team=3, + eps_deployments_team=4, + ) + + +def test_setup_environments_only_creates_create_pull_request_when_not_in_weekly_release(capsys): + fake_repo = MagicMock() + fake_repo.name = "NHSDigital/example-repo" + + fake_github = MagicMock() + fake_github.get_repo.return_value = fake_repo + + manager = GithubEnvironmentManager( + github=fake_github, + github_teams=_github_teams(), + interactive=False, + rate_limit_delay_seconds=0, + ) + + manager._setup_repo_environment = MagicMock() + manager._setup_account_resources_environments = MagicMock() + + manager.setup_environments(_repo_config(in_weekly_release=False)) + + assert manager._setup_repo_environment.call_count == 1 + first_environment = manager._setup_repo_environment.call_args.args[1] + assert first_environment.name == "create_pull_request" + manager._setup_account_resources_environments.assert_not_called() + + output = capsys.readouterr().out + assert "not in weekly release, so not creating release environments" in output + + +def test_setup_environments_creates_all_release_environments_when_in_weekly_release(): + fake_repo = MagicMock() + fake_repo.name = "NHSDigital/example-repo" + + fake_github = MagicMock() + fake_github.get_repo.return_value = fake_repo + + manager = GithubEnvironmentManager( + github=fake_github, + github_teams=_github_teams(), + interactive=False, + rate_limit_delay_seconds=0, + ) + + manager._setup_repo_environment = MagicMock() + manager._setup_account_resources_environments = MagicMock() + + manager.setup_environments(_repo_config(in_weekly_release=True)) + + created_environment_names = [call.args[1].name for call in manager._setup_repo_environment.call_args_list] + + assert created_environment_names == [ + "create_pull_request", + "dev", + "ref", + "int", + "dev-pr", + "recovery", + "qa", + "prod", + ] + manager._setup_account_resources_environments.assert_not_called() diff --git a/packages/setup_github_repo/tests/test_github_repo_settings.py b/packages/setup_github_repo/tests/test_github_repo_settings.py new file mode 100644 index 00000000..87899314 --- /dev/null +++ b/packages/setup_github_repo/tests/test_github_repo_settings.py @@ -0,0 +1,165 @@ +"""Unit tests for repository-level settings updates in GithubRepoSettingsManager.""" + +from unittest.mock import MagicMock, call, patch + +from setup_github_repo.app.github_repo_settings import GithubRepoSettingsManager +from setup_github_repo.app.models import RepoConfig + + +def _repo_config() -> RepoConfig: + return RepoConfig( + repoUrl="NHSDigital/example-repo", + mainBranch="main", + setTargetSpineServers=False, + isAccountResources=False, + setTargetServiceSearchServers=False, + isEchoRepo=False, + inWeeklyRelease=False, + ) + + +def test_setup_general_settings_applies_expected_pr_options(): + fake_required_status_checks = MagicMock() + fake_required_status_checks.checks = [MagicMock(context="build", app_id=123)] + + fake_branch = MagicMock() + fake_branch.get_required_status_checks.return_value = fake_required_status_checks + fake_branch.get_required_signatures.return_value = False + + fake_repo = MagicMock() + fake_repo.get_branch.return_value = fake_branch + fake_repo.private = False + + fake_requester = MagicMock() + fake_requester.requestJsonAndCheck.side_effect = [ + ({}, {"default_workflow_permissions": "read"}), + ({}, {}), + ({}, {}), + ] + fake_repo._requester = fake_requester + + fake_github = MagicMock() + fake_github.get_repo.return_value = fake_repo + + manager = GithubRepoSettingsManager( + github=fake_github, + github_teams={}, # type: ignore[arg-type] + interactive=False, + rate_limit_delay_seconds=0, + ) + + manager.setup_general_settings(_repo_config()) + + fake_github.get_repo.assert_called_once_with("NHSDigital/example-repo") + fake_repo.edit.assert_called_once_with( + allow_merge_commit=False, + allow_squash_merge=True, + allow_rebase_merge=False, + allow_auto_merge=True, + delete_branch_on_merge=True, + squash_merge_commit_title="PR_TITLE", + squash_merge_commit_message="PR_BODY", + ) + fake_repo.get_branch.assert_called_once_with("main") + fake_branch.edit_protection.assert_called_once_with( + strict=True, + required_approving_review_count=1, + dismiss_stale_reviews=True, + require_last_push_approval=True, + checks=[("build", 123)], + ) + fake_branch.add_required_signatures.assert_called_once_with() + fake_requester.requestJsonAndCheck.assert_has_calls( + [ + call("GET", "/repos/NHSDigital/example-repo/actions/permissions/workflow"), + call( + "PUT", + "/repos/NHSDigital/example-repo/actions/permissions/workflow", + input={ + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": True, + }, + ), + call( + "PUT", + "/repos/NHSDigital/example-repo/actions/permissions/fork-pr-contributor-approval", + input={"approval_policy": "all_external_contributors"}, + ), + ] + ) + + +def test_setup_general_settings_uses_contexts_when_checks_not_present(): + fake_required_status_checks = MagicMock() + fake_required_status_checks.checks = [] + fake_required_status_checks.contexts = ["build", "test"] + + fake_branch = MagicMock() + fake_branch.get_required_status_checks.return_value = fake_required_status_checks + fake_branch.get_required_signatures.return_value = True + + fake_repo = MagicMock() + fake_repo.get_branch.return_value = fake_branch + fake_repo.private = True + + fake_requester = MagicMock() + fake_requester.requestJsonAndCheck.side_effect = [ + ({}, {"default_workflow_permissions": "write"}), + ({}, {}), + ] + fake_repo._requester = fake_requester + fake_github = MagicMock() + fake_github.get_repo.return_value = fake_repo + + manager = GithubRepoSettingsManager( + github=fake_github, + github_teams={}, # type: ignore[arg-type] + interactive=False, + rate_limit_delay_seconds=0, + ) + + manager.setup_general_settings(_repo_config()) + + fake_branch.edit_protection.assert_called_once_with( + strict=True, + required_approving_review_count=1, + dismiss_stale_reviews=True, + require_last_push_approval=True, + checks=["build", "test"], + ) + fake_branch.add_required_signatures.assert_not_called() + fake_requester.requestJsonAndCheck.assert_has_calls( + [ + call("GET", "/repos/NHSDigital/example-repo/actions/permissions/workflow"), + call( + "PUT", + "/repos/NHSDigital/example-repo/actions/permissions/workflow", + input={ + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": True, + }, + ), + ] + ) + + +@patch( + "setup_github_repo.app.github_repo_settings.GithubRepoSettingsManager._confirm_action", + return_value=False, +) +def test_setup_general_settings_skips_when_not_confirmed(_mock_confirm_action: MagicMock): + fake_repo = MagicMock() + fake_github = MagicMock() + fake_github.get_repo.return_value = fake_repo + + manager = GithubRepoSettingsManager( + github=fake_github, + github_teams={}, # type: ignore[arg-type] + interactive=True, + rate_limit_delay_seconds=0, + ) + + manager.setup_general_settings(_repo_config()) + + fake_github.get_repo.assert_not_called() + fake_repo.edit.assert_not_called() diff --git a/packages/setup_github_repo/tests/test_github_secrets.py b/packages/setup_github_repo/tests/test_github_secrets.py new file mode 100644 index 00000000..b1e32009 --- /dev/null +++ b/packages/setup_github_repo/tests/test_github_secrets.py @@ -0,0 +1,202 @@ +"""Unit tests for weekly-release secret creation behavior in GithubSecretManager.""" + +from unittest.mock import MagicMock + +from setup_github_repo.app.github_secrets import GithubSecretManager +from setup_github_repo.app.models import Roles, Secrets, RepoConfig + + +def _repo_config( + *, + in_weekly_release: bool, + set_target_spine_servers: bool = False, + set_target_service_search_servers: bool = False, + is_account_resources: bool = False, + is_echo_repo: bool = False, +) -> RepoConfig: + return RepoConfig( + repoUrl="NHSDigital/example-repo", + mainBranch="main", + setTargetSpineServers=set_target_spine_servers, + isAccountResources=is_account_resources, + setTargetServiceSearchServers=set_target_service_search_servers, + isEchoRepo=is_echo_repo, + inWeeklyRelease=in_weekly_release, + ) + + +def _roles() -> Roles: + return Roles( + cloud_formation_deploy_role="deploy-role", + cloud_formation_check_version_role="check-role", + cloud_formation_prepare_changeset_role="changeset-role", + release_notes_execute_lambda_role="release-notes-role", + artillery_runner_role="artillery-role", + ) + + +def _secrets() -> Secrets: + roles = _roles() + return Secrets( + regression_test_pem="regression-pem", + automerge_pem="automerge-pem", + create_pull_request_pem="create-pr-pem", + eps_multi_repo_deployment_pem="multi-repo-pem", + dev_roles=roles, + int_roles=roles, + prod_roles=roles, + qa_roles=roles, + ref_roles=roles, + recovery_roles=roles, + proxygen_prod_role="proxygen-prod-role", + proxygen_ptl_role="proxygen-ptl-role", + dev_target_spine_server="dev-spine", + int_target_spine_server="int-spine", + prod_target_spine_server="prod-spine", + qa_target_spine_server="qa-spine", + ref_target_spine_server="ref-spine", + recovery_target_spine_server="recovery-spine", + dev_target_service_search_server="dev-search", + int_target_service_search_server="int-search", + prod_target_service_search_server="prod-search", + qa_target_service_search_server="qa-search", + ref_target_service_search_server="ref-search", + recovery_target_service_search_server="recovery-search", + dependabot_token="dependabot-token", + ) + + +def test_set_all_secrets_only_creates_baseline_secrets_when_not_in_weekly_release(capsys): + fake_repo = MagicMock() + fake_repo.full_name = "NHSDigital/example-repo" + + fake_github = MagicMock() + fake_github.get_repo.return_value = fake_repo + + manager = GithubSecretManager( + github=fake_github, + github_teams=MagicMock(), # type: ignore[arg-type] + interactive=False, + rate_limit_delay_seconds=0, + ) + + manager._set_secret = MagicMock() + manager._set_environment_secret = MagicMock() + manager._set_role_secrets = MagicMock() + + manager.set_all_secrets(_repo_config(in_weekly_release=False), _secrets()) + + set_secret_names = [call.kwargs["secret_name"] for call in manager._set_secret.call_args_list] + assert set_secret_names == ["DEPENDABOT_TOKEN"] + + set_environment_secret_names = [ + call.kwargs["secret_name"] for call in manager._set_environment_secret.call_args_list + ] + assert set_environment_secret_names == [ + "AUTOMERGE_PEM", + "AUTOMERGE_APP_ID", + "CREATE_PULL_REQUEST_PEM", + "CREATE_PULL_REQUEST_APP_ID", + ] + + manager._set_role_secrets.assert_not_called() + fake_github.get_repo.assert_called_once_with("NHSDigital/example-repo") + + output = capsys.readouterr().out + assert "not in weekly release, so not creating additional secrets" in output + + +def test_set_all_secrets_creates_additional_secrets_when_in_weekly_release(): + fake_repo = MagicMock() + fake_repo.full_name = "NHSDigital/example-repo" + + fake_github = MagicMock() + fake_github.get_repo.return_value = fake_repo + + manager = GithubSecretManager( + github=fake_github, + github_teams=MagicMock(), # type: ignore[arg-type] + interactive=False, + rate_limit_delay_seconds=0, + ) + + manager._set_secret = MagicMock() + manager._set_environment_secret = MagicMock() + manager._set_role_secrets = MagicMock() + + manager.set_all_secrets(_repo_config(in_weekly_release=True), _secrets()) + + set_secret_names = [call.kwargs["secret_name"] for call in manager._set_secret.call_args_list] + assert "DEPENDABOT_TOKEN" in set_secret_names + assert "DEV_CLOUD_FORMATION_EXECUTE_LAMBDA_ROLE" in set_secret_names + assert "REGRESSION_TESTS_PEM" in set_secret_names + assert "REF_ARTILLERY_RUNNER_ROLE" in set_secret_names + + role_env_names = [call.kwargs["env_name"] for call in manager._set_role_secrets.call_args_list] + assert role_env_names == ["DEV", "INT", "PROD", "QA", "REF", "RECOVERY"] + + fake_github.get_repo.assert_called_once_with("NHSDigital/example-repo") + + +def test_set_all_secrets_sets_target_spine_server_secrets_when_enabled(): + fake_repo = MagicMock() + fake_repo.full_name = "NHSDigital/example-repo" + + fake_github = MagicMock() + fake_github.get_repo.return_value = fake_repo + + manager = GithubSecretManager( + github=fake_github, + github_teams=MagicMock(), # type: ignore[arg-type] + interactive=False, + rate_limit_delay_seconds=0, + ) + + manager._set_secret = MagicMock() + manager._set_environment_secret = MagicMock() + manager._set_role_secrets = MagicMock() + + manager.set_all_secrets( + _repo_config(in_weekly_release=True, set_target_spine_servers=True), + _secrets(), + ) + + set_secret_names = [call.kwargs["secret_name"] for call in manager._set_secret.call_args_list] + assert "DEV_TARGET_SPINE_SERVER" in set_secret_names + assert "INT_TARGET_SPINE_SERVER" in set_secret_names + assert "PROD_TARGET_SPINE_SERVER" in set_secret_names + assert "QA_TARGET_SPINE_SERVER" in set_secret_names + assert "REF_TARGET_SPINE_SERVER" in set_secret_names + assert "RECOVERY_TARGET_SPINE_SERVER" in set_secret_names + + +def test_set_all_secrets_sets_target_service_search_secrets_when_enabled(): + fake_repo = MagicMock() + fake_repo.full_name = "NHSDigital/example-repo" + + fake_github = MagicMock() + fake_github.get_repo.return_value = fake_repo + + manager = GithubSecretManager( + github=fake_github, + github_teams=MagicMock(), # type: ignore[arg-type] + interactive=False, + rate_limit_delay_seconds=0, + ) + + manager._set_secret = MagicMock() + manager._set_environment_secret = MagicMock() + manager._set_role_secrets = MagicMock() + + manager.set_all_secrets( + _repo_config(in_weekly_release=True, set_target_service_search_servers=True), + _secrets(), + ) + + set_secret_names = [call.kwargs["secret_name"] for call in manager._set_secret.call_args_list] + assert "DEV_TARGET_SERVICE_SEARCH_SERVER" in set_secret_names + assert "INT_TARGET_SERVICE_SEARCH_SERVER" in set_secret_names + assert "PROD_TARGET_SERVICE_SEARCH_SERVER" in set_secret_names + assert "QA_TARGET_SERVICE_SEARCH_SERVER" in set_secret_names + assert "REF_TARGET_SERVICE_SEARCH_SERVER" in set_secret_names + assert "RECOVERY_TARGET_SERVICE_SEARCH_SERVER" in set_secret_names diff --git a/packages/setup_github_repo/tests/test_github_setup.py b/packages/setup_github_repo/tests/test_github_setup.py new file mode 100644 index 00000000..b2d110ab --- /dev/null +++ b/packages/setup_github_repo/tests/test_github_setup.py @@ -0,0 +1,145 @@ +"""Unit tests for GithubSetupService orchestration and team resolution.""" + +from unittest.mock import MagicMock, call, patch + +from setup_github_repo.app.github_setup import GithubSetupService +from setup_github_repo.app.models import GithubTeams, RepoConfig, Roles, Secrets + + +def _repo_config() -> RepoConfig: + return RepoConfig( + repoUrl="NHSDigital/example-repo", + mainBranch="main", + setTargetSpineServers=False, + isAccountResources=False, + setTargetServiceSearchServers=False, + isEchoRepo=False, + inWeeklyRelease=True, + ) + + +def _roles() -> Roles: + return Roles( + cloud_formation_deploy_role="deploy-role", + cloud_formation_check_version_role="check-role", + cloud_formation_prepare_changeset_role="changeset-role", + release_notes_execute_lambda_role="release-notes-role", + artillery_runner_role="artillery-role", + ) + + +def _secrets() -> Secrets: + roles = _roles() + return Secrets( + regression_test_pem="regression-pem", + automerge_pem="automerge-pem", + create_pull_request_pem="create-pr-pem", + eps_multi_repo_deployment_pem="multi-repo-pem", + dev_roles=roles, + int_roles=roles, + prod_roles=roles, + qa_roles=roles, + ref_roles=roles, + recovery_roles=roles, + proxygen_prod_role="proxygen-prod-role", + proxygen_ptl_role="proxygen-ptl-role", + dev_target_spine_server="dev-spine", + int_target_spine_server="int-spine", + prod_target_spine_server="prod-spine", + qa_target_spine_server="qa-spine", + ref_target_spine_server="ref-spine", + recovery_target_spine_server="recovery-spine", + dev_target_service_search_server="dev-search", + int_target_service_search_server="int-search", + prod_target_service_search_server="prod-search", + qa_target_service_search_server="qa-search", + ref_target_service_search_server="ref-search", + recovery_target_service_search_server="recovery-search", + dependabot_token="dependabot-token", + ) + + +@patch("setup_github_repo.app.github_setup.GithubRepoSettingsManager") +@patch("setup_github_repo.app.github_setup.GithubSecretManager") +@patch("setup_github_repo.app.github_setup.GithubEnvironmentManager") +@patch("setup_github_repo.app.github_setup.GithubAccessManager") +def test_init_wires_all_manager_dependencies( + mock_access_manager: MagicMock, + mock_environment_manager: MagicMock, + mock_secret_manager: MagicMock, + mock_repo_settings_manager: MagicMock, +): + fake_github = MagicMock() + teams = GithubTeams( + eps_administrator_team=1, + eps_testers_team=2, + eps_team=3, + eps_deployments_team=4, + ) + + GithubSetupService( + github=fake_github, + github_teams=teams, + interactive=False, + rate_limit_delay_seconds=0, + ) + + expected_call = call( + github=fake_github, + github_teams=teams, + interactive=False, + rate_limit_delay_seconds=0, + ) + mock_access_manager.assert_called_once_with(**expected_call.kwargs) + mock_environment_manager.assert_called_once_with(**expected_call.kwargs) + mock_secret_manager.assert_called_once_with(**expected_call.kwargs) + mock_repo_settings_manager.assert_called_once_with(**expected_call.kwargs) + + +def test_get_github_teams_reads_expected_slugs_and_returns_ids(): + fake_org = MagicMock() + fake_org.get_team_by_slug.side_effect = [ + MagicMock(id=11), + MagicMock(id=22), + MagicMock(id=33), + MagicMock(id=44), + ] + + fake_github = MagicMock() + fake_github.get_organization.return_value = fake_org + + teams = GithubSetupService.get_github_teams(github=fake_github) + + fake_github.get_organization.assert_called_once_with("NHSDigital") + fake_org.get_team_by_slug.assert_has_calls( + [ + call("eps-administrators"), + call("eps-testers"), + call("eps"), + call("eps-deployments"), + ] + ) + assert teams == GithubTeams( + eps_administrator_team=11, + eps_testers_team=22, + eps_team=33, + eps_deployments_team=44, + ) + + +def test_setup_repo_calls_each_manager_with_repo_config_and_secrets(): + service = GithubSetupService.__new__(GithubSetupService) + service._repo_settings_manager = MagicMock() + service._access_manager = MagicMock() + service._environment_manager = MagicMock() + service._secret_manager = MagicMock() + + repo_config = _repo_config() + secrets = _secrets() + + service.setup_repo(repo_config=repo_config, secrets=secrets) + + service._repo_settings_manager.setup_general_settings.assert_called_once_with(repo_config=repo_config) + service._access_manager.setup_access.assert_called_once_with(repo_config=repo_config) + service._environment_manager.setup_environments.assert_called_once_with(repo_config=repo_config) + service._secret_manager.set_all_secrets.assert_called_once_with(repo_config=repo_config, secrets=secrets) diff --git a/packages/setup_github_repo/tests/test_repo_status.py b/packages/setup_github_repo/tests/test_repo_status.py new file mode 100644 index 00000000..5fe74738 --- /dev/null +++ b/packages/setup_github_repo/tests/test_repo_status.py @@ -0,0 +1,93 @@ +"""Unit tests for repo status payload normalization, parsing, and loading.""" + +import importlib +import json +from pathlib import Path + +import pytest + +repo_status = importlib.import_module("setup_github_repo.app.repo_status") +RepoStatusLoader = repo_status.RepoStatusLoader + + +def test_parse_repos_payload_from_list_of_strings(): + payload = ["NHSDigital/repo-one", "NHSDigital/repo-two"] + + result = repo_status._parse_repos_payload(payload) + + assert len(result) == 2 + assert result[0].repoUrl == "NHSDigital/repo-one" + assert result[0].mainBranch == "main" + assert result[0].setTargetSpineServers is False + assert result[0].isAccountResources is False + assert result[0].setTargetServiceSearchServers is False + assert result[0].isEchoRepo is False + assert result[0].inWeeklyRelease is False + + +def test_parse_repos_payload_from_repos_dict(): + payload = { + "repos": { + "NHSDigital/repo-one": { + "mainBranch": "release/1.x", + "set_target_spine_servers": True, + "is_account_resources": True, + "set_target_service_search_servers": False, + "is_echo_repo": True, + "in_weekly_release": True, + } + } + } + + result = repo_status._parse_repos_payload(payload) + + assert len(result) == 1 + assert result[0].repoUrl == "NHSDigital/repo-one" + assert result[0].mainBranch == "release/1.x" + assert result[0].setTargetSpineServers is True + assert result[0].isAccountResources is True + assert result[0].setTargetServiceSearchServers is False + assert result[0].isEchoRepo is True + assert result[0].inWeeklyRelease is True + + +def test_normalise_repo_entry_rejects_empty_repo_url(): + with pytest.raises(ValueError): + repo_status._normalise_repo_entry({"repoUrl": " "}) + + +def test_parse_repos_payload_rejects_invalid_shape(): + with pytest.raises(ValueError): + repo_status._parse_repos_payload({"notRepos": []}) + + +def test_load_repo_configs_from_local_repos_file(): + payload = { + "repos": [ + { + "repoUrl": "NHSDigital/repo-one", + "mainBranch": "main", + "setTargetSpineServers": True, + "isAccountResources": False, + "setTargetServiceSearchServers": True, + "isEchoRepo": False, + "inWeeklyRelease": True, + } + ] + } + root_repos_file = Path(__file__).resolve().parents[3] / "repos.json" + original_content = root_repos_file.read_text(encoding="utf-8") + root_repos_file.write_text(json.dumps(payload), encoding="utf-8") + + loader = RepoStatusLoader() + + try: + result = loader.load_repo_configs() + finally: + root_repos_file.write_text(original_content, encoding="utf-8") + + assert result[0].repoUrl == "NHSDigital/repo-one" + assert result[0].mainBranch == "main" + assert result[0].setTargetSpineServers is True + assert result[0].setTargetServiceSearchServers is True + assert result[0].inWeeklyRelease is True diff --git a/packages/setup_github_repo/tests/test_runner.py b/packages/setup_github_repo/tests/test_runner.py new file mode 100644 index 00000000..e4a31b51 --- /dev/null +++ b/packages/setup_github_repo/tests/test_runner.py @@ -0,0 +1,150 @@ +"""Unit tests for SetupGithubRepoRunner orchestration and dependency wiring.""" + +from dataclasses import asdict +from unittest.mock import MagicMock, call, patch + +from setup_github_repo.app.models import GithubTeams, RepoConfig, Roles, Secrets +from setup_github_repo.app.runner import SetupGithubRepoRunner + + +def _repo_config(repo_url: str) -> RepoConfig: + return RepoConfig( + repoUrl=repo_url, + mainBranch="main", + setTargetSpineServers=False, + isAccountResources=False, + setTargetServiceSearchServers=False, + isEchoRepo=False, + inWeeklyRelease=True, + ) + + +def _roles() -> Roles: + return Roles( + cloud_formation_deploy_role="deploy-role", + cloud_formation_check_version_role="check-role", + cloud_formation_prepare_changeset_role="changeset-role", + release_notes_execute_lambda_role="release-notes-role", + artillery_runner_role="artillery-role", + ) + + +def _secrets() -> Secrets: + roles = _roles() + return Secrets( + regression_test_pem="regression-pem", + automerge_pem="automerge-pem", + create_pull_request_pem="create-pr-pem", + eps_multi_repo_deployment_pem="multi-repo-pem", + dev_roles=roles, + int_roles=roles, + prod_roles=roles, + qa_roles=roles, + ref_roles=roles, + recovery_roles=roles, + proxygen_prod_role="proxygen-prod-role", + proxygen_ptl_role="proxygen-ptl-role", + dev_target_spine_server="dev-spine", + int_target_spine_server="int-spine", + prod_target_spine_server="prod-spine", + qa_target_spine_server="qa-spine", + ref_target_spine_server="ref-spine", + recovery_target_spine_server="recovery-spine", + dev_target_service_search_server="dev-search", + int_target_service_search_server="int-search", + prod_target_service_search_server="prod-search", + qa_target_service_search_server="qa-search", + ref_target_service_search_server="ref-search", + recovery_target_service_search_server="recovery-search", + dependabot_token="dependabot-token", + ) + + +@patch("setup_github_repo.app.runner.SecretsBuilder") +@patch("setup_github_repo.app.runner.GithubSetupService") +@patch("setup_github_repo.app.runner.RepoStatusLoader") +@patch("setup_github_repo.app.runner.AwsExportsService") +@patch("setup_github_repo.app.runner.Github") +def test_init_wires_dependencies( + mock_github: MagicMock, + mock_aws_exports_service: MagicMock, + mock_repo_status_loader: MagicMock, + mock_github_setup_service: MagicMock, + mock_secrets_builder: MagicMock, +): + github_instance = MagicMock() + mock_github.return_value = github_instance + aws_exports_instance = MagicMock() + mock_aws_exports_service.return_value = aws_exports_instance + github_teams = MagicMock() + mock_github_setup_service.get_github_teams.return_value = github_teams + + runner = SetupGithubRepoRunner(gh_auth_token="token-123") + + mock_github.assert_called_once_with("token-123") + mock_repo_status_loader.assert_called_once_with() + mock_github_setup_service.get_github_teams.assert_called_once_with(github=github_instance) + mock_github_setup_service.assert_called_once_with(github=github_instance, github_teams=github_teams) + mock_secrets_builder.assert_called_once_with(aws_exports_instance) + assert runner._github_teams == github_teams + + +def test_run_sets_up_all_loaded_repos(): + runner = SetupGithubRepoRunner.__new__(SetupGithubRepoRunner) + secrets = _secrets() + runner._secrets_builder = MagicMock() + runner._secrets_builder.build.return_value = secrets + runner._print_setup_summary = MagicMock() + runner._repo_status_loader = MagicMock() + runner._repo_status_loader.load_repo_configs.return_value = [ + _repo_config("NHSDigital/eps-aws-dashboards"), + _repo_config("NHSDigital/other-repo"), + ] + runner._github_setup = MagicMock() + + runner.run() + + runner._print_setup_summary.assert_called_once_with(secrets_keys=sorted(asdict(secrets).keys())) + runner._github_setup.setup_repo.assert_has_calls( + [ + call(repo_config=_repo_config("NHSDigital/eps-aws-dashboards"), secrets=secrets), + call(repo_config=_repo_config("NHSDigital/other-repo"), secrets=secrets), + ] + ) + assert runner._github_setup.setup_repo.call_count == 2 + + +def test_run_skips_setup_when_no_matching_repo(): + runner = SetupGithubRepoRunner.__new__(SetupGithubRepoRunner) + secrets = _secrets() + runner._secrets_builder = MagicMock() + runner._secrets_builder.build.return_value = secrets + runner._print_setup_summary = MagicMock() + runner._repo_status_loader = MagicMock() + runner._repo_status_loader.load_repo_configs.return_value = [_repo_config("NHSDigital/other-repo")] + runner._github_setup = MagicMock() + + runner.run() + + runner._github_setup.setup_repo.assert_called_once_with( + repo_config=_repo_config("NHSDigital/other-repo"), + secrets=secrets, + ) + + +def test_print_setup_summary_displays_team_and_secret_keys(capsys): + runner = SetupGithubRepoRunner.__new__(SetupGithubRepoRunner) + runner._github_teams = GithubTeams( + eps_administrator_team=1, + eps_testers_team=2, + eps_team=3, + eps_deployments_team=4, + ) + + runner._print_setup_summary(secrets_keys=["a_secret", "z_secret"]) + + output = capsys.readouterr().out + assert "github_teams" in output + assert "secrets keys only" in output + assert "a_secret" in output + assert "z_secret" in output diff --git a/packages/setup_github_repo/tests/test_secrets_builder.py b/packages/setup_github_repo/tests/test_secrets_builder.py new file mode 100644 index 00000000..e87a946a --- /dev/null +++ b/packages/setup_github_repo/tests/test_secrets_builder.py @@ -0,0 +1,87 @@ +"""Unit tests for secrets payload creation from exports, files, and environment.""" + +import os +from pathlib import Path +import tempfile +from unittest.mock import patch + +import pytest + +from setup_github_repo.app.constants import AWS_PROFILE_BY_ENV +from setup_github_repo.app.models import Roles +from setup_github_repo.app.secrets_builder import SecretsBuilder + + +class StubAwsExportsService: + def __init__(self): + self.requested_profiles: list[str] = [] + self.get_named_export_calls: list[tuple[str, bool]] = [] + + def get_all_exports(self, profile_name: str): + self.requested_profiles.append(profile_name) + return [ + {"Name": "ci-resources:CloudFormationDeployRole", "Value": f"{profile_name}-deploy"}, + {"Name": "ci-resources:CloudFormationCheckVersionRole", "Value": f"{profile_name}-check"}, + {"Name": "ci-resources:CloudFormationPrepareChangesetRole", "Value": f"{profile_name}-changeset"}, + {"Name": "ci-resources:ReleaseNotesExecuteLambdaRole", "Value": f"{profile_name}-release"}, + {"Name": "ci-resources:ArtilleryRunnerRole", "Value": f"{profile_name}-artillery"}, + {"Name": "ci-resources:ProxygenPTLRole", "Value": "ptl-role-arn"}, + {"Name": "ci-resources:ProxygenProdRole", "Value": "prod-role-arn"}, + ] + + def get_role_exports(self, all_exports): + def _find(name: str): + return next(export["Value"] for export in all_exports if export["Name"] == name) + + return Roles( + cloud_formation_deploy_role=_find("ci-resources:CloudFormationDeployRole"), + cloud_formation_check_version_role=_find("ci-resources:CloudFormationCheckVersionRole"), + cloud_formation_prepare_changeset_role=_find("ci-resources:CloudFormationPrepareChangesetRole"), + release_notes_execute_lambda_role=_find("ci-resources:ReleaseNotesExecuteLambdaRole"), + artillery_runner_role=_find("ci-resources:ArtilleryRunnerRole"), + ) + + def get_named_export(self, all_exports, export_name: str, required: bool): + self.get_named_export_calls.append((export_name, required)) + return next(export["Value"] for export in all_exports if export["Name"] == export_name) + + +def test_build_returns_complete_secrets_payload(): + with tempfile.TemporaryDirectory() as temp_dir: + secrets_dir = Path(temp_dir) + (secrets_dir / "regression_test_app.pem").write_text("regression", encoding="utf-8") + (secrets_dir / "eps_multi_repo_deployment.pem").write_text("multi-repo", encoding="utf-8") + (secrets_dir / "automerge.pem").write_text("automerge", encoding="utf-8") + (secrets_dir / "create_pull_request.pem").write_text("create-pr", encoding="utf-8") + + aws_exports = StubAwsExportsService() + builder = SecretsBuilder(aws_exports=aws_exports, secrets_directory=secrets_dir) + + with patch.dict(os.environ, {"dependabot_token": "token-123"}, clear=False): + result = builder.build() + + assert result.regression_test_pem == "regression" + assert result.eps_multi_repo_deployment_pem == "multi-repo" + assert result.automerge_pem == "automerge" + assert result.create_pull_request_pem == "create-pr" + assert result.proxygen_ptl_role == "ptl-role-arn" + assert result.proxygen_prod_role == "prod-role-arn" + assert result.dependabot_token == "token-123" + + assert set(aws_exports.requested_profiles) == set(AWS_PROFILE_BY_ENV.values()) + assert ("ci-resources:ProxygenPTLRole", True) in aws_exports.get_named_export_calls + assert ("ci-resources:ProxygenProdRole", True) in aws_exports.get_named_export_calls + + +def test_build_raises_when_secret_file_missing(): + with tempfile.TemporaryDirectory() as temp_dir: + secrets_dir = Path(temp_dir) + (secrets_dir / "regression_test_app.pem").write_text("regression", encoding="utf-8") + (secrets_dir / "automerge.pem").write_text("automerge", encoding="utf-8") + (secrets_dir / "create_pull_request.pem").write_text("create-pr", encoding="utf-8") + + aws_exports = StubAwsExportsService() + builder = SecretsBuilder(aws_exports=aws_exports, secrets_directory=secrets_dir) + + with pytest.raises(FileNotFoundError): + builder.build() diff --git a/poetry.lock b/poetry.lock index 66cd338f..4b1246e0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "black" @@ -51,6 +51,46 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] +[[package]] +name = "boto3" +version = "1.42.90" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "boto3-1.42.90-py3-none-any.whl", hash = "sha256:fde7f7bcad6ec8342d6bf18f56d118d0cb6df189310cfaf73e2eb6443b1cb418"}, + {file = "boto3-1.42.90.tar.gz", hash = "sha256:bafb5bb1dea262ac95f9afb1e415f06a9490f05cb203bdd897d0afdcd17733c6"}, +] + +[package.dependencies] +botocore = ">=1.42.90,<1.43.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.16.0,<0.17.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.42.90" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "botocore-1.42.90-py3-none-any.whl", hash = "sha256:5c95504720346990adc8e3ae1023eb46f9409084b79688e4773ba7099c5fd3db"}, + {file = "botocore-1.42.90.tar.gz", hash = "sha256:234c39492cd3088acb021d999e3392a4d50238ae3e70b9d9ae1504c30d9009d1"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.31.2)"] + [[package]] name = "certifi" version = "2026.1.4" @@ -318,7 +358,7 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -584,6 +624,18 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "jmespath" +version = "1.1.0" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -878,6 +930,21 @@ pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pytokens" version = "0.4.0" @@ -1038,6 +1105,36 @@ urllib3 = ">=1.26,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] +[[package]] +name = "s3transfer" +version = "0.16.0" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe"}, + {file = "s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920"}, +] + +[package.dependencies] +botocore = ">=1.37.4,<2.0a0" + +[package.extras] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1092,4 +1189,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = "^3.14" -content-hash = "2bf75740d20c9574cca8bd67761cc324932b23e29fd3248b92af9b9838a6a42d" +content-hash = "0e4e2c1d12e75ba7b0c20bed9388584c85b45453f7c734f404dcb14653878e78" diff --git a/pyproject.toml b/pyproject.toml index ca0fa210..f705d700 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ package-mode = false python = "^3.14" pygithub = "^2.9.0" requests = "^2.33.1" +boto3 = "^1.42.90" [tool.poetry.scripts] diff --git a/repos.json b/repos.json index 7e4c3d03..dae8d709 100644 --- a/repos.json +++ b/repos.json @@ -462,5 +462,20 @@ "isAccountResources": false, "setTargetServiceSearchServers": false, "isEchoRepo": false + }, + { + "repoUrl": "NHSDigital/eps-test-reports", + "friendlyName": "eps test reports", + "ciWorkflow": "NONE", + "releaseWorkflow": "release.yml", + "mainBranch": "main", + "inWeeklyRelease": false, + "releaseFiles": [], + "isApiRepo": false, + "isSpineRepo": false, + "setTargetSpineServers": false, + "isAccountResources": false, + "setTargetServiceSearchServers": false, + "isEchoRepo": false } ] diff --git a/scripts/setup_github_repos.py b/scripts/setup_github_repos.py new file mode 100755 index 00000000..0aba6112 --- /dev/null +++ b/scripts/setup_github_repos.py @@ -0,0 +1,25 @@ +"""Compatibility wrapper for the refactored GitHub repository setup package. + +Usage remains the same: + poetry run python scripts/setup_github_repos.py --gh_auth_token "$GH_TOKEN" + +You can also run without passing a token; the CLI will use `gh auth token` +and fall back to `gh auth login` when needed. + +The CLI also validates AWS credentials for required profiles and runs +`make aws-login` if credentials are missing or expired. +""" + +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from packages.setup_github_repo.app.cli import main # noqa: E402 + + +if __name__ == "__main__": + main()