From 2831bf178b77a7a19962c3f58f85f2ab25a72e68 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Mon, 12 Oct 2026 15:03:46 +0200 Subject: [PATCH 1/6] Add secrets, envs, labels, pages, wf perms, deploy keys --- changelog/+actions_secrets.added.md | 1 + changelog/+default_branch.added.md | 1 + changelog/+deploy_keys.added.md | 1 + changelog/+environments.added.md | 1 + changelog/+labels.added.md | 1 + changelog/+pages.added.md | 1 + changelog/+workflow_permissions.added.md | 1 + docs/ref/utils/index.rst | 5 + .../utils/saltext.github.utils.gh.actions.rst | 5 + .../saltext.github.utils.gh.deploy_keys.rst | 5 + .../saltext.github.utils.gh.environments.rst | 5 + .../utils/saltext.github.utils.gh.labels.rst | 5 + .../utils/saltext.github.utils.gh.pages.rst | 5 + src/saltext/github/modules/github.py | 858 +++++++++++++++- src/saltext/github/states/github.py | 937 +++++++++++++++++- src/saltext/github/utils/gh/actions.py | 176 ++++ src/saltext/github/utils/gh/deploy_keys.py | 90 ++ src/saltext/github/utils/gh/environments.py | 226 +++++ src/saltext/github/utils/gh/labels.py | 130 +++ src/saltext/github/utils/gh/pages.py | 118 +++ src/saltext/github/utils/gh/repos.py | 2 + tests/functional/conftest.py | 12 +- .../modules/github/test_actions_secrets.py | 50 + .../modules/github/test_deploy_keys.py | 55 + .../modules/github/test_environments.py | 77 ++ .../functional/modules/github/test_labels.py | 58 ++ tests/functional/modules/github/test_pages.py | 53 + tests/functional/modules/github/test_repos.py | 5 + .../github/test_workflow_permissions.py | 26 + .../states/github/test_actions_secret.py | 75 ++ .../states/github/test_deploy_key.py | 93 ++ .../states/github/test_environment.py | 142 +++ tests/functional/states/github/test_label.py | 91 ++ tests/functional/states/github/test_pages.py | 85 ++ tests/functional/states/github/test_repo.py | 29 + .../github/test_workflow_permissions.py | 72 ++ tests/support/github/__init__.py | 35 +- tests/support/github/base.py | 15 +- tests/support/github/entities.py | 341 +++++++ tests/support/github/routes/__init__.py | 10 + tests/support/github/routes/actions.py | 79 ++ tests/support/github/routes/deploy_keys.py | 32 + tests/support/github/routes/environments.py | 68 ++ tests/support/github/routes/labels.py | 44 + tests/support/github/routes/pages.py | 47 + tests/support/github/state.py | 71 +- 46 files changed, 4231 insertions(+), 8 deletions(-) create mode 100644 changelog/+actions_secrets.added.md create mode 100644 changelog/+default_branch.added.md create mode 100644 changelog/+deploy_keys.added.md create mode 100644 changelog/+environments.added.md create mode 100644 changelog/+labels.added.md create mode 100644 changelog/+pages.added.md create mode 100644 changelog/+workflow_permissions.added.md create mode 100644 docs/ref/utils/saltext.github.utils.gh.actions.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.deploy_keys.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.environments.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.labels.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.pages.rst create mode 100644 src/saltext/github/utils/gh/actions.py create mode 100644 src/saltext/github/utils/gh/deploy_keys.py create mode 100644 src/saltext/github/utils/gh/environments.py create mode 100644 src/saltext/github/utils/gh/labels.py create mode 100644 src/saltext/github/utils/gh/pages.py create mode 100644 tests/functional/modules/github/test_actions_secrets.py create mode 100644 tests/functional/modules/github/test_deploy_keys.py create mode 100644 tests/functional/modules/github/test_environments.py create mode 100644 tests/functional/modules/github/test_labels.py create mode 100644 tests/functional/modules/github/test_pages.py create mode 100644 tests/functional/modules/github/test_workflow_permissions.py create mode 100644 tests/functional/states/github/test_actions_secret.py create mode 100644 tests/functional/states/github/test_deploy_key.py create mode 100644 tests/functional/states/github/test_environment.py create mode 100644 tests/functional/states/github/test_label.py create mode 100644 tests/functional/states/github/test_pages.py create mode 100644 tests/functional/states/github/test_workflow_permissions.py create mode 100644 tests/support/github/routes/actions.py create mode 100644 tests/support/github/routes/deploy_keys.py create mode 100644 tests/support/github/routes/environments.py create mode 100644 tests/support/github/routes/labels.py create mode 100644 tests/support/github/routes/pages.py diff --git a/changelog/+actions_secrets.added.md b/changelog/+actions_secrets.added.md new file mode 100644 index 00000000..6490ad62 --- /dev/null +++ b/changelog/+actions_secrets.added.md @@ -0,0 +1 @@ +Added management of repository Actions secrets, encrypted client-side to the repository public key (``github.list_actions_secrets``/``get_actions_secret``/``set_actions_secret``/``remove_actions_secret`` and the ``actions_secret_present``/``actions_secret_absent`` states) diff --git a/changelog/+default_branch.added.md b/changelog/+default_branch.added.md new file mode 100644 index 00000000..0e9e876d --- /dev/null +++ b/changelog/+default_branch.added.md @@ -0,0 +1 @@ +Added ``default_branch`` to ``github.edit_repo`` and the ``repo_present`` state diff --git a/changelog/+deploy_keys.added.md b/changelog/+deploy_keys.added.md new file mode 100644 index 00000000..03d475c7 --- /dev/null +++ b/changelog/+deploy_keys.added.md @@ -0,0 +1 @@ +Added management of repository deploy keys (``github.list_deploy_keys``/``get_deploy_key``/``add_deploy_key``/``remove_deploy_key`` and the ``deploy_key_present``/``deploy_key_absent`` states) diff --git a/changelog/+environments.added.md b/changelog/+environments.added.md new file mode 100644 index 00000000..4ca6c5ae --- /dev/null +++ b/changelog/+environments.added.md @@ -0,0 +1 @@ +Added management of repository environments, including deployment branch policies (``github.list_environments``/``get_environment``/``set_environment``/``remove_environment``/``list_deployment_branch_policies``/``add_deployment_branch_policy``/``remove_deployment_branch_policy`` and the ``environment_present``/``environment_absent`` states) diff --git a/changelog/+labels.added.md b/changelog/+labels.added.md new file mode 100644 index 00000000..554b9349 --- /dev/null +++ b/changelog/+labels.added.md @@ -0,0 +1 @@ +Added management of repository labels (``github.list_labels``/``get_label``/``add_label``/``edit_label``/``remove_label`` and the ``label_present``/``label_absent`` states) diff --git a/changelog/+pages.added.md b/changelog/+pages.added.md new file mode 100644 index 00000000..f89b6820 --- /dev/null +++ b/changelog/+pages.added.md @@ -0,0 +1 @@ +Added management of GitHub Pages sites (``github.get_pages``/``setup_pages``/``update_pages``/``remove_pages`` and the ``pages_present``/``pages_absent`` states) diff --git a/changelog/+workflow_permissions.added.md b/changelog/+workflow_permissions.added.md new file mode 100644 index 00000000..70cb9e51 --- /dev/null +++ b/changelog/+workflow_permissions.added.md @@ -0,0 +1 @@ +Added management of default workflow permissions for the ``GITHUB_TOKEN`` (``github.get_workflow_permissions``/``set_workflow_permissions`` and the ``workflow_permissions_managed`` state) diff --git a/docs/ref/utils/index.rst b/docs/ref/utils/index.rst index da3eef7c..f467b750 100644 --- a/docs/ref/utils/index.rst +++ b/docs/ref/utils/index.rst @@ -11,10 +11,15 @@ _________ utils utils.gh + utils.gh.actions utils.gh.client + utils.gh.deploy_keys + utils.gh.environments utils.gh.exceptions utils.gh.issues + utils.gh.labels utils.gh.milestones + utils.gh.pages utils.gh.pulls utils.gh.repos utils.gh.rulesets diff --git a/docs/ref/utils/saltext.github.utils.gh.actions.rst b/docs/ref/utils/saltext.github.utils.gh.actions.rst new file mode 100644 index 00000000..16e27cb6 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.actions.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.actions +=============================== + +.. automodule:: saltext.github.utils.gh.actions + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.deploy_keys.rst b/docs/ref/utils/saltext.github.utils.gh.deploy_keys.rst new file mode 100644 index 00000000..38d18189 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.deploy_keys.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.deploy_keys +=================================== + +.. automodule:: saltext.github.utils.gh.deploy_keys + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.environments.rst b/docs/ref/utils/saltext.github.utils.gh.environments.rst new file mode 100644 index 00000000..6b0011ad --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.environments.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.environments +==================================== + +.. automodule:: saltext.github.utils.gh.environments + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.labels.rst b/docs/ref/utils/saltext.github.utils.gh.labels.rst new file mode 100644 index 00000000..b4c0e933 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.labels.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.labels +============================== + +.. automodule:: saltext.github.utils.gh.labels + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.pages.rst b/docs/ref/utils/saltext.github.utils.gh.pages.rst new file mode 100644 index 00000000..f0a3e331 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.pages.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.pages +============================= + +.. automodule:: saltext.github.utils.gh.pages + :members: diff --git a/src/saltext/github/modules/github.py b/src/saltext/github/modules/github.py index 8b8ce5cc..e062d650 100644 --- a/src/saltext/github/modules/github.py +++ b/src/saltext/github/modules/github.py @@ -61,9 +61,14 @@ from saltext.github.utils import filter_unset try: + from saltext.github.utils.gh import actions as gh_actions from saltext.github.utils.gh import client as gh_client + from saltext.github.utils.gh import deploy_keys as gh_deploy_keys + from saltext.github.utils.gh import environments as gh_environments from saltext.github.utils.gh import issues as gh_issues + from saltext.github.utils.gh import labels as gh_labels from saltext.github.utils.gh import milestones as gh_milestones + from saltext.github.utils.gh import pages as gh_pages from saltext.github.utils.gh import pulls as gh_pulls from saltext.github.utils.gh import repos as gh_repos from saltext.github.utils.gh import rulesets as gh_rulesets @@ -84,9 +89,6 @@ def __virtual__(): - """ - Only load this module if PyGithub is installed on this minion. - """ if HAS_LIBS: return __virtualname__ return ( # pragma: no cover @@ -774,6 +776,7 @@ def edit_repo( has_issues=None, has_wiki=None, has_downloads=None, + default_branch=None, profile="github", ): """ @@ -801,6 +804,11 @@ def edit_repo( has_downloads Whether to enable downloads for this repository. + default_branch + The default branch of the repository. The branch must already exist. + + .. versionadded:: 1.1.0 + profile The name of the profile configuration to use. Defaults to ``github``. @@ -826,6 +834,7 @@ def edit_repo( has_issues=has_issues, has_wiki=has_wiki, has_downloads=has_downloads, + default_branch=default_branch, allow_privacy_change=allow_privacy_change, ) return True @@ -1681,3 +1690,846 @@ def list_rulesets(profile="github", *, per_page=None, **kwargs): return _ruleset_error_ret(err) except CommandExecutionError: return {"result": False, "comment": "Error getting rulesets"} + + +def _resolve_repo_name(repo_name, profile): + """ + Default ``repo_name`` to the profile configuration when unset. + """ + if repo_name is None: + return _get_config_value(profile, "repo_name") + return repo_name + + +def list_deploy_keys(repo_name=None, profile="github"): + """ + List the deploy keys of a repository. + + .. versionadded:: 1.1.0 + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.list_deploy_keys my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_deploy_keys.list_all(_get_conn(profile), repo_name) + + +def get_deploy_key(key_id, repo_name=None, profile="github"): + """ + Return the data of a single deploy key of a repository. + + .. versionadded:: 1.1.0 + + key_id + The ID of the deploy key. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.get_deploy_key 12345 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_deploy_keys.get(_get_conn(profile), repo_name, key_id) + + +def add_deploy_key(title, key, read_only=False, repo_name=None, profile="github"): + """ + Add a deploy key to a repository. Returns the data of the created key. + + Note that deploy keys are immutable. To modify one, remove it and + add a replacement. + + .. versionadded:: 1.1.0 + + title + The display title of the deploy key. + + key + The SSH public key. + + read_only + Deny write access to the repository for this key. Defaults to false. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.add_deploy_key autorelease 'ssh-ed25519 AAAA...' read_only=false repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_deploy_keys.create( + _get_conn(profile), repo_name, title, key, read_only=bool(read_only) + ) + + +def remove_deploy_key(key_id, repo_name=None, profile="github"): + """ + Remove a deploy key from a repository. + + .. versionadded:: 1.1.0 + + key_id + The ID of the deploy key. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.remove_deploy_key 12345 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_deploy_keys.delete(_get_conn(profile), repo_name, key_id) + return True + + +def list_actions_secrets(repo_name=None, profile="github"): + """ + List the Actions secrets of a repository. Secret values are write-only, + so this only returns metadata (``name``, ``created_at``, ``updated_at``). + + .. versionadded:: 1.1.0 + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.list_actions_secrets my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_actions.list_secrets(_get_conn(profile), repo_name) + + +def get_actions_secret(name, repo_name=None, profile="github"): + """ + Return the metadata of a single Actions secret of a repository. + The value itself is write-only and cannot be retrieved. + + .. versionadded:: 1.1.0 + + name + The name of the secret. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.get_actions_secret MY_SECRET repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_actions.get_secret(_get_conn(profile), repo_name, name) + + +def set_actions_secret(name, value, repo_name=None, profile="github"): + """ + Create or update an Actions secret of a repository. The value is + encrypted to the repository public key before leaving the minion. + + .. versionadded:: 1.1.0 + + name + The name of the secret. + + value + The plaintext secret value. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.set_actions_secret MY_SECRET hunter2 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_actions.set_secret(_get_conn(profile), repo_name, name, value) + return True + + +def remove_actions_secret(name, repo_name=None, profile="github"): + """ + Remove an Actions secret from a repository. + + .. versionadded:: 1.1.0 + + name + The name of the secret. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.remove_actions_secret MY_SECRET repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_actions.delete_secret(_get_conn(profile), repo_name, name) + return True + + +def get_workflow_permissions(repo_name=None, profile="github"): + """ + Return the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository, a mapping with ``default_workflow_permissions`` + (``read``/``write``) and ``can_approve_pull_request_reviews``. + + .. versionadded:: 1.1.0 + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.get_workflow_permissions my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_actions.get_workflow_permissions(_get_conn(profile), repo_name) + + +def set_workflow_permissions( + default_workflow_permissions=None, + can_approve_pull_request_reviews=None, + repo_name=None, + profile="github", +): + """ + Update the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository. + + .. versionadded:: 1.1.0 + + default_workflow_permissions + The default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Whether GitHub Actions can approve pull requests. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.set_workflow_permissions read can_approve_pull_request_reviews=true repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_actions.set_workflow_permissions( + _get_conn(profile), + repo_name, + default_workflow_permissions=default_workflow_permissions, + can_approve_pull_request_reviews=can_approve_pull_request_reviews, + ) + return True + + +def list_environments(repo_name=None, profile="github"): + """ + List the environments of a repository. + + .. versionadded:: 1.1.0 + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.list_environments my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_environments.list_all(_get_conn(profile), repo_name) + + +def get_environment(name, repo_name=None, profile="github"): + """ + Return the data of a single environment of a repository. + + .. versionadded:: 1.1.0 + + name + The name of the environment. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.get_environment release repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_environments.get(_get_conn(profile), repo_name, name) + + +def set_environment( + name, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branch_policy=..., + repo_name=None, + profile="github", +): + """ + Create or update an environment of a repository. + Returns the data of the environment. + + .. versionadded:: 1.1.0 + + name + The name of the environment. + + wait_timer + The amount of time to delay a job after the job is initially + triggered, in minutes. + + prevent_self_review + Whether or not a user who created the job is prevented from + approving their own job. + + reviewers + The people or teams that may review jobs that reference the + environment, as a list of mappings with ``type`` (``User`` or + ``Team``) and ``id``. + + deployment_branch_policy + The type of deployment branch policy for this environment, either + ``none`` to allow all branches or a mapping with + ``protected_branches`` and ``custom_branch_policies`` booleans. + Branch name patterns for custom policies are managed separately, + see :py:func:`github.add_deployment_branch_policy + `. + If not passed at all, the current policy is left unchanged. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.set_environment release repo_name=my-repo \\ + deployment_branch_policy='{protected_branches: false, custom_branch_policies: true}' + """ + repo_name = _resolve_repo_name(repo_name, profile) + conn = _get_conn(profile) + if deployment_branch_policy is ...: + # PUT replaces the whole environment config, so carry over the + # current policy when the caller does not specify one. + try: + current = gh_environments.get(conn, repo_name, name) + deployment_branch_policy = gh_environments.normalize(current)[ + "deployment_branch_policy" + ] + except GitHubNotFoundError: + deployment_branch_policy = None + return gh_environments.set_( + conn, + repo_name, + name, + wait_timer=wait_timer, + prevent_self_review=prevent_self_review, + reviewers=reviewers, + deployment_branch_policy=deployment_branch_policy, + ) + + +def remove_environment(name, repo_name=None, profile="github"): + """ + Remove an environment from a repository. + + .. versionadded:: 1.1.0 + + name + The name of the environment. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.remove_environment release repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_environments.delete(_get_conn(profile), repo_name, name) + return True + + +def list_deployment_branch_policies(environment, repo_name=None, profile="github"): + """ + List the deployment branch policies of an environment. + + .. versionadded:: 1.1.0 + + environment + The name of the environment. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.list_deployment_branch_policies release repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_environments.list_branch_policies(_get_conn(profile), repo_name, environment) + + +def add_deployment_branch_policy( + environment, name, type_="branch", repo_name=None, profile="github" +): + r""" + Add a deployment branch (or tag) policy to an environment. + Returns the data of the created policy. + + The environment's ``deployment_branch_policy`` must have + ``custom_branch_policies`` enabled. + + .. versionadded:: 1.1.0 + + environment + The name of the environment. + + name + The name pattern that branches (or tags) must match in order + to deploy to the environment. + + type\_ + Whether this rule targets a ``branch`` or ``tag``. + Defaults to ``branch``. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.add_deployment_branch_policy release main repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_environments.create_branch_policy( + _get_conn(profile), repo_name, environment, name, type_=type_ + ) + + +def remove_deployment_branch_policy(environment, policy_id, repo_name=None, profile="github"): + """ + Remove a deployment branch policy from an environment. + + .. versionadded:: 1.1.0 + + environment + The name of the environment. + + policy_id + The ID of the branch policy. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.remove_deployment_branch_policy release 12345 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_environments.delete_branch_policy(_get_conn(profile), repo_name, environment, policy_id) + return True + + +def list_labels(repo_name=None, profile="github"): + """ + List the labels of a repository. + + .. versionadded:: 1.1.0 + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.list_labels my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_labels.list_all(_get_conn(profile), repo_name) + + +def get_label(name, repo_name=None, profile="github"): + """ + Return the data of a single label of a repository. + + .. versionadded:: 1.1.0 + + name + The name of the label. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.get_label bug repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_labels.get(_get_conn(profile), repo_name, name) + + +def add_label(name, color=None, description=None, repo_name=None, profile="github"): + """ + Create a label in a repository. Returns the data of the created label. + + .. versionadded:: 1.1.0 + + name + The name of the label. + + color + The hexadecimal color code for the label, without the leading ``#``. + If unset, GitHub assigns a random one. + + description + A short description of the label. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.add_label release color=00ff00 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_labels.create( + _get_conn(profile), repo_name, name, color=color, description=description + ) + + +def edit_label(name, new_name=None, color=None, description=None, repo_name=None, profile="github"): + """ + Update an existing label of a repository. + Returns the data of the updated label. + + .. versionadded:: 1.1.0 + + name + The (current) name of the label. + + new_name + A new name for the label. + + color + The hexadecimal color code for the label, without the leading ``#``. + + description + A short description of the label. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.edit_label release color=00ff00 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_labels.update( + _get_conn(profile), + repo_name, + name, + new_name=new_name, + color=color, + description=description, + ) + + +def remove_label(name, repo_name=None, profile="github"): + """ + Delete a label from a repository. + + .. versionadded:: 1.1.0 + + name + The name of the label. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.remove_label release repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_labels.delete(_get_conn(profile), repo_name, name) + return True + + +def get_pages(repo_name=None, profile="github"): + """ + Return information about the GitHub Pages site of a repository. + + .. versionadded:: 1.1.0 + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.get_pages my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_pages.get(_get_conn(profile), repo_name) + + +def setup_pages( + build_type=None, source_branch=None, source_path=None, repo_name=None, profile="github" +): + """ + Configure a GitHub Pages site for a repository. + Returns the data of the created site. + + .. versionadded:: 1.1.0 + + build_type + The process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.setup_pages build_type=workflow repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_pages.create( + _get_conn(profile), + repo_name, + build_type=build_type, + source=gh_pages.build_source(source_branch, source_path), + ) + + +def update_pages( + build_type=None, + source_branch=None, + source_path=None, + https_enforced=None, + repo_name=None, + profile="github", +): + """ + Update the GitHub Pages site configuration of a repository. + + .. versionadded:: 1.1.0 + + build_type + The process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + https_enforced + Whether HTTPS is enforced for the site. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.update_pages build_type=workflow repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_pages.update( + _get_conn(profile), + repo_name, + build_type=build_type, + source=gh_pages.build_source(source_branch, source_path), + https_enforced=https_enforced, + ) + return True + + +def remove_pages(repo_name=None, profile="github"): + """ + Delete the GitHub Pages site of a repository. + + .. versionadded:: 1.1.0 + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github.remove_pages my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_pages.delete(_get_conn(profile), repo_name) + return True diff --git a/src/saltext/github/states/github.py b/src/saltext/github/states/github.py index dc4d8c9e..bad15c6b 100644 --- a/src/saltext/github/states/github.py +++ b/src/saltext/github/states/github.py @@ -1,5 +1,7 @@ """ -Manage organization teams/members, repositories and rulesets. +Manage organization teams/members, repositories and their resources +(rulesets, deploy keys, Actions secrets and settings, environments, +labels, Pages). """ import datetime @@ -8,6 +10,16 @@ from salt.exceptions import CommandExecutionError +from saltext.github.utils import filter_unset +from saltext.github.utils.gh.exceptions import GitHubNotFoundError + +try: + from saltext.github.utils.gh import environments as gh_environments +except ImportError: # pragma: no cover + # PyGithub is missing; __virtual__ fails since the execution module + # is not loaded either. + gh_environments = None + log = logging.getLogger(__name__) __virtualname__ = "github" @@ -461,6 +473,7 @@ def repo_present( auto_init=False, gitignore_template=None, license_template=None, + default_branch=None, teams=None, profile="github", **kwargs, @@ -499,6 +512,12 @@ def repo_present( license_template The desired LICENSE template to apply, e.g "mit" or "mozilla". + default_branch + The default branch of the repository. The branch must already exist, + so this is not applied when the repository is newly created. + + .. versionadded:: 1.1.0 + teams The teams for which this repo should belong to, specified as a dict of team name to permission (``pull``, ``push`` or ``admin``). @@ -534,6 +553,7 @@ def repo_present( "auto_init": auto_init, "gitignore_template": gitignore_template, "license_template": license_template, + "default_branch": default_branch, } # Keep track of current_teams if we've fetched them after creating a new repo @@ -578,6 +598,8 @@ def repo_present( ret["result"] = None else: add_params = dict(given_params) + # The default branch can only be switched to an existing branch + add_params.pop("default_branch", None) add_params.update(kwargs) result = __salt__["github.add_repo"](name, profile=profile, **add_params) @@ -922,3 +944,916 @@ def ruleset_present( ret["result"] = False ret["comment"] = "Could not update ruleset" return ret + + +def _key_body(key): + """ + Reduce an SSH public key to its comparable part (type + base64 body), + ignoring whitespace variations and the comment. + """ + return " ".join(key.split()[:2]) + + +def deploy_key_present(name, key, read_only=False, repo_name=None, profile="github"): + """ + Ensure a deploy key with this title and public key is present + in a repository. + + Since deploy keys are immutable in the GitHub API, a key whose + ``key`` or ``read_only`` setting differs is replaced (removed + and recreated). + + .. versionadded:: 1.1.0 + + Example: + + .. code-block:: yaml + + Autorelease deploy key is present: + github.deploy_key_present: + - name: autorelease + - key: ssh-ed25519 AAAA... + - repo_name: my-repo + + name + The display title of the deploy key. + + key + The SSH public key. + + read_only + Deny write access to the repository for this key. Defaults to false. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + keys = __salt__["github.list_deploy_keys"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list deploy keys: {err}" + return ret + + current = next((cur for cur in keys if cur["title"] == name), None) + + if current is not None: + if _key_body(current["key"]) == _key_body(key) and bool(current["read_only"]) is bool( + read_only + ): + ret["comment"] = f"Deploy key {name} is already present" + return ret + ret["changes"]["old"] = {"key": current["key"], "read_only": current["read_only"]} + ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been replaced" + return ret + try: + __salt__["github.remove_deploy_key"]( + current["id"], repo_name=repo_name, profile=profile + ) + __salt__["github.add_deploy_key"]( + name, key, read_only=read_only, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to replace deploy key {name}: {err}" + return ret + ret["comment"] = f"Deploy key {name} has been replaced" + return ret + + ret["changes"]["old"] = None + ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been added" + return ret + try: + __salt__["github.add_deploy_key"]( + name, key, read_only=read_only, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to add deploy key {name}: {err}" + return ret + ret["comment"] = f"Deploy key {name} has been added" + return ret + + +def deploy_key_absent(name, repo_name=None, profile="github"): + """ + Ensure no deploy key with this title is present in a repository. + + .. versionadded:: 1.1.0 + + name + The display title of the deploy key. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + keys = __salt__["github.list_deploy_keys"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list deploy keys: {err}" + return ret + + current = next((cur for cur in keys if cur["title"] == name), None) + if current is None: + ret["comment"] = f"Deploy key {name} is already absent" + return ret + + ret["changes"]["old"] = f"Deploy key {name} exists" + ret["changes"]["new"] = f"Deploy key {name} removed" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been removed" + return ret + try: + __salt__["github.remove_deploy_key"](current["id"], repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to remove deploy key {name}: {err}" + return ret + ret["comment"] = f"Deploy key {name} has been removed" + return ret + + +def actions_secret_present(name, value=None, force=False, repo_name=None, profile="github"): + """ + Ensure an Actions secret is present in a repository. + + Secret values are write-only in the GitHub API, so this state cannot + detect value drift. By default, an existing secret is left untouched. + Set ``force`` to push the value on every run. + + .. versionadded:: 1.1.0 + + name + The name of the secret. GitHub treats secret names + case-insensitively. + + value + The plaintext secret value. It is encrypted to the repository + public key before leaving the minion and never appears in the + state output. Required when the secret does not exist yet or + ``force`` is set. + + force + Push the value even when the secret already exists. + Defaults to false. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + secrets = __salt__["github.list_actions_secrets"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list Actions secrets: {err}" + return ret + + current = next((sec for sec in secrets if sec["name"].lower() == name.lower()), None) + + if current is not None and not force: + ret["comment"] = ( + f"Secret {name} is already present. Note that secret values are " + "write-only and cannot be verified, set force to push the value." + ) + return ret + + if value is None: + ret["result"] = False + ret["comment"] = f"A value is required to set secret {name}" + return ret + + if current is None: + ret["changes"]["old"] = None + ret["changes"]["new"] = f"Secret {name} set" + verb = "created" + else: + ret["changes"]["old"] = f"Secret {name} exists" + ret["changes"]["new"] = f"Secret {name} updated" + verb = "updated" + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Secret {name} would have been {verb}" + return ret + try: + __salt__["github.set_actions_secret"](name, value, repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to set secret {name}: {err}" + return ret + ret["comment"] = f"Secret {name} has been {verb}" + return ret + + +def actions_secret_absent(name, repo_name=None, profile="github"): + """ + Ensure an Actions secret is absent from a repository. + + .. versionadded:: 1.1.0 + + name + The name of the secret. GitHub treats secret names + case-insensitively. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + secrets = __salt__["github.list_actions_secrets"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list Actions secrets: {err}" + return ret + + current = next((sec for sec in secrets if sec["name"].lower() == name.lower()), None) + if current is None: + ret["comment"] = f"Secret {name} is already absent" + return ret + + ret["changes"]["old"] = f"Secret {name} exists" + ret["changes"]["new"] = f"Secret {name} removed" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Secret {name} would have been removed" + return ret + try: + __salt__["github.remove_actions_secret"]( + current["name"], repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to remove secret {name}: {err}" + return ret + ret["comment"] = f"Secret {name} has been removed" + return ret + + +def workflow_permissions_managed( + name, + default_workflow_permissions=None, + can_approve_pull_request_reviews=None, + repo_name=None, + profile="github", +): + """ + Manage the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository. + + .. versionadded:: 1.1.0 + + Example: + + .. code-block:: yaml + + Actions tokens are restricted in my-repo: + github.workflow_permissions_managed: + - name: my-repo + - default_workflow_permissions: read + - can_approve_pull_request_reviews: true + + name + The name of the repository, unless ``repo_name`` is passed. + + default_workflow_permissions + The default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Whether GitHub Actions can approve pull requests. + + repo_name + The name of the repository, if it differs from ``name``. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + repo_name = repo_name or name + + desired = filter_unset( + { + "default_workflow_permissions": default_workflow_permissions, + "can_approve_pull_request_reviews": can_approve_pull_request_reviews, + } + ) + if not desired: + ret["result"] = False + ret["comment"] = "At least one setting to manage must be specified" + return ret + + try: + current = __salt__["github.get_workflow_permissions"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get workflow permissions: {err}" + return ret + + diff = {key: val for key, val in desired.items() if current.get(key) != val} + if not diff: + ret["comment"] = "Workflow permissions are already set as specified" + return ret + + ret["changes"]["old"] = {key: current.get(key) for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = "Workflow permissions would have been updated" + return ret + try: + __salt__["github.set_workflow_permissions"](repo_name=repo_name, profile=profile, **diff) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to set workflow permissions: {err}" + return ret + ret["comment"] = "Workflow permissions have been updated" + return ret + + +def _desired_branch_policy(deployment_branches): + """ + Map the state's ``deployment_branches`` parameter onto the API's + ``deployment_branch_policy`` setting. + """ + if deployment_branches == "all": + return None + if deployment_branches == "protected": + return {"protected_branches": True, "custom_branch_policies": False} + if isinstance(deployment_branches, list): + return {"protected_branches": False, "custom_branch_policies": True} + raise CommandExecutionError( + "deployment_branches must be 'all', 'protected' or a list of branch " + f"name patterns, got: {deployment_branches!r}" + ) + + +def _converge_branch_policies(ret, name, deployment_branches, *, env_exists, repo_name, profile): + """ + Converge an environment's deployment branch name patterns, recording + changes in ``ret``. Returns false when the state should abort. + """ + current_policies = [] + if env_exists: + try: + current_policies = __salt__["github.list_deployment_branch_policies"]( + name, repo_name=repo_name, profile=profile + ) + except GitHubNotFoundError: + current_policies = [] + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list deployment branch policies: {err}" + return False + current_patterns = {policy["name"] for policy in current_policies} + desired_patterns = set(deployment_branches) + if current_patterns == desired_patterns: + return True + ret["changes"]["deployment_branches"] = { + "old": sorted(current_patterns), + "new": sorted(desired_patterns), + } + if __opts__["test"]: + ret["result"] = None + return True + try: + for pattern in desired_patterns - current_patterns: + __salt__["github.add_deployment_branch_policy"]( + name, pattern, repo_name=repo_name, profile=profile + ) + for policy in current_policies: + if policy["name"] not in desired_patterns: + __salt__["github.remove_deployment_branch_policy"]( + name, policy["id"], repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to converge deployment branch policies: {err}" + return False + return True + + +def environment_present( # pylint: disable=too-many-statements,too-many-branches + name, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branches=None, + repo_name=None, + profile="github", +): + """ + Ensure an environment is present in a repository and configured + as specified. Unspecified settings are left unmanaged. + + .. versionadded:: 1.1.0 + + Example: + + .. code-block:: yaml + + Release environment is present: + github.environment_present: + - name: release + - repo_name: my-repo + - deployment_branches: + - main + + name + The name of the environment. + + wait_timer + The amount of time to delay a job after the job is initially + triggered, in minutes. + + prevent_self_review + Whether or not a user who created the job is prevented from + approving their own job. + + reviewers + The people or teams that may review jobs that reference the + environment, as a list of mappings with ``type`` (``User`` or + ``Team``) and ``id``. + + deployment_branches + Which branches can deploy to this environment. Either ``all``, + ``protected`` (branches with branch protection rules) or a list + of branch name patterns. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + current = __salt__["github.get_environment"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + current = None + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get environment {name}: {err}" + return ret + + desired = filter_unset( + { + "wait_timer": wait_timer, + "prevent_self_review": prevent_self_review, + "reviewers": reviewers, + } + ) + if deployment_branches is not None: + try: + desired["deployment_branch_policy"] = _desired_branch_policy(deployment_branches) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + return ret + + env_diff = {} + if current is None: + ret["changes"]["environment"] = {"old": None, "new": f"Environment {name} created"} + set_kwargs = dict(desired) + else: + current_settings = gh_environments.normalize(current) + for key, val in desired.items(): + current_val = current_settings.get(key) + if key == "reviewers": + matches = sorted((reviewer["type"], reviewer["id"]) for reviewer in val) == sorted( + (reviewer["type"], reviewer["id"]) for reviewer in current_val + ) + else: + matches = current_val == val + if not matches: + env_diff[key] = val + ret["changes"][key] = {"old": current_val, "new": val} + # The API call replaces the whole configuration, so settings + # that are not managed here need to be carried over. + set_kwargs = {**current_settings, **desired} + + if env_diff or current is None: + if not __opts__["test"]: + try: + __salt__["github.set_environment"]( + name, + repo_name=repo_name, + profile=profile, + **set_kwargs, + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to configure environment {name}: {err}" + return ret + + # Converge the branch name patterns for custom branch policies + if isinstance(deployment_branches, list) and not _converge_branch_policies( + ret, + name, + deployment_branches, + env_exists=current is not None, + repo_name=repo_name, + profile=profile, + ): + return ret + + if not ret["changes"]: + ret["comment"] = f"Environment {name} is already configured as specified" + elif __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Environment {name} would have been configured" + else: + ret["comment"] = f"Environment {name} has been configured" + return ret + + +def environment_absent(name, repo_name=None, profile="github"): + """ + Ensure an environment is absent from a repository. + + .. versionadded:: 1.1.0 + + name + The name of the environment. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + __salt__["github.get_environment"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Environment {name} is already absent" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get environment {name}: {err}" + return ret + + ret["changes"]["old"] = f"Environment {name} exists" + ret["changes"]["new"] = f"Environment {name} removed" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Environment {name} would have been removed" + return ret + try: + __salt__["github.remove_environment"](name, repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to remove environment {name}: {err}" + return ret + ret["comment"] = f"Environment {name} has been removed" + return ret + + +def _normalize_color(color): + """ + Normalize a label color for comparisons: lowercase, no leading ``#``. + """ + if color is None: + return None + return color.lstrip("#").lower() + + +def label_present(name, color=None, description=None, repo_name=None, profile="github"): + """ + Ensure a label is present in a repository and configured as + specified. Unspecified settings are left unmanaged. + + .. versionadded:: 1.1.0 + + name + The name of the label. + + color + The hexadecimal color code for the label. A leading ``#`` + is accepted. + + description + A short description of the label. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + current = __salt__["github.get_label"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + current = None + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get label {name}: {err}" + return ret + + if current is None: + ret["changes"]["old"] = None + ret["changes"]["new"] = f"Label {name} created" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Label {name} would have been created" + return ret + try: + __salt__["github.add_label"]( + name, color=color, description=description, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to create label {name}: {err}" + return ret + ret["comment"] = f"Label {name} has been created" + return ret + + diff = {} + if color is not None and _normalize_color(current["color"]) != _normalize_color(color): + diff["color"] = color + if description is not None and current.get("description") != description: + diff["description"] = description + + if not diff: + ret["comment"] = f"Label {name} is already configured as specified" + return ret + + ret["changes"]["old"] = {key: current.get(key) for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Label {name} would have been updated" + return ret + try: + __salt__["github.edit_label"](name, repo_name=repo_name, profile=profile, **diff) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to update label {name}: {err}" + return ret + ret["comment"] = f"Label {name} has been updated" + return ret + + +def label_absent(name, repo_name=None, profile="github"): + """ + Ensure a label is absent from a repository. + + .. versionadded:: 1.1.0 + + name + The name of the label. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + __salt__["github.get_label"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Label {name} is already absent" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get label {name}: {err}" + return ret + + ret["changes"]["old"] = f"Label {name} exists" + ret["changes"]["new"] = f"Label {name} removed" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Label {name} would have been removed" + return ret + try: + __salt__["github.remove_label"](name, repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to remove label {name}: {err}" + return ret + ret["comment"] = f"Label {name} has been removed" + return ret + + +def pages_present( + name, + build_type=None, + source_branch=None, + source_path=None, + https_enforced=None, + repo_name=None, + profile="github", +): + """ + Ensure GitHub Pages are enabled for a repository and configured as + specified. Unspecified settings are left unmanaged. + + .. versionadded:: 1.1.0 + + Example: + + .. code-block:: yaml + + Pages are deployed via Actions in my-repo: + github.pages_present: + - name: my-repo + - build_type: workflow + + name + The name of the repository, unless ``repo_name`` is passed. + + build_type + The process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + https_enforced + Whether HTTPS is enforced for the site. + + repo_name + The name of the repository, if it differs from ``name``. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + repo_name = repo_name or name + + try: + current = __salt__["github.get_pages"](repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + current = None + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get Pages configuration: {err}" + return ret + + if current is None: + ret["changes"]["old"] = None + ret["changes"]["new"] = "Pages site created" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Pages would have been enabled for {repo_name}" + return ret + try: + __salt__["github.setup_pages"]( + build_type=build_type, + source_branch=source_branch, + source_path=source_path, + repo_name=repo_name, + profile=profile, + ) + if https_enforced is not None: + __salt__["github.update_pages"]( + https_enforced=https_enforced, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to enable Pages for {repo_name}: {err}" + return ret + ret["comment"] = f"Pages have been enabled for {repo_name}" + return ret + + current_source = current.get("source") or {} + desired = filter_unset( + { + "build_type": build_type, + "source_branch": source_branch, + "source_path": source_path, + "https_enforced": https_enforced, + } + ) + current_flat = { + "build_type": current.get("build_type"), + "source_branch": current_source.get("branch"), + "source_path": current_source.get("path"), + "https_enforced": current.get("https_enforced"), + } + diff = {key: val for key, val in desired.items() if current_flat[key] != val} + + if not diff: + ret["comment"] = f"Pages for {repo_name} are already configured as specified" + return ret + + ret["changes"]["old"] = {key: current_flat[key] for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Pages configuration for {repo_name} would have been updated" + return ret + update_kwargs = dict(diff) + if "source_branch" in diff or "source_path" in diff: + # The source is submitted as a single object, carry over the + # unchanged half + update_kwargs.setdefault("source_branch", current_flat["source_branch"]) + update_kwargs.setdefault("source_path", current_flat["source_path"]) + try: + __salt__["github.update_pages"](repo_name=repo_name, profile=profile, **update_kwargs) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to update Pages configuration for {repo_name}: {err}" + return ret + ret["comment"] = f"Pages configuration for {repo_name} has been updated" + return ret + + +def pages_absent(name, repo_name=None, profile="github"): + """ + Ensure GitHub Pages are disabled for a repository. + + .. versionadded:: 1.1.0 + + name + The name of the repository, unless ``repo_name`` is passed. + + repo_name + The name of the repository, if it differs from ``name``. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + repo_name = repo_name or name + + try: + __salt__["github.get_pages"](repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Pages are already disabled for {repo_name}" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get Pages configuration: {err}" + return ret + + ret["changes"]["old"] = f"Pages enabled for {repo_name}" + ret["changes"]["new"] = f"Pages disabled for {repo_name}" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Pages would have been disabled for {repo_name}" + return ret + try: + __salt__["github.remove_pages"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to disable Pages for {repo_name}: {err}" + return ret + ret["comment"] = f"Pages have been disabled for {repo_name}" + return ret diff --git a/src/saltext/github/utils/gh/actions.py b/src/saltext/github/utils/gh/actions.py new file mode 100644 index 00000000..47908c85 --- /dev/null +++ b/src/saltext/github/utils/gh/actions.py @@ -0,0 +1,176 @@ +""" +Operations on GitHub Actions repository secrets and settings. +""" + +import base64 +import logging + +from nacl import encoding +from nacl import public + +from saltext.github.utils import filter_unset +from saltext.github.utils.gh import client + +log = logging.getLogger(__name__) + + +def _secrets_path(conn, repo_name, *suffix): + return ["repos", conn.org_name, repo_name, "actions", "secrets", *suffix] + + +def list_secrets(conn, repo_name): + """ + Return all Actions secrets of a repository. Secret values are write-only, + so this only carries metadata (``name``, ``created_at``, ``updated_at``). + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + """ + return client.query(conn, _secrets_path(conn, repo_name), per_page=100)["secrets"] + + +def get_secret(conn, repo_name, name): + """ + Return the metadata of a single Actions secret. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The name of the secret. + + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` + if the secret does not exist. + """ + return client.query(conn, _secrets_path(conn, repo_name, name)) + + +def get_public_key(conn, repo_name): + """ + Return the public key used for encrypting Actions secrets of a repository, + a mapping with ``key_id`` and (base64-encoded) ``key``. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + """ + return client.query(conn, _secrets_path(conn, repo_name, "public-key")) + + +def encrypt_secret(public_key, value): + """ + Encrypt a secret value for upload using a repository public key + (libsodium sealed box). Returns the base64-encoded ciphertext. + + public_key + The base64-encoded public key of the repository, as returned + by :func:`get_public_key` in ``key``. + + value + The secret value to encrypt. + """ + sealed_box = public.SealedBox(public.PublicKey(public_key.encode(), encoding.Base64Encoder)) + return base64.b64encode(sealed_box.encrypt(value.encode())).decode() + + +def set_secret(conn, repo_name, name, value): + """ + Create or update an Actions secret. The value is encrypted to the + repository public key before leaving this host. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The name of the secret. + + value + The plaintext secret value. + """ + pubkey = get_public_key(conn, repo_name) + client.query( + conn, + _secrets_path(conn, repo_name, name), + method="PUT", + data={ + "encrypted_value": encrypt_secret(pubkey["key"], value), + "key_id": pubkey["key_id"], + }, + ) + + +def delete_secret(conn, repo_name, name): + """ + Remove an Actions secret from a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The name of the secret. + """ + client.query(conn, _secrets_path(conn, repo_name, name), method="DELETE") + + +def get_workflow_permissions(conn, repo_name): + """ + Return the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository, a mapping with ``default_workflow_permissions`` + (``read``/``write``) and ``can_approve_pull_request_reviews``. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + """ + return client.query( + conn, ["repos", conn.org_name, repo_name, "actions", "permissions", "workflow"] + ) + + +def set_workflow_permissions( + conn, repo_name, *, default_workflow_permissions=None, can_approve_pull_request_reviews=None +): + """ + Update the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + default_workflow_permissions + The default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Whether GitHub Actions can approve pull requests. + """ + client.query( + conn, + ["repos", conn.org_name, repo_name, "actions", "permissions", "workflow"], + method="PUT", + data=filter_unset( + { + "default_workflow_permissions": default_workflow_permissions, + "can_approve_pull_request_reviews": can_approve_pull_request_reviews, + } + ), + ) diff --git a/src/saltext/github/utils/gh/deploy_keys.py b/src/saltext/github/utils/gh/deploy_keys.py new file mode 100644 index 00000000..e39b8143 --- /dev/null +++ b/src/saltext/github/utils/gh/deploy_keys.py @@ -0,0 +1,90 @@ +""" +Operations on GitHub repository deploy keys. +""" + +import logging + +from saltext.github.utils.gh import client + +log = logging.getLogger(__name__) + + +def _path(conn, repo_name, *suffix): + return ["repos", conn.org_name, repo_name, "keys", *suffix] + + +def list_all(conn, repo_name): + """ + Return all deploy keys of a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + """ + return client.query(conn, _path(conn, repo_name)) + + +def get(conn, repo_name, key_id): + """ + Return the data of a single deploy key. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + key_id + The ID of the deploy key. + + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` + if the deploy key does not exist. + """ + return client.query(conn, _path(conn, repo_name, key_id)) + + +def create(conn, repo_name, title, key, *, read_only=False): + """ + Add a deploy key to a repository. Returns the data of the created key. + + Note that deploy keys are immutable, they can only be replaced. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + title + The display title of the deploy key. + + key + The SSH public key. + + read_only + Deny write access to the repository for this key. Defaults to false. + """ + return client.query( + conn, + _path(conn, repo_name), + method="POST", + data={"title": title, "key": key, "read_only": read_only}, + ) + + +def delete(conn, repo_name, key_id): + """ + Remove a deploy key from a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + key_id + The ID of the deploy key. + """ + client.query(conn, _path(conn, repo_name, key_id), method="DELETE") diff --git a/src/saltext/github/utils/gh/environments.py b/src/saltext/github/utils/gh/environments.py new file mode 100644 index 00000000..7f1e0e95 --- /dev/null +++ b/src/saltext/github/utils/gh/environments.py @@ -0,0 +1,226 @@ +""" +Operations on GitHub repository (deployment) environments. +""" + +import logging +from urllib.parse import quote + +from saltext.github.utils import filter_unset +from saltext.github.utils.gh import client + +log = logging.getLogger(__name__) + +UNSET = ... + + +def _path(conn, repo_name, *suffix): + # Environment names can contain characters that are invalid in URL paths + return ["repos", conn.org_name, repo_name, "environments"] + [ + quote(str(segment), safe="") for segment in suffix + ] + + +def normalize(env): + """ + Extract the managed settings from an environment API payload into a + flat mapping with ``wait_timer``, ``prevent_self_review``, ``reviewers`` + and ``deployment_branch_policy``. The API scatters them over + ``protection_rules`` entries. + + env + The environment payload to normalize. + """ + ret = { + "wait_timer": 0, + "prevent_self_review": False, + "reviewers": [], + "deployment_branch_policy": env.get("deployment_branch_policy"), + } + for rule in env.get("protection_rules") or []: + if rule.get("type") == "wait_timer": + ret["wait_timer"] = rule.get("wait_timer", 0) + elif rule.get("type") == "required_reviewers": + ret["prevent_self_review"] = rule.get("prevent_self_review", False) + ret["reviewers"] = [ + {"type": reviewer["type"], "id": reviewer["reviewer"]["id"]} + for reviewer in rule.get("reviewers") or [] + ] + return ret + + +def list_all(conn, repo_name): + """ + Return all environments of a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + """ + return client.query(conn, _path(conn, repo_name), per_page=100)["environments"] + + +def get(conn, repo_name, name): + """ + Return the data of a single environment. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The name of the environment. + + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` + if the environment does not exist. + """ + return client.query(conn, _path(conn, repo_name, name)) + + +def set_( + conn, + repo_name, + name, + *, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branch_policy=UNSET, +): + """ + Create or update an environment. Returns the data of the environment. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The name of the environment. + + wait_timer + The amount of time to delay a job after the job is initially + triggered, in minutes. + + prevent_self_review + Whether or not a user who created the job is prevented from + approving their own job. + + reviewers + The people or teams that may review jobs that reference the + environment, as a list of mappings with ``type`` (``User`` or + ``Team``) and ``id``. + + deployment_branch_policy + The type of deployment branch policy for this environment, either + ``None`` to allow all branches or a mapping with + ``protected_branches`` and ``custom_branch_policies`` booleans. + Branch name patterns for custom policies are managed separately, + see :func:`create_branch_policy`. + """ + data = filter_unset( + { + "wait_timer": wait_timer, + "prevent_self_review": prevent_self_review, + "reviewers": reviewers, + } + ) + if deployment_branch_policy is not UNSET: + data["deployment_branch_policy"] = deployment_branch_policy + return client.query(conn, _path(conn, repo_name, name), method="PUT", data=data) + + +def delete(conn, repo_name, name): + """ + Delete an environment from a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The name of the environment. + """ + client.query(conn, _path(conn, repo_name, name), method="DELETE") + + +def list_branch_policies(conn, repo_name, environment): + """ + Return all deployment branch policies of an environment. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + environment + The name of the environment. + """ + return client.query( + conn, + _path(conn, repo_name, environment, "deployment-branch-policies"), + per_page=100, + )["branch_policies"] + + +def create_branch_policy(conn, repo_name, environment, name, *, type_="branch"): + r""" + Add a deployment branch (or tag) policy to an environment. + Returns the data of the created policy. + + The environment's ``deployment_branch_policy`` must have + ``custom_branch_policies`` enabled. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + environment + The name of the environment. + + name + The name pattern that branches (or tags) must match in order + to deploy to the environment. + + type\_ + Whether this rule targets a ``branch`` or ``tag``. + Defaults to ``branch``. + """ + return client.query( + conn, + _path(conn, repo_name, environment, "deployment-branch-policies"), + method="POST", + data={"name": name, "type": type_}, + ) + + +def delete_branch_policy(conn, repo_name, environment, policy_id): + """ + Remove a deployment branch policy from an environment. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + environment + The name of the environment. + + policy_id + The ID of the branch policy. + """ + client.query( + conn, + _path(conn, repo_name, environment, "deployment-branch-policies", policy_id), + method="DELETE", + ) diff --git a/src/saltext/github/utils/gh/labels.py b/src/saltext/github/utils/gh/labels.py new file mode 100644 index 00000000..eb47f3eb --- /dev/null +++ b/src/saltext/github/utils/gh/labels.py @@ -0,0 +1,130 @@ +""" +Operations on GitHub repository (issue) labels. +""" + +import logging +from urllib.parse import quote + +from saltext.github.utils import filter_unset +from saltext.github.utils.gh import client + +log = logging.getLogger(__name__) + + +def _path(conn, repo_name, *suffix): + # Label names can contain characters that are invalid in URL paths + return ["repos", conn.org_name, repo_name, "labels"] + [ + quote(str(segment), safe="") for segment in suffix + ] + + +def normalize_color(color): + """ + Normalize a label color for comparisons: lowercase, no leading ``#``. + + color + The hexadecimal color code to normalize. + """ + if color is None: + return None + return color.lstrip("#").lower() + + +def list_all(conn, repo_name): + """ + Return all labels of a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + """ + return client.query(conn, _path(conn, repo_name)) + + +def get(conn, repo_name, name): + """ + Return the data of a single label. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The name of the label. + + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` + if the label does not exist. + """ + return client.query(conn, _path(conn, repo_name, name)) + + +def create(conn, repo_name, name, *, color=None, description=None): + """ + Create a label in a repository. Returns the data of the created label. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The name of the label. + + color + The hexadecimal color code for the label, without the leading ``#``. + If unset, GitHub assigns a random one. + + description + A short description of the label. + """ + data = filter_unset({"name": name, "color": normalize_color(color), "description": description}) + return client.query(conn, _path(conn, repo_name), method="POST", data=data) + + +def update(conn, repo_name, name, *, new_name=None, color=None, description=None): + """ + Update an existing label. Returns the data of the updated label. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The (current) name of the label. + + new_name + A new name for the label. + + color + The hexadecimal color code for the label, without the leading ``#``. + + description + A short description of the label. + """ + data = filter_unset( + {"new_name": new_name, "color": normalize_color(color), "description": description} + ) + return client.query(conn, _path(conn, repo_name, name), method="PATCH", data=data) + + +def delete(conn, repo_name, name): + """ + Delete a label from a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + name + The name of the label. + """ + client.query(conn, _path(conn, repo_name, name), method="DELETE") diff --git a/src/saltext/github/utils/gh/pages.py b/src/saltext/github/utils/gh/pages.py new file mode 100644 index 00000000..ae7933fd --- /dev/null +++ b/src/saltext/github/utils/gh/pages.py @@ -0,0 +1,118 @@ +""" +Operations on GitHub Pages sites of repositories. +""" + +import logging + +from saltext.github.utils import filter_unset +from saltext.github.utils.gh import client + +log = logging.getLogger(__name__) + + +def _path(conn, repo_name): + return ["repos", conn.org_name, repo_name, "pages"] + + +def build_source(branch=None, path=None): + """ + Build a Pages ``source`` payload from branch and path, or ``None`` + when both are unset. + + branch + The repository branch to publish from. + + path + The repository directory to publish from, either ``/`` or ``/docs``. + """ + source = filter_unset({"branch": branch, "path": path}) + return source or None + + +def get(conn, repo_name): + """ + Return information about the GitHub Pages site of a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` + if the repository has no Pages site. + """ + return client.query(conn, _path(conn, repo_name)) + + +def create(conn, repo_name, *, build_type=None, source=None): + """ + Configure a GitHub Pages site for a repository. + Returns the data of the created site. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + build_type + The process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source + For ``legacy`` builds, the deployment source as a mapping with + ``branch`` and ``path``. + """ + return client.query( + conn, + _path(conn, repo_name), + method="POST", + data=filter_unset({"build_type": build_type, "source": source}), + ) + + +def update(conn, repo_name, *, build_type=None, source=None, cname=..., https_enforced=None): + """ + Update the GitHub Pages site configuration of a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + + build_type + The process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source + For ``legacy`` builds, the deployment source as a mapping with + ``branch`` and ``path``. + + cname + The custom domain for the site. Pass ``None`` explicitly + to remove it. + + https_enforced + Whether HTTPS is enforced for the site. + """ + data = filter_unset( + {"build_type": build_type, "source": source, "https_enforced": https_enforced} + ) + if cname is not ...: + data["cname"] = cname + client.query(conn, _path(conn, repo_name), method="PUT", data=data) + + +def delete(conn, repo_name): + """ + Delete the GitHub Pages site of a repository. + + conn + The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + The name of the repository. + """ + client.query(conn, _path(conn, repo_name), method="DELETE") diff --git a/src/saltext/github/utils/gh/repos.py b/src/saltext/github/utils/gh/repos.py index d44a2b5a..157ebd5d 100644 --- a/src/saltext/github/utils/gh/repos.py +++ b/src/saltext/github/utils/gh/repos.py @@ -181,6 +181,7 @@ def update( has_issues=None, has_wiki=None, has_downloads=None, + default_branch=None, allow_privacy_change=False, ): """ @@ -213,6 +214,7 @@ def update( "has_issues": has_issues, "has_wiki": has_wiki, "has_downloads": has_downloads, + "default_branch": default_branch, } ) repo.edit(name, **parameters) diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 8c07249d..62c1c4fa 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -4,6 +4,7 @@ import pytest from saltfactories.utils.functional import Loaders +from tests.support.github import DEPLOY_KEY_A from tests.support.github import FakeGitHubAPI log = logging.getLogger(__name__) @@ -212,7 +213,7 @@ def fake_setup(fake_state): org.add_member(user1) org.add_member(user2) repo_a = org.add_repo("repo-a") - org.add_repo("repo-b", private=True) + repo_b = org.add_repo("repo-b", private=True) org.add_ruleset("org-rules") team_a = org.add_team("Team A") repo_a.add_ruleset("repo-rules", enforcement="disabled") @@ -220,6 +221,15 @@ def fake_setup(fake_state): team_a.add_repo(repo_a, "pull") repo_a.add_milestone("v1.0", user1) repo_a.add_milestone("v2.0", user1) + repo_a.add_deploy_key("existing-key", DEPLOY_KEY_A, read_only=True) + repo_a.add_secret("EXISTING_SECRET", "hunter2") + release = repo_a.add_environment( + "release", + deployment_branch_policy={"protected_branches": False, "custom_branch_policies": True}, + ) + release.add_branch_policy("main") + repo_a.add_label("bug", color="d73a4a", description="Something is broken") + repo_b.set_pages(build_type="legacy", source={"branch": "gh-pages", "path": "/"}) bug = repo_a.add_issue( "Found a bug", user1, labels=["bug", "prio:high"], assignee="user2", milestone="v1.0" ) diff --git a/tests/functional/modules/github/test_actions_secrets.py b/tests/functional/modules/github/test_actions_secrets.py new file mode 100644 index 00000000..0c8d40cd --- /dev/null +++ b/tests/functional/modules/github/test_actions_secrets.py @@ -0,0 +1,50 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_actions_secrets(github): + ret = github.list_actions_secrets() + assert [secret["name"] for secret in ret] == ["EXISTING_SECRET"] + # Secret values are write-only + assert "value" not in ret[0] + + +def test_get_actions_secret(github): + # Secret names are case-insensitive + ret = github.get_actions_secret("existing_secret") + assert ret["name"] == "EXISTING_SECRET" + assert "value" not in ret + + +def test_get_actions_secret_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.get_actions_secret("GHOST") + + +def test_set_actions_secret_new(github, fake_org): + assert github.set_actions_secret("NEW_SECRET", "s3cr3t") is True + # The fake decrypts the submitted sealed box, proving the + # encryption roundtrip end-to-end + assert fake_org.get_repo("repo-a").get_secret("NEW_SECRET").value == "s3cr3t" + + +def test_set_actions_secret_update(github, fake_org): + assert github.set_actions_secret("EXISTING_SECRET", "rotated") is True + assert fake_org.get_repo("repo-a").get_secret("EXISTING_SECRET").value == "rotated" + + +def test_set_actions_secret_encrypted_in_transit(github, fake_github): + github.set_actions_secret("NEW_SECRET", "s3cr3t") + puts = fake_github.filter_requests(method="PUT", path="*/actions/secrets/NEW_SECRET") + assert puts + assert "s3cr3t" not in str(puts[0].body) + + +def test_remove_actions_secret(github, fake_org): + assert github.remove_actions_secret("EXISTING_SECRET") is True + assert "EXISTING_SECRET" not in fake_org.get_repo("repo-a").secrets + + +def test_remove_actions_secret_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.remove_actions_secret("GHOST") diff --git a/tests/functional/modules/github/test_deploy_keys.py b/tests/functional/modules/github/test_deploy_keys.py new file mode 100644 index 00000000..5b49606c --- /dev/null +++ b/tests/functional/modules/github/test_deploy_keys.py @@ -0,0 +1,55 @@ +import pytest +from salt.exceptions import CommandExecutionError + +from tests.support.github import DEPLOY_KEY_A +from tests.support.github import DEPLOY_KEY_B + + +def test_list_deploy_keys(github): + ret = github.list_deploy_keys() + assert [key["title"] for key in ret] == ["existing-key"] + assert ret[0]["key"] == DEPLOY_KEY_A + assert ret[0]["read_only"] is True + + +def test_list_deploy_keys_other_repo(github): + assert github.list_deploy_keys(repo_name="repo-b") == [] + + +def test_get_deploy_key(github, fake_org): + existing = next(iter(fake_org.get_repo("repo-a").deploy_keys)) + ret = github.get_deploy_key(existing.id) + assert ret["title"] == "existing-key" + assert ret["key"] == DEPLOY_KEY_A + + +def test_get_deploy_key_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.get_deploy_key(1234) + + +def test_add_deploy_key(github, fake_org): + ret = github.add_deploy_key("autorelease", DEPLOY_KEY_B) + assert ret["title"] == "autorelease" + assert ret["read_only"] is False + repo = fake_org.get_repo("repo-a") + added = next(key for key in repo.deploy_keys if key.title == "autorelease") + assert added.key == DEPLOY_KEY_B + + +def test_add_deploy_key_read_only(github, fake_org): + github.add_deploy_key("autorelease", DEPLOY_KEY_B, read_only=True) + repo = fake_org.get_repo("repo-a") + added = next(key for key in repo.deploy_keys if key.title == "autorelease") + assert added.read_only is True + + +def test_remove_deploy_key(github, fake_org): + existing = next(iter(fake_org.get_repo("repo-a").deploy_keys)) + assert github.remove_deploy_key(existing.id) is True + assert len(fake_org.get_repo("repo-a").deploy_keys) == 0 + + +def test_remove_deploy_key_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.remove_deploy_key(1234) diff --git a/tests/functional/modules/github/test_environments.py b/tests/functional/modules/github/test_environments.py new file mode 100644 index 00000000..85d8a4ac --- /dev/null +++ b/tests/functional/modules/github/test_environments.py @@ -0,0 +1,77 @@ +import pytest +from salt.exceptions import CommandExecutionError + +CUSTOM_POLICY = {"protected_branches": False, "custom_branch_policies": True} + + +def test_list_environments(github): + ret = github.list_environments() + assert [env["name"] for env in ret] == ["release"] + + +def test_get_environment(github): + ret = github.get_environment("release") + assert ret["name"] == "release" + assert ret["deployment_branch_policy"] == CUSTOM_POLICY + + +def test_get_environment_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.get_environment("ghost") + + +def test_set_environment_create(github, fake_org): + ret = github.set_environment("staging", wait_timer=5) + assert ret["name"] == "staging" + env = fake_org.get_repo("repo-a").get_environment("staging") + assert env.wait_timer == 5 + assert env.deployment_branch_policy is None + + +def test_set_environment_carries_over_policy(github, fake_org): + # A PUT replaces the whole config, the wrapper is expected to carry + # over the current branch policy when it is not specified. + github.set_environment("release", wait_timer=10) + env = fake_org.get_repo("repo-a").get_environment("release") + assert env.wait_timer == 10 + assert env.deployment_branch_policy == CUSTOM_POLICY + + +def test_set_environment_reset_policy(github, fake_org): + github.set_environment("release", deployment_branch_policy=None) + env = fake_org.get_repo("repo-a").get_environment("release") + assert env.deployment_branch_policy is None + + +def test_set_environment_reviewers(github, fake_org): + github.set_environment("release", reviewers=[{"type": "User", "id": 42}]) + env = fake_org.get_repo("repo-a").get_environment("release") + assert env.reviewers == [{"type": "User", "id": 42}] + + +def test_remove_environment(github, fake_org): + assert github.remove_environment("release") is True + assert "release" not in fake_org.get_repo("repo-a").environments + + +def test_remove_environment_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.remove_environment("ghost") + + +def test_list_deployment_branch_policies(github): + ret = github.list_deployment_branch_policies("release") + assert [policy["name"] for policy in ret] == ["main"] + + +def test_add_deployment_branch_policy(github, fake_org): + ret = github.add_deployment_branch_policy("release", "release/*") + assert ret["name"] == "release/*" + env = fake_org.get_repo("repo-a").get_environment("release") + assert {policy.name for policy in env.branch_policies} == {"main", "release/*"} + + +def test_remove_deployment_branch_policy(github, fake_org): + existing = next(iter(fake_org.get_repo("repo-a").get_environment("release").branch_policies)) + assert github.remove_deployment_branch_policy("release", existing.id) is True + assert len(fake_org.get_repo("repo-a").get_environment("release").branch_policies) == 0 diff --git a/tests/functional/modules/github/test_labels.py b/tests/functional/modules/github/test_labels.py new file mode 100644 index 00000000..efd42ca1 --- /dev/null +++ b/tests/functional/modules/github/test_labels.py @@ -0,0 +1,58 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_labels(github): + ret = github.list_labels() + assert [label["name"] for label in ret] == ["bug"] + + +def test_get_label(github): + ret = github.get_label("bug") + assert ret["color"] == "d73a4a" + assert ret["description"] == "Something is broken" + + +def test_get_label_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.get_label("ghost") + + +def test_get_label_special_chars(github, fake_org): + # Label names are URL-quoted on the wire + fake_org.get_repo("repo-a").add_label("prio: high", color="ff0000") + ret = github.get_label("prio: high") + assert ret["name"] == "prio: high" + + +def test_add_label(github, fake_org): + ret = github.add_label("release", color="#00FF00", description="Release automation") + assert ret["name"] == "release" + label = fake_org.get_repo("repo-a").get_label("release") + # The color is normalized before submission + assert label.color == "00ff00" + assert label.description == "Release automation" + + +def test_edit_label(github, fake_org): + github.edit_label("bug", color="ff0000") + label = fake_org.get_repo("repo-a").get_label("bug") + assert label.color == "ff0000" + assert label.description == "Something is broken" + + +def test_edit_label_rename(github, fake_org): + github.edit_label("bug", new_name="defect") + repo = fake_org.get_repo("repo-a") + assert "defect" in repo.labels + assert "bug" not in repo.labels + + +def test_remove_label(github, fake_org): + assert github.remove_label("bug") is True + assert "bug" not in fake_org.get_repo("repo-a").labels + + +def test_remove_label_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.remove_label("ghost") diff --git a/tests/functional/modules/github/test_pages.py b/tests/functional/modules/github/test_pages.py new file mode 100644 index 00000000..11314f49 --- /dev/null +++ b/tests/functional/modules/github/test_pages.py @@ -0,0 +1,53 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_get_pages(github): + ret = github.get_pages(repo_name="repo-b") + assert ret["build_type"] == "legacy" + assert ret["source"] == {"branch": "gh-pages", "path": "/"} + + +def test_get_pages_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.get_pages() # repo-a has no Pages site + + +def test_setup_pages(github, fake_org): + ret = github.setup_pages(build_type="workflow") + assert ret["build_type"] == "workflow" + assert fake_org.get_repo("repo-a").pages.build_type == "workflow" + + +def test_setup_pages_legacy_source(github, fake_org): + github.setup_pages(build_type="legacy", source_branch="docs", source_path="/docs") + pages = fake_org.get_repo("repo-a").pages + assert pages.source == {"branch": "docs", "path": "/docs"} + + +def test_update_pages(github, fake_org): + assert github.update_pages(build_type="workflow", repo_name="repo-b") is True + pages = fake_org.get_repo("repo-b").pages + assert pages.build_type == "workflow" + # Unspecified settings are left untouched + assert pages.source == {"branch": "gh-pages", "path": "/"} + + +def test_update_pages_https(github, fake_org): + assert github.update_pages(https_enforced=True, repo_name="repo-b") is True + assert fake_org.get_repo("repo-b").pages.https_enforced is True + + +def test_update_pages_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.update_pages(build_type="workflow") + + +def test_remove_pages(github, fake_org): + assert github.remove_pages(repo_name="repo-b") is True + assert fake_org.get_repo("repo-b").pages is None + + +def test_remove_pages_missing(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.remove_pages() diff --git a/tests/functional/modules/github/test_repos.py b/tests/functional/modules/github/test_repos.py index df5a91e9..109aba5f 100644 --- a/tests/functional/modules/github/test_repos.py +++ b/tests/functional/modules/github/test_repos.py @@ -64,6 +64,11 @@ def test_edit_repo(github, fake_org): assert github.get_repo_info("repo-a")["description"] == "Updated" +def test_edit_repo_default_branch(github, fake_org): + assert github.edit_repo("repo-a", default_branch="develop") is True + assert fake_org.get_repo("repo-a").default_branch == "develop" + + def test_edit_repo_privacy_guard(github): with pytest.raises(CommandExecutionError, match="privacy"): github.edit_repo("repo-a", private=True, profile="github_noprivacy") diff --git a/tests/functional/modules/github/test_workflow_permissions.py b/tests/functional/modules/github/test_workflow_permissions.py new file mode 100644 index 00000000..9129f29a --- /dev/null +++ b/tests/functional/modules/github/test_workflow_permissions.py @@ -0,0 +1,26 @@ +def test_get_workflow_permissions(github): + ret = github.get_workflow_permissions() + assert ret == { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": False, + } + + +def test_set_workflow_permissions(github, fake_org): + assert ( + github.set_workflow_permissions( + default_workflow_permissions="read", can_approve_pull_request_reviews=True + ) + is True + ) + repo = fake_org.get_repo("repo-a") + assert repo.data.default_workflow_permissions == "read" + assert repo.data.can_approve_pull_request_reviews is True + + +def test_set_workflow_permissions_partial(github, fake_org): + assert github.set_workflow_permissions(can_approve_pull_request_reviews=True) is True + repo = fake_org.get_repo("repo-a") + # The other setting is left untouched + assert repo.data.default_workflow_permissions == "write" + assert repo.data.can_approve_pull_request_reviews is True diff --git a/tests/functional/states/github/test_actions_secret.py b/tests/functional/states/github/test_actions_secret.py new file mode 100644 index 00000000..e20290c6 --- /dev/null +++ b/tests/functional/states/github/test_actions_secret.py @@ -0,0 +1,75 @@ +def test_actions_secret_present_create(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.actions_secret_present(name="NEW_SECRET", value="s3cr3t", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes + # The value must never leak into the state output + assert "s3cr3t" not in str(ret.changes) + ret.comment + assert (len(fake_github.mutations()) == before) is testmode + repo = fake_org.get_repo("repo-a") + if testmode: + assert "NEW_SECRET" not in repo.secrets + else: + assert repo.get_secret("NEW_SECRET").value == "s3cr3t" + rerun = github.actions_secret_present(name="NEW_SECRET", value="s3cr3t") + assert rerun.result is True + assert not rerun.changes + + +def test_actions_secret_present_existing_untouched(github, fake_github, fake_org, testmode): + # Secret values are write-only, so an existing secret counts as present + ret = github.actions_secret_present(name="existing_secret", value="other", test=testmode) + assert ret.result is True + assert not ret.changes + assert "write-only" in ret.comment + assert not fake_github.mutations() + assert fake_org.get_repo("repo-a").get_secret("EXISTING_SECRET").value == "hunter2" + + +def test_actions_secret_present_force(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.actions_secret_present( + name="EXISTING_SECRET", value="rotated", force=True, test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes + assert "rotated" not in str(ret.changes) + ret.comment + assert (len(fake_github.mutations()) == before) is testmode + expected = "hunter2" if testmode else "rotated" + assert fake_org.get_repo("repo-a").get_secret("EXISTING_SECRET").value == expected + + +def test_actions_secret_present_requires_value(github, fake_github): + ret = github.actions_secret_present(name="ANOTHER_SECRET") + assert ret.result is False + assert "value is required" in ret.comment + assert not fake_github.mutations() + + +def test_actions_secret_present_missing_repo(github): + ret = github.actions_secret_present(name="NEW_SECRET", value="s3cr3t", repo_name="ghost") + assert ret.result is False + assert "Failed to list Actions secrets" in ret.comment + + +def test_actions_secret_absent(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.actions_secret_absent(name="existing_secret", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes + assert (len(fake_github.mutations()) == before) is testmode + assert ("EXISTING_SECRET" in fake_org.get_repo("repo-a").secrets) is testmode + if not testmode: + rerun = github.actions_secret_absent(name="existing_secret") + assert rerun.result is True + assert not rerun.changes + + +def test_actions_secret_absent_already(github, fake_github, testmode): + ret = github.actions_secret_absent(name="GHOST", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() diff --git a/tests/functional/states/github/test_deploy_key.py b/tests/functional/states/github/test_deploy_key.py new file mode 100644 index 00000000..521eb41a --- /dev/null +++ b/tests/functional/states/github/test_deploy_key.py @@ -0,0 +1,93 @@ +from tests.support.github import DEPLOY_KEY_A +from tests.support.github import DEPLOY_KEY_B + + +def test_deploy_key_present_create(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.deploy_key_present(name="autorelease", key=DEPLOY_KEY_B, test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["old"] is None + assert (len(fake_github.mutations()) == before) is testmode + repo = fake_org.get_repo("repo-a") + added = [key for key in repo.deploy_keys if key.title == "autorelease"] + assert bool(added) is not testmode + if not testmode: + assert added[0].key == DEPLOY_KEY_B + + +def test_deploy_key_present_idempotent(github, fake_github, testmode): + ret = github.deploy_key_present( + name="existing-key", key=DEPLOY_KEY_A, read_only=True, test=testmode + ) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_deploy_key_present_ignores_key_comment(github, fake_github, testmode): + key = " ".join(DEPLOY_KEY_A.split()[:2]) + " another-comment" + ret = github.deploy_key_present(name="existing-key", key=key, read_only=True, test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_deploy_key_present_replaces_changed_key(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.deploy_key_present( + name="existing-key", key=DEPLOY_KEY_B, read_only=True, test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["old"]["key"] == DEPLOY_KEY_A + assert (len(fake_github.mutations()) == before) is testmode + current = next( + key for key in fake_org.get_repo("repo-a").deploy_keys if key.title == "existing-key" + ) + expected = DEPLOY_KEY_A if testmode else DEPLOY_KEY_B + assert current.key == expected + if not testmode: + rerun = github.deploy_key_present(name="existing-key", key=DEPLOY_KEY_B, read_only=True) + assert rerun.result is True + assert not rerun.changes + + +def test_deploy_key_present_replaces_changed_read_only(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.deploy_key_present(name="existing-key", key=DEPLOY_KEY_A, test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["new"]["read_only"] is False + assert (len(fake_github.mutations()) == before) is testmode + current = next( + key for key in fake_org.get_repo("repo-a").deploy_keys if key.title == "existing-key" + ) + assert current.read_only is testmode + + +def test_deploy_key_present_missing_repo(github): + ret = github.deploy_key_present(name="autorelease", key=DEPLOY_KEY_B, repo_name="ghost") + assert ret.result is False + assert "Failed to list deploy keys" in ret.comment + + +def test_deploy_key_absent(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.deploy_key_absent(name="existing-key", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes + assert (len(fake_github.mutations()) == before) is testmode + assert bool(fake_org.get_repo("repo-a").deploy_keys) is testmode + if not testmode: + rerun = github.deploy_key_absent(name="existing-key") + assert rerun.result is True + assert not rerun.changes + + +def test_deploy_key_absent_already(github, fake_github, testmode): + ret = github.deploy_key_absent(name="ghost", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() diff --git a/tests/functional/states/github/test_environment.py b/tests/functional/states/github/test_environment.py new file mode 100644 index 00000000..750ba71e --- /dev/null +++ b/tests/functional/states/github/test_environment.py @@ -0,0 +1,142 @@ +CUSTOM_POLICY = {"protected_branches": False, "custom_branch_policies": True} +PROTECTED_POLICY = {"protected_branches": True, "custom_branch_policies": False} + + +def test_environment_present_create(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.environment_present(name="staging", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "environment" in ret.changes + assert (len(fake_github.mutations()) == before) is testmode + assert ("staging" in fake_org.get_repo("repo-a").environments) is not testmode + + +def test_environment_present_create_with_branches(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.environment_present( + name="staging", deployment_branches=["main", "staging/*"], test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "environment" in ret.changes + assert ret.changes["deployment_branches"]["new"] == ["main", "staging/*"] + assert (len(fake_github.mutations()) == before) is testmode + if testmode: + assert "staging" not in fake_org.get_repo("repo-a").environments + else: + env = fake_org.get_repo("repo-a").get_environment("staging") + assert env.deployment_branch_policy == CUSTOM_POLICY + assert {policy.name for policy in env.branch_policies} == {"main", "staging/*"} + rerun = github.environment_present( + name="staging", deployment_branches=["main", "staging/*"] + ) + assert rerun.result is True + assert not rerun.changes + + +def test_environment_present_idempotent(github, fake_github, testmode): + ret = github.environment_present(name="release", deployment_branches=["main"], test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_environment_present_branch_pattern_drift(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.environment_present( + name="release", deployment_branches=["main", "hotfix/*"], test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"deployment_branches": {"old": ["main"], "new": ["hotfix/*", "main"]}} + assert (len(fake_github.mutations()) == before) is testmode + env = fake_org.get_repo("repo-a").get_environment("release") + expected = {"main"} if testmode else {"main", "hotfix/*"} + assert {policy.name for policy in env.branch_policies} == expected + + +def test_environment_present_removes_stale_patterns(github, fake_org, testmode): + ret = github.environment_present(name="release", deployment_branches=["develop"], test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "deployment_branches" in ret.changes + env = fake_org.get_repo("repo-a").get_environment("release") + expected = {"main"} if testmode else {"develop"} + assert {policy.name for policy in env.branch_policies} == expected + + +def test_environment_present_wait_timer_drift_keeps_policy(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.environment_present(name="release", wait_timer=30, test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"wait_timer": {"old": 0, "new": 30}} + assert (len(fake_github.mutations()) == before) is testmode + env = fake_org.get_repo("repo-a").get_environment("release") + if not testmode: + assert env.wait_timer == 30 + # The unmanaged branch policy is carried over, not reset + assert env.deployment_branch_policy == CUSTOM_POLICY + assert {policy.name for policy in env.branch_policies} == {"main"} + + +def test_environment_present_protected_branches(github, fake_org, testmode): + ret = github.environment_present(name="release", deployment_branches="protected", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["deployment_branch_policy"]["new"] == PROTECTED_POLICY + env = fake_org.get_repo("repo-a").get_environment("release") + expected = CUSTOM_POLICY if testmode else PROTECTED_POLICY + assert env.deployment_branch_policy == expected + + +def test_environment_present_all_branches(github, fake_org, testmode): + ret = github.environment_present(name="release", deployment_branches="all", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["deployment_branch_policy"]["new"] is None + env = fake_org.get_repo("repo-a").get_environment("release") + expected = CUSTOM_POLICY if testmode else None + assert env.deployment_branch_policy == expected + + +def test_environment_present_reviewers(github, fake_org, testmode): + reviewers = [{"type": "User", "id": 42}] + ret = github.environment_present(name="release", reviewers=reviewers, test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["reviewers"]["new"] == reviewers + if not testmode: + assert fake_org.get_repo("repo-a").get_environment("release").reviewers == reviewers + rerun = github.environment_present(name="release", reviewers=reviewers) + assert rerun.result is True + assert not rerun.changes + + +def test_environment_present_invalid_branches(github, fake_github): + ret = github.environment_present(name="release", deployment_branches=42) + assert ret.result is False + assert "deployment_branches" in ret.comment + assert not fake_github.mutations() + + +def test_environment_absent(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.environment_absent(name="release", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes + assert (len(fake_github.mutations()) == before) is testmode + assert ("release" in fake_org.get_repo("repo-a").environments) is testmode + if not testmode: + rerun = github.environment_absent(name="release") + assert rerun.result is True + assert not rerun.changes + + +def test_environment_absent_already(github, fake_github, testmode): + ret = github.environment_absent(name="ghost", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() diff --git a/tests/functional/states/github/test_label.py b/tests/functional/states/github/test_label.py new file mode 100644 index 00000000..3e937f9f --- /dev/null +++ b/tests/functional/states/github/test_label.py @@ -0,0 +1,91 @@ +def test_label_present_create(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.label_present( + name="release", color="#00FF00", description="Release automation", test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["old"] is None + assert (len(fake_github.mutations()) == before) is testmode + repo = fake_org.get_repo("repo-a") + if testmode: + assert "release" not in repo.labels + else: + label = repo.get_label("release") + assert label.color == "00ff00" + assert label.description == "Release automation" + rerun = github.label_present( + name="release", color="#00FF00", description="Release automation" + ) + assert rerun.result is True + assert not rerun.changes + + +def test_label_present_idempotent(github, fake_github, testmode): + # Color comparison ignores case and a leading # + ret = github.label_present( + name="bug", color="#D73A4A", description="Something is broken", test=testmode + ) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_label_present_unmanaged_settings(github, fake_github, testmode): + ret = github.label_present(name="bug", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_label_present_color_drift(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.label_present(name="bug", color="ff0000", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"old": {"color": "d73a4a"}, "new": {"color": "ff0000"}} + assert (len(fake_github.mutations()) == before) is testmode + label = fake_org.get_repo("repo-a").get_label("bug") + expected = "d73a4a" if testmode else "ff0000" + assert label.color == expected + # Unmanaged settings are left untouched + assert label.description == "Something is broken" + + +def test_label_present_description_drift(github, fake_org, testmode): + ret = github.label_present(name="bug", description="Defect", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["new"] == {"description": "Defect"} + label = fake_org.get_repo("repo-a").get_label("bug") + expected = "Something is broken" if testmode else "Defect" + assert label.description == expected + + +def test_label_present_missing_repo(github): + # A missing repo is indistinguishable from a missing label (404), + # so the failure surfaces during the creation attempt + ret = github.label_present(name="bug", repo_name="ghost") + assert ret.result is False + assert "Failed to create label" in ret.comment + + +def test_label_absent(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.label_absent(name="bug", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes + assert (len(fake_github.mutations()) == before) is testmode + assert ("bug" in fake_org.get_repo("repo-a").labels) is testmode + if not testmode: + rerun = github.label_absent(name="bug") + assert rerun.result is True + assert not rerun.changes + + +def test_label_absent_already(github, fake_github, testmode): + ret = github.label_absent(name="ghost", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() diff --git a/tests/functional/states/github/test_pages.py b/tests/functional/states/github/test_pages.py new file mode 100644 index 00000000..2345d5c6 --- /dev/null +++ b/tests/functional/states/github/test_pages.py @@ -0,0 +1,85 @@ +def test_pages_present_enable(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.pages_present(name="repo-a", build_type="workflow", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["old"] is None + assert (len(fake_github.mutations()) == before) is testmode + pages = fake_org.get_repo("repo-a").pages + if testmode: + assert pages is None + else: + assert pages.build_type == "workflow" + rerun = github.pages_present(name="repo-a", build_type="workflow") + assert rerun.result is True + assert not rerun.changes + + +def test_pages_present_enable_with_https(github, fake_org, testmode): + ret = github.pages_present( + name="repo-a", build_type="workflow", https_enforced=True, test=testmode + ) + assert ret.result is not False + if not testmode: + assert fake_org.get_repo("repo-a").pages.https_enforced is True + + +def test_pages_present_idempotent(github, fake_github, testmode): + ret = github.pages_present( + name="repo-b", build_type="legacy", source_branch="gh-pages", source_path="/", test=testmode + ) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_pages_present_build_type_drift(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.pages_present(name="repo-b", build_type="workflow", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == { + "old": {"build_type": "legacy"}, + "new": {"build_type": "workflow"}, + } + assert (len(fake_github.mutations()) == before) is testmode + expected = "legacy" if testmode else "workflow" + assert fake_org.get_repo("repo-b").pages.build_type == expected + + +def test_pages_present_source_drift(github, fake_org, testmode): + ret = github.pages_present(name="repo-b", source_branch="docs", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["new"] == {"source_branch": "docs"} + expected = "gh-pages" if testmode else "docs" + assert fake_org.get_repo("repo-b").pages.source["branch"] == expected + + +def test_pages_present_missing_repo(github): + # A missing repo is indistinguishable from a missing Pages site (404), + # so the failure surfaces during the creation attempt + ret = github.pages_present(name="ghost") + assert ret.result is False + assert "Failed to enable Pages" in ret.comment + + +def test_pages_absent(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.pages_absent(name="repo-b", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes + assert (len(fake_github.mutations()) == before) is testmode + assert (fake_org.get_repo("repo-b").pages is not None) is testmode + if not testmode: + rerun = github.pages_absent(name="repo-b") + assert rerun.result is True + assert not rerun.changes + + +def test_pages_absent_already(github, fake_github, testmode): + ret = github.pages_absent(name="repo-a", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() diff --git a/tests/functional/states/github/test_repo.py b/tests/functional/states/github/test_repo.py index d5670cea..52a36e8c 100644 --- a/tests/functional/states/github/test_repo.py +++ b/tests/functional/states/github/test_repo.py @@ -102,6 +102,35 @@ def test_repo_present_team_removal(github, fake_github, fake_org, testmode): assert ("repo-a" in fake_org.get_team("team-a").repos) is testmode +def test_repo_present_default_branch(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.repo_present(name="repo-a", default_branch="develop", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "repo" in ret.changes + assert (len(fake_github.mutations()) == before) is testmode + expected = "main" if testmode else "develop" + assert fake_org.get_repo("repo-a").default_branch == expected + + +def test_repo_present_default_branch_idempotent(github, fake_github, testmode): + ret = github.repo_present(name="repo-a", default_branch="main", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_repo_present_create_ignores_default_branch(github, fake_org, _instant_sleep, testmode): + # The default branch can only be switched to an existing branch, + # so it is not applied on repository creation + ret = github.repo_present(name="new-repo", default_branch="develop", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "repo" in ret.changes + if not testmode: + assert fake_org.get_repo("new-repo").default_branch == "main" + + def test_repo_present_missing_team(github): ret = github.repo_present(name="repo-b", teams={"Ghost": "pull"}) assert ret.result is False diff --git a/tests/functional/states/github/test_workflow_permissions.py b/tests/functional/states/github/test_workflow_permissions.py new file mode 100644 index 00000000..0b7f0754 --- /dev/null +++ b/tests/functional/states/github/test_workflow_permissions.py @@ -0,0 +1,72 @@ +def test_workflow_permissions_managed(github, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github.workflow_permissions_managed( + name="repo-a", + default_workflow_permissions="read", + can_approve_pull_request_reviews=True, + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["old"] == { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": False, + } + assert (len(fake_github.mutations()) == before) is testmode + repo = fake_org.get_repo("repo-a") + if testmode: + assert repo.data.default_workflow_permissions == "write" + assert repo.data.can_approve_pull_request_reviews is False + else: + assert repo.data.default_workflow_permissions == "read" + assert repo.data.can_approve_pull_request_reviews is True + rerun = github.workflow_permissions_managed( + name="repo-a", + default_workflow_permissions="read", + can_approve_pull_request_reviews=True, + ) + assert rerun.result is True + assert not rerun.changes + + +def test_workflow_permissions_managed_unspecified_setting_untouched(github, fake_org, testmode): + ret = github.workflow_permissions_managed( + name="repo-a", default_workflow_permissions="read", test=testmode + ) + assert ret.result is not False + assert list(ret.changes["new"]) == ["default_workflow_permissions"] + repo = fake_org.get_repo("repo-a") + assert repo.data.can_approve_pull_request_reviews is False + + +def test_workflow_permissions_managed_idempotent(github, fake_github, testmode): + ret = github.workflow_permissions_managed( + name="repo-a", can_approve_pull_request_reviews=False, test=testmode + ) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_workflow_permissions_managed_repo_name_override(github, fake_org, testmode): + ret = github.workflow_permissions_managed( + name="restrict Actions", + repo_name="repo-b", + default_workflow_permissions="read", + test=testmode, + ) + assert ret.result is not False + expected = "write" if testmode else "read" + assert fake_org.get_repo("repo-b").data.default_workflow_permissions == expected + + +def test_workflow_permissions_managed_no_settings(github): + ret = github.workflow_permissions_managed(name="repo-a") + assert ret.result is False + assert "At least one setting" in ret.comment + + +def test_workflow_permissions_managed_missing_repo(github): + ret = github.workflow_permissions_managed(name="ghost", default_workflow_permissions="read") + assert ret.result is False + assert "Failed to get workflow permissions" in ret.comment diff --git a/tests/support/github/__init__.py b/tests/support/github/__init__.py index 06f3c0f8..34ccd264 100644 --- a/tests/support/github/__init__.py +++ b/tests/support/github/__init__.py @@ -21,27 +21,40 @@ Per-area route mixins composed into :class:`FakeGitHubAPI` below. """ +from tests.support.github.base import DEPLOY_KEY_A +from tests.support.github.base import DEPLOY_KEY_B from tests.support.github.base import ORG from tests.support.github.base import TOKEN +from tests.support.github.entities import BranchPolicy +from tests.support.github.entities import DeployKey from tests.support.github.entities import Entity from tests.support.github.entities import EntityData from tests.support.github.entities import EntityView +from tests.support.github.entities import Environment from tests.support.github.entities import Issue from tests.support.github.entities import IssueComment +from tests.support.github.entities import Label from tests.support.github.entities import Member from tests.support.github.entities import Milestone from tests.support.github.entities import NotFound from tests.support.github.entities import Organization +from tests.support.github.entities import Pages from tests.support.github.entities import PullRequest from tests.support.github.entities import Repo from tests.support.github.entities import RepoId from tests.support.github.entities import Ruleset +from tests.support.github.entities import Secret from tests.support.github.entities import Team from tests.support.github.entities import TeamMember from tests.support.github.entities import User +from tests.support.github.routes import ActionsRoutes +from tests.support.github.routes import DeployKeyRoutes +from tests.support.github.routes import EnvironmentRoutes from tests.support.github.routes import IssueRoutes +from tests.support.github.routes import LabelRoutes from tests.support.github.routes import MilestoneRoutes from tests.support.github.routes import OrgRoutes +from tests.support.github.routes import PagesRoutes from tests.support.github.routes import RepoRoutes from tests.support.github.routes import RulesetRoutes from tests.support.github.routes import TeamRoutes @@ -49,31 +62,51 @@ from tests.support.github.state import GlobalState __all__ = [ + "DEPLOY_KEY_A", + "DEPLOY_KEY_B", "ORG", "TOKEN", + "BranchPolicy", + "DeployKey", "Entity", "EntityData", "EntityView", + "Environment", "FakeGitHubAPI", "GlobalState", "Issue", "IssueComment", + "Label", "Member", "Milestone", "NotFound", "Organization", + "Pages", "PullRequest", "Repo", "RepoId", "Request", "Ruleset", + "Secret", "Team", "TeamMember", "User", ] -class FakeGitHubAPI(OrgRoutes, RepoRoutes, TeamRoutes, RulesetRoutes, MilestoneRoutes, IssueRoutes): +class FakeGitHubAPI( # pylint: disable=too-many-ancestors + OrgRoutes, + RepoRoutes, + TeamRoutes, + RulesetRoutes, + MilestoneRoutes, + IssueRoutes, + DeployKeyRoutes, + ActionsRoutes, + EnvironmentRoutes, + LabelRoutes, + PagesRoutes, +): """ Dict-backed implementation of the consumed GitHub API surface. """ diff --git a/tests/support/github/base.py b/tests/support/github/base.py index 0f771953..2cb718de 100644 --- a/tests/support/github/base.py +++ b/tests/support/github/base.py @@ -12,8 +12,11 @@ from http.server import BaseHTTPRequestHandler from http.server import ThreadingHTTPServer from urllib.parse import parse_qs +from urllib.parse import unquote from urllib.parse import urlsplit +from nacl.public import PrivateKey + from tests.support.github.entities import Organization from tests.support.github.entities import Repo from tests.support.github.routing import HTTP_METHOD @@ -23,6 +26,14 @@ TOKEN = "test-token" ORG = "testorg" +# Sample SSH public keys for deploy key tests +DEPLOY_KEY_A = ( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPDeployKeyAForTesting0000000000000000000A existing" +) +DEPLOY_KEY_B = ( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPDeployKeyBForTesting0000000000000000000B replacement" +) + class APIBase: """ @@ -37,6 +48,8 @@ def __init__(self, token: str = TOKEN, state: GlobalState | None = None): self.lock = threading.Lock() self.requests = [] self.state = state or GlobalState() + # Keypair for the Actions secrets sealed-box encryption + self.secrets_private_key = PrivateKey.generate() self._server = None self._routes: dict[HTTP_METHOD, list[tuple[tuple[re.Pattern[str], ...], Callable]]] = {} for name in dir(self): @@ -211,7 +224,7 @@ def _handle(self): query = {key: vals[0] for key, vals in parse_qs(split.query).items()} status, headers, payload = api.dispatch( self.command, - split.path, + unquote(split.path), query, body, self.headers.get("Authorization"), diff --git a/tests/support/github/entities.py b/tests/support/github/entities.py index 51376e5d..7a506ab1 100644 --- a/tests/support/github/entities.py +++ b/tests/support/github/entities.py @@ -274,6 +274,9 @@ def remove_repo(self, name: RepoName, *, exc=NotFound) -> None: self.gh.repos.pop(repo_id) for team in self.teams: team.data.repos.pop(repo_id, None) + for store in (self.gh.deploy_keys, self.gh.secrets, self.gh.environments, self.gh.labels): + store.pop(repo_id, None) + self.gh.pages.pop(repo_id, None) def add_member(self, user: "User | UserData | UserName", *, exc=NotFound, **params) -> "Member": login = user if isinstance(user, UserName) else user.login @@ -468,6 +471,9 @@ class RepoData(EntityData): has_issues: bool = True has_wiki: bool = True has_downloads: bool = True + # Managed via the Actions permissions endpoints, not part of the repo payload + default_workflow_permissions: str = "write" + can_approve_pull_request_reviews: bool = False def __post_init__(self): super().__post_init__() @@ -601,6 +607,110 @@ def get_pull(self, number: int | str, *, exc=NotFound) -> "PullRequest": ) return PullRequest(self.gh, self.gh.pulls[self.data.repo_id][number]) + @property + def deploy_keys(self) -> "EntityView[int, DeployKey]": + return EntityView( + { + key_id: DeployKey(self.gh, data) + for key_id, data in self.gh.deploy_keys[self.data.repo_id].items() + } + ) + + def add_deploy_key(self, title: str, key: str, **params) -> "DeployKey": + return self.gh.add(DeployKeyData(title, self.data.repo_id, key, **params)) + + def get_deploy_key(self, key_id: int | str, *, exc=NotFound) -> "DeployKey": + key_id = int(key_id) + if key_id not in self.gh.deploy_keys[self.data.repo_id]: + raise exc(f"Deploy key {key_id=} does not exist for repo {self.data.repo_id}") + return DeployKey(self.gh, self.gh.deploy_keys[self.data.repo_id][key_id]) + + def remove_deploy_key(self, key_id: int | str, *, exc=NotFound) -> None: + key_id = int(key_id) + if key_id not in self.gh.deploy_keys[self.data.repo_id]: + raise exc(f"Deploy key {key_id=} does not exist for repo {self.data.repo_id}") + del self.gh.deploy_keys[self.data.repo_id][key_id] + + @property + def secrets(self) -> "EntityView[str, Secret]": + return EntityView( + { + name: Secret(self.gh, data) + for name, data in self.gh.secrets[self.data.repo_id].items() + }, + resolve=lambda key: key.upper() if isinstance(key, str) else key, + ) + + def add_secret(self, name: str, value: str, **params) -> "Secret": + return self.gh.add(SecretData(name, self.data.repo_id, value, **params)) + + def get_secret(self, name: str, *, exc=NotFound) -> "Secret": + name = name.upper() + if name not in self.gh.secrets[self.data.repo_id]: + raise exc(f"Secret {name=} does not exist for repo {self.data.repo_id}") + return Secret(self.gh, self.gh.secrets[self.data.repo_id][name]) + + def remove_secret(self, name: str, *, exc=NotFound) -> None: + name = name.upper() + if name not in self.gh.secrets[self.data.repo_id]: + raise exc(f"Secret {name=} does not exist for repo {self.data.repo_id}") + del self.gh.secrets[self.data.repo_id][name] + + @property + def environments(self) -> "EntityView[str, Environment]": + return EntityView( + { + name: Environment(self.gh, data) + for name, data in self.gh.environments[self.data.repo_id].items() + } + ) + + def add_environment(self, name: str, **params) -> "Environment": + return self.gh.add(EnvironmentData(name, self.data.repo_id, **params)) + + def get_environment(self, name: str, *, exc=NotFound) -> "Environment": + if name not in self.gh.environments[self.data.repo_id]: + raise exc(f"Environment {name=} does not exist for repo {self.data.repo_id}") + return Environment(self.gh, self.gh.environments[self.data.repo_id][name]) + + def remove_environment(self, name: str, *, exc=NotFound) -> None: + if name not in self.gh.environments[self.data.repo_id]: + raise exc(f"Environment {name=} does not exist for repo {self.data.repo_id}") + del self.gh.environments[self.data.repo_id][name] + + @property + def labels(self) -> "EntityView[str, Label]": + return EntityView( + {name: Label(self.gh, data) for name, data in self.gh.labels[self.data.repo_id].items()} + ) + + def add_label(self, name: str, **params) -> "Label": + return self.gh.add(LabelData(name, self.data.repo_id, **params)) + + def get_label(self, name: str, *, exc=NotFound) -> "Label": + if name not in self.gh.labels[self.data.repo_id]: + raise exc(f"Label {name=} does not exist for repo {self.data.repo_id}") + return Label(self.gh, self.gh.labels[self.data.repo_id][name]) + + def remove_label(self, name: str, *, exc=NotFound) -> None: + if name not in self.gh.labels[self.data.repo_id]: + raise exc(f"Label {name=} does not exist for repo {self.data.repo_id}") + del self.gh.labels[self.data.repo_id][name] + + @property + def pages(self) -> "Pages | None": + if self.data.repo_id not in self.gh.pages: + return None + return Pages(self.gh, self.gh.pages[self.data.repo_id]) + + def set_pages(self, **params) -> "Pages": + return self.gh.add(PagesData(self.data.repo_id, **params)) + + def remove_pages(self, *, exc=NotFound) -> None: + if self.data.repo_id not in self.gh.pages: + raise exc(f"Pages do not exist for repo {self.data.repo_id}") + del self.gh.pages[self.data.repo_id] + def team_payload(self, permission, /, **ovrr): rend = self.payload() rend["permissions"] = { @@ -625,6 +735,8 @@ def payload(self, /, **ovrr): "open_issues_count": 0, } rend.pop("owner_type") + rend.pop("default_workflow_permissions") + rend.pop("can_approve_pull_request_reviews") return rend | ovrr @@ -932,6 +1044,235 @@ def issue_payload(self, /, **ovrr): ) +@dataclass +class DeployKeyData(EntityData): + title: str + repo: RepoId + key: str + _: KW_ONLY + read_only: bool = False + verified: bool = True + created_at: str = CREATED_AT + + def __post_init__(self): + # Titles are only unique per repo, include the repo in the ID. + self.id = zlib.crc32(f"{type(self).__name__}/{self.repo}/{self.title}".encode()) + + +@dataclass +class DeployKey(Entity): + data: DeployKeyData + + def payload(self, /, **ovrr): + rend = asdict(self.data) + rend.pop("repo") + return ( + rend + | { + "url": f"{self.gh.base_url}/repos/{self.data.repo}/keys/{self.data.id}", + } + | ovrr + ) + + +@dataclass +class SecretData(EntityData): + name: str + repo: RepoId + # The fake stores the decrypted plaintext for assertions. The real API + # never returns it. + value: str = "" + _: KW_ONLY + created_at: str = CREATED_AT + updated_at: str = CREATED_AT + + def __post_init__(self): + # Like the real API, secret names are case-insensitive and + # rendered in upper case. + self.name = self.name.upper() + super().__post_init__() + + +@dataclass +class Secret(Entity): + data: SecretData + + def payload(self, /, **ovrr): + return { + "name": self.data.name, + "created_at": self.data.created_at, + "updated_at": self.data.updated_at, + } | ovrr + + +@dataclass +class BranchPolicyData(EntityData): + name: str + environment: str + repo: RepoId + _: KW_ONLY + type: str = "branch" + + def __post_init__(self): + # Patterns are only unique per environment, include it in the ID. + self.id = zlib.crc32( + f"{type(self).__name__}/{self.repo}/{self.environment}/{self.name}".encode() + ) + + +@dataclass +class BranchPolicy(Entity): + data: BranchPolicyData + + def payload(self, /, **ovrr): + return { + "id": self.data.id, + "name": self.data.name, + "type": self.data.type, + } | ovrr + + +@dataclass +class EnvironmentData(EntityData): + name: str + repo: RepoId + _: KW_ONLY + wait_timer: int = 0 + prevent_self_review: bool = False + reviewers: list[dict] = field(default_factory=list) # [{"type": ..., "id": ...}] + # None (all branches) or {"protected_branches": bool, "custom_branch_policies": bool} + deployment_branch_policy: dict | None = None + branch_policies: dict[int, BranchPolicyData] = field(default_factory=dict) + created_at: str = CREATED_AT + updated_at: str = CREATED_AT + + +@dataclass +class Environment(Entity): + data: EnvironmentData + + @property + def branch_policies(self) -> "EntityView[int, BranchPolicy]": + return EntityView( + {pid: BranchPolicy(self.gh, data) for pid, data in self.data.branch_policies.items()} + ) + + def add_branch_policy(self, name: str, **params) -> BranchPolicy: + policy = BranchPolicyData(name, self.data.name, self.data.repo, **params) + self.data.branch_policies[policy.id] = policy + return BranchPolicy(self.gh, policy) + + def get_branch_policy(self, policy_id: int | str, *, exc=NotFound) -> BranchPolicy: + policy_id = int(policy_id) + if policy_id not in self.data.branch_policies: + raise exc(f"Branch policy {policy_id=} does not exist in env {self.data.name}") + return BranchPolicy(self.gh, self.data.branch_policies[policy_id]) + + def remove_branch_policy(self, policy_id: int | str, *, exc=NotFound) -> None: + policy_id = int(policy_id) + if policy_id not in self.data.branch_policies: + raise exc(f"Branch policy {policy_id=} does not exist in env {self.data.name}") + del self.data.branch_policies[policy_id] + + def payload(self, /, **ovrr): + # Like the real API, the settings are scattered over protection_rules + protection_rules = [] + if self.data.wait_timer: + protection_rules.append( + {"id": 1, "type": "wait_timer", "wait_timer": self.data.wait_timer} + ) + if self.data.reviewers: + protection_rules.append( + { + "id": 2, + "type": "required_reviewers", + "prevent_self_review": self.data.prevent_self_review, + "reviewers": [ + {"type": reviewer["type"], "reviewer": {"id": reviewer["id"]}} + for reviewer in self.data.reviewers + ], + } + ) + if self.data.deployment_branch_policy: + protection_rules.append({"id": 3, "type": "branch_policy"}) + policy = self.data.deployment_branch_policy + return { + "id": self.data.id, + "name": self.data.name, + "url": f"{self.gh.base_url}/repos/{self.data.repo}/environments/{self.data.name}", + "html_url": ( + f"https://github.example.com/{self.data.repo}/deployments/" + f"activity_log?environments_filter={self.data.name}" + ), + "created_at": self.data.created_at, + "updated_at": self.data.updated_at, + "protection_rules": protection_rules, + "deployment_branch_policy": dict(policy) if policy else None, + } | ovrr + + +@dataclass +class LabelData(EntityData): + name: str + repo: RepoId + _: KW_ONLY + color: str = "ededed" + description: str | None = None + default: bool = False + + def __post_init__(self): + # Label names are only unique per repo, include it in the ID. + self.id = zlib.crc32(f"{type(self).__name__}/{self.repo}/{self.name}".encode()) + + +@dataclass +class Label(Entity): + data: LabelData + + def payload(self, /, **ovrr): + rend = asdict(self.data) + rend.pop("repo") + return ( + rend + | { + "url": f"{self.gh.base_url}/repos/{self.data.repo}/labels/{self.data.name}", + } + | ovrr + ) + + +@dataclass +class PagesData(EntityData): + repo: RepoId + _: KW_ONLY + build_type: str = "legacy" + source: dict | None = None # {"branch": ..., "path": ...} + cname: str | None = None + https_enforced: bool = False + status: str = "built" + + def __post_init__(self): + self.id = zlib.crc32(f"{type(self).__name__}/{self.repo}".encode()) + + +@dataclass +class Pages(Entity): + data: PagesData + + def payload(self, /, **ovrr): + rend = asdict(self.data) + rend.pop("repo") + return ( + rend + | { + "url": f"{self.gh.base_url}/repos/{self.data.repo}/pages", + "html_url": f"https://{self.data.repo.owner}.github.example.com/{self.data.repo.name}/", + "public": True, + } + | ovrr + ) + + @dataclass class MilestoneData(EntityData): title: str diff --git a/tests/support/github/routes/__init__.py b/tests/support/github/routes/__init__.py index e7b42893..ecc6c730 100644 --- a/tests/support/github/routes/__init__.py +++ b/tests/support/github/routes/__init__.py @@ -2,17 +2,27 @@ Route mixins implementing the consumed GitHub API surface, grouped per area. """ +from tests.support.github.routes.actions import ActionsRoutes +from tests.support.github.routes.deploy_keys import DeployKeyRoutes +from tests.support.github.routes.environments import EnvironmentRoutes from tests.support.github.routes.issues import IssueRoutes +from tests.support.github.routes.labels import LabelRoutes from tests.support.github.routes.milestones import MilestoneRoutes from tests.support.github.routes.orgs import OrgRoutes +from tests.support.github.routes.pages import PagesRoutes from tests.support.github.routes.repos import RepoRoutes from tests.support.github.routes.rulesets import RulesetRoutes from tests.support.github.routes.teams import TeamRoutes __all__ = [ + "ActionsRoutes", + "DeployKeyRoutes", + "EnvironmentRoutes", "IssueRoutes", + "LabelRoutes", "MilestoneRoutes", "OrgRoutes", + "PagesRoutes", "RepoRoutes", "RulesetRoutes", "TeamRoutes", diff --git a/tests/support/github/routes/actions.py b/tests/support/github/routes/actions.py new file mode 100644 index 00000000..1763132e --- /dev/null +++ b/tests/support/github/routes/actions.py @@ -0,0 +1,79 @@ +""" +Routes: Actions secrets and workflow permissions. +""" + +import base64 + +from nacl.public import SealedBox + +from tests.support.github.base import APIBase +from tests.support.github.routing import DELETE +from tests.support.github.routing import GET +from tests.support.github.routing import PUT + +# The generic secret name pattern must not swallow the public-key route +SECRET_PATH = ( + r"/repos/(?P[^/]+)/(?P[^/]+)/actions/secrets/(?P(?!public-key$)[^/]+)" +) + + +class ActionsRoutes(APIBase): + @GET("repos///actions/secrets/public-key") + def _secrets_public_key(self, match, _query, _body): + self._resolve_repo(match) + public_key = base64.b64encode(bytes(self.secrets_private_key.public_key)).decode() + return 200, {"key_id": "568250167242549743", "key": public_key} + + @GET("repos///actions/secrets") + def _list_secrets(self, match, _query, _body): + repo = self._resolve_repo(match) + secrets = [secret.payload() for secret in sorted(repo.secrets, key=lambda x: x.name)] + return 200, {"total_count": len(secrets), "secrets": secrets} + + @GET(SECRET_PATH) + def _get_secret(self, match, _query, _body): + repo = self._resolve_repo(match) + return 200, repo.get_secret(match["name"]).payload() + + @PUT(SECRET_PATH) + def _put_secret(self, match, _query, body): + repo = self._resolve_repo(match) + # Decrypt the sealed box to prove the client encrypted correctly + # (and to allow asserting submitted values in tests). + value = ( + SealedBox(self.secrets_private_key) + .decrypt(base64.b64decode(body["encrypted_value"])) + .decode() + ) + try: + secret = repo.get_secret(match["name"]) + except KeyError: + repo.add_secret(match["name"], value) + return 201 + secret.update(value=value, updated_at="2026-10-12T00:00:00Z") + return 204 + + @DELETE(SECRET_PATH) + def _delete_secret(self, match, _query, _body): + repo = self._resolve_repo(match) + repo.remove_secret(match["name"]) + return 204 + + @GET("repos///actions/permissions/workflow") + def _get_workflow_permissions(self, match, _query, _body): + repo = self._resolve_repo(match) + return 200, { + "default_workflow_permissions": repo.data.default_workflow_permissions, + "can_approve_pull_request_reviews": repo.data.can_approve_pull_request_reviews, + } + + @PUT("repos///actions/permissions/workflow") + def _set_workflow_permissions(self, match, _query, body): + repo = self._resolve_repo(match) + upd = { + fld: body[fld] + for fld in ("default_workflow_permissions", "can_approve_pull_request_reviews") + if fld in body + } + repo.update(**upd) + return 204 diff --git a/tests/support/github/routes/deploy_keys.py b/tests/support/github/routes/deploy_keys.py new file mode 100644 index 00000000..5768b67b --- /dev/null +++ b/tests/support/github/routes/deploy_keys.py @@ -0,0 +1,32 @@ +""" +Routes: repository deploy keys. +""" + +from tests.support.github.base import APIBase +from tests.support.github.routing import DELETE +from tests.support.github.routing import GET +from tests.support.github.routing import POST + + +class DeployKeyRoutes(APIBase): + @GET("repos///keys") + def _list_deploy_keys(self, match, _query, _body): + repo = self._resolve_repo(match) + return 200, [key.payload() for key in sorted(repo.deploy_keys, key=lambda x: x.id)] + + @POST("repos///keys") + def _create_deploy_key(self, match, _query, body): + repo = self._resolve_repo(match) + key = repo.add_deploy_key(body["title"], body["key"], read_only=bool(body.get("read_only"))) + return 201, key.payload() + + @GET("repos///keys/") + def _get_deploy_key(self, match, _query, _body): + repo = self._resolve_repo(match) + return 200, repo.get_deploy_key(match["key_id"]).payload() + + @DELETE("repos///keys/") + def _delete_deploy_key(self, match, _query, _body): + repo = self._resolve_repo(match) + repo.remove_deploy_key(match["key_id"]) + return 204 diff --git a/tests/support/github/routes/environments.py b/tests/support/github/routes/environments.py new file mode 100644 index 00000000..51f93fa1 --- /dev/null +++ b/tests/support/github/routes/environments.py @@ -0,0 +1,68 @@ +""" +Routes: repository environments and their deployment branch policies. +""" + +from tests.support.github.base import APIBase +from tests.support.github.routing import DELETE +from tests.support.github.routing import GET +from tests.support.github.routing import POST +from tests.support.github.routing import PUT + + +class EnvironmentRoutes(APIBase): + @GET("repos///environments") + def _list_environments(self, match, _query, _body): + repo = self._resolve_repo(match) + environments = [env.payload() for env in sorted(repo.environments, key=lambda x: x.name)] + return 200, {"total_count": len(environments), "environments": environments} + + @GET("repos///environments/") + def _get_environment(self, match, _query, _body): + repo = self._resolve_repo(match) + return 200, repo.get_environment(match["environment"]).payload() + + @PUT("repos///environments/") + def _put_environment(self, match, _query, body): + repo = self._resolve_repo(match) + # Like the real API, a PUT replaces the full configuration, + # so unsent settings are reset to their defaults. + settings = { + "wait_timer": (body or {}).get("wait_timer", 0), + "prevent_self_review": (body or {}).get("prevent_self_review", False), + "reviewers": (body or {}).get("reviewers") or [], + "deployment_branch_policy": (body or {}).get("deployment_branch_policy"), + } + try: + env = repo.get_environment(match["environment"]) + except KeyError: + env = repo.add_environment(match["environment"], **settings) + else: + env = env.update(**settings) + return 200, env.payload() + + @DELETE("repos///environments/") + def _delete_environment(self, match, _query, _body): + repo = self._resolve_repo(match) + repo.remove_environment(match["environment"]) + return 204 + + @GET("repos///environments//deployment-branch-policies") + def _list_branch_policies(self, match, _query, _body): + repo = self._resolve_repo(match) + env = repo.get_environment(match["environment"]) + policies = [policy.payload() for policy in sorted(env.branch_policies, key=lambda x: x.id)] + return 200, {"total_count": len(policies), "branch_policies": policies} + + @POST("repos///environments//deployment-branch-policies") + def _create_branch_policy(self, match, _query, body): + repo = self._resolve_repo(match) + env = repo.get_environment(match["environment"]) + policy = env.add_branch_policy(body["name"], type=body.get("type", "branch")) + return 200, policy.payload() + + @DELETE("repos///environments//deployment-branch-policies/") + def _delete_branch_policy(self, match, _query, _body): + repo = self._resolve_repo(match) + env = repo.get_environment(match["environment"]) + env.remove_branch_policy(match["policy_id"]) + return 204 diff --git a/tests/support/github/routes/labels.py b/tests/support/github/routes/labels.py new file mode 100644 index 00000000..dd8f8448 --- /dev/null +++ b/tests/support/github/routes/labels.py @@ -0,0 +1,44 @@ +""" +Routes: repository (issue) labels. +""" + +from tests.support.github.base import APIBase +from tests.support.github.routing import DELETE +from tests.support.github.routing import GET +from tests.support.github.routing import PATCH +from tests.support.github.routing import POST + + +class LabelRoutes(APIBase): + @GET("repos///labels") + def _list_labels(self, match, _query, _body): + repo = self._resolve_repo(match) + return 200, [label.payload() for label in sorted(repo.labels, key=lambda x: x.name)] + + @POST("repos///labels") + def _create_label(self, match, _query, body): + repo = self._resolve_repo(match) + assert body["name"] not in repo.labels + params = {fld: body[fld] for fld in ("color", "description") if fld in body} + label = repo.add_label(body["name"], **params) + return 201, label.payload() + + @GET("repos///labels/") + def _get_label(self, match, _query, _body): + repo = self._resolve_repo(match) + return 200, repo.get_label(match["name"]).payload() + + @PATCH("repos///labels/") + def _edit_label(self, match, _query, body): + repo = self._resolve_repo(match) + label = repo.get_label(match["name"]) + upd = {fld: body[fld] for fld in ("color", "description") if fld in body} + if "new_name" in body: + upd["name"] = body["new_name"] + return 200, label.update(**upd).payload() + + @DELETE("repos///labels/") + def _delete_label(self, match, _query, _body): + repo = self._resolve_repo(match) + repo.remove_label(match["name"]) + return 204 diff --git a/tests/support/github/routes/pages.py b/tests/support/github/routes/pages.py new file mode 100644 index 00000000..c298ecd4 --- /dev/null +++ b/tests/support/github/routes/pages.py @@ -0,0 +1,47 @@ +""" +Routes: GitHub Pages sites. +""" + +from tests.support.github.base import APIBase +from tests.support.github.routing import DELETE +from tests.support.github.routing import GET +from tests.support.github.routing import POST +from tests.support.github.routing import PUT + + +class PagesRoutes(APIBase): + @GET("repos///pages") + def _get_pages(self, match, _query, _body): + repo = self._resolve_repo(match) + if repo.pages is None: + return 404, {"message": "Not Found"} + return 200, repo.pages.payload() + + @POST("repos///pages") + def _create_pages(self, match, _query, body): + repo = self._resolve_repo(match) + assert repo.pages is None + params = {fld: body[fld] for fld in ("build_type", "source") if fld in (body or {})} + params.setdefault("source", {"branch": repo.data.default_branch, "path": "/"}) + pages = repo.set_pages(**params) + return 201, pages.payload() + + @PUT("repos///pages") + def _update_pages(self, match, _query, body): + repo = self._resolve_repo(match) + pages = repo.pages + if pages is None: + return 404, {"message": "Not Found"} + upd = { + fld: body[fld] + for fld in ("build_type", "source", "cname", "https_enforced") + if fld in body + } + pages.update(**upd) + return 204 + + @DELETE("repos///pages") + def _delete_pages(self, match, _query, _body): + repo = self._resolve_repo(match) + repo.remove_pages() + return 204 diff --git a/tests/support/github/state.py b/tests/support/github/state.py index 71b970e8..33526b1d 100644 --- a/tests/support/github/state.py +++ b/tests/support/github/state.py @@ -10,10 +10,16 @@ from dataclasses import dataclass from dataclasses import field +from tests.support.github.entities import DeployKey +from tests.support.github.entities import DeployKeyData from tests.support.github.entities import Entity from tests.support.github.entities import EntityData +from tests.support.github.entities import Environment +from tests.support.github.entities import EnvironmentData from tests.support.github.entities import Issue from tests.support.github.entities import IssueData +from tests.support.github.entities import Label +from tests.support.github.entities import LabelData from tests.support.github.entities import Member from tests.support.github.entities import MemberData from tests.support.github.entities import Milestone @@ -23,6 +29,8 @@ from tests.support.github.entities import OrganizationData from tests.support.github.entities import OrgName from tests.support.github.entities import OrgRulesetData +from tests.support.github.entities import Pages +from tests.support.github.entities import PagesData from tests.support.github.entities import PullRequest from tests.support.github.entities import PullRequestData from tests.support.github.entities import Repo @@ -30,6 +38,8 @@ from tests.support.github.entities import RepoId from tests.support.github.entities import RepoRulesetData from tests.support.github.entities import Ruleset +from tests.support.github.entities import Secret +from tests.support.github.entities import SecretData from tests.support.github.entities import Team from tests.support.github.entities import TeamData from tests.support.github.entities import TeamName @@ -59,6 +69,15 @@ class GlobalState: # pylint: disable=too-many-instance-attributes pulls: dict[RepoId, dict[int, PullRequestData]] = field( default_factory=lambda: defaultdict(dict) ) + deploy_keys: dict[RepoId, dict[int, DeployKeyData]] = field( + default_factory=lambda: defaultdict(dict) + ) + secrets: dict[RepoId, dict[str, SecretData]] = field(default_factory=lambda: defaultdict(dict)) + environments: dict[RepoId, dict[str, EnvironmentData]] = field( + default_factory=lambda: defaultdict(dict) + ) + labels: dict[RepoId, dict[str, LabelData]] = field(default_factory=lambda: defaultdict(dict)) + pages: dict[RepoId, PagesData] = field(default_factory=dict) def __post_init__(self): self.snapshot() @@ -74,6 +93,11 @@ def snapshot(self): self._init_milestones = deepcopy(self.milestones) self._init_issues = deepcopy(self.issues) self._init_pulls = deepcopy(self.pulls) + self._init_deploy_keys = deepcopy(self.deploy_keys) + self._init_secrets = deepcopy(self.secrets) + self._init_environments = deepcopy(self.environments) + self._init_labels = deepcopy(self.labels) + self._init_pages = deepcopy(self.pages) def reset(self): self.orgs = {} @@ -86,6 +110,11 @@ def reset(self): self.milestones = defaultdict(dict) self.issues = defaultdict(dict) self.pulls = defaultdict(dict) + self.deploy_keys = defaultdict(dict) + self.secrets = defaultdict(dict) + self.environments = defaultdict(dict) + self.labels = defaultdict(dict) + self.pages = {} self._init_orgs = {} self._init_users = {} self._init_repos = {} @@ -96,6 +125,11 @@ def reset(self): self._init_milestones = defaultdict(dict) self._init_issues = defaultdict(dict) self._init_pulls = defaultdict(dict) + self._init_deploy_keys = defaultdict(dict) + self._init_secrets = defaultdict(dict) + self._init_environments = defaultdict(dict) + self._init_labels = defaultdict(dict) + self._init_pages = {} def restore(self): self.orgs = deepcopy(self._init_orgs) @@ -108,6 +142,11 @@ def restore(self): self.milestones = deepcopy(self._init_milestones) self.issues = deepcopy(self._init_issues) self.pulls = deepcopy(self._init_pulls) + self.deploy_keys = deepcopy(self._init_deploy_keys) + self.secrets = deepcopy(self._init_secrets) + self.environments = deepcopy(self._init_environments) + self.labels = deepcopy(self._init_labels) + self.pages = deepcopy(self._init_pages) def _next_issue_number(self, repo_id: RepoId) -> int: # Issues and pull requests share one number sequence per repo. @@ -160,7 +199,7 @@ def get_repo(self, repo_id: RepoId | Sequence[str] | str, *, exc=NotFound) -> Re raise exc(f"Requested nonexistent repo {resolved.name=} from org {resolved.owner=}") return Repo(self, self.repos[resolved]) - def add(self, entity: EntityData | Entity): + def add(self, entity: EntityData | Entity): # pylint: disable=too-many-return-statements if isinstance(entity, Entity): data = entity.data else: @@ -183,6 +222,26 @@ def add(self, entity: EntityData | Entity): assert data.source in self.orgs self.rulesets_org.setdefault(data.source, {})[data.id] = data return Ruleset(self, data) + if isinstance(data, DeployKeyData): + assert data.repo in self.repos + self.deploy_keys[data.repo][data.id] = data + return DeployKey(self, data) + if isinstance(data, SecretData): + assert data.repo in self.repos + self.secrets[data.repo][data.name] = data + return Secret(self, data) + if isinstance(data, EnvironmentData): + assert data.repo in self.repos + self.environments[data.repo][data.name] = data + return Environment(self, data) + if isinstance(data, LabelData): + assert data.repo in self.repos + self.labels[data.repo][data.name] = data + return Label(self, data) + if isinstance(data, PagesData): + assert data.repo in self.repos + self.pages[data.repo] = data + return Pages(self, data) if isinstance(data, MilestoneData): if data.number < 0: data = data.with_(number=len(self.milestones[data.repo]) + 1) @@ -226,6 +285,16 @@ def replace(self, old: EntityData | Entity, new: EntityData | Entity): self.rulesets_repo[old_data.source].pop(old_data.id) elif isinstance(old_data, OrgRulesetData): self.rulesets_org[old_data.source].pop(old_data.id) + elif isinstance(old_data, DeployKeyData): + self.deploy_keys[old_data.repo].pop(old_data.id) + elif isinstance(old_data, SecretData): + self.secrets[old_data.repo].pop(old_data.name) + elif isinstance(old_data, EnvironmentData): + self.environments[old_data.repo].pop(old_data.name) + elif isinstance(old_data, LabelData): + self.labels[old_data.repo].pop(old_data.name) + elif isinstance(old_data, PagesData): + self.pages.pop(old_data.repo) elif isinstance(old_data, MilestoneData): self.milestones[old_data.repo].pop(old_data.number) elif isinstance(old_data, IssueData): From 3e3af845561d0ae19dfed42d2bb3867dda4aa47d Mon Sep 17 00:00:00 2001 From: jeanluc Date: Mon, 12 Oct 2026 15:42:52 +0200 Subject: [PATCH 2/6] Refactor into global/org/repo-scoped modules --- changelog/+actions_secrets.added.md | 2 +- changelog/+default_branch.added.md | 2 +- changelog/+deploy_keys.added.md | 2 +- changelog/+environments.added.md | 2 +- changelog/+labels.added.md | 2 +- changelog/+legacy_modules.deprecated.md | 1 + changelog/+pages.added.md | 2 +- changelog/+scoped_modules.added.md | 1 + changelog/+workflow_permissions.added.md | 2 +- docs/ref/modules/index.rst | 2 + .../saltext.github.modules.github_org.rst | 5 + .../saltext.github.modules.github_repo.rst | 5 + docs/ref/states/index.rst | 2 + .../saltext.github.states.github_org.rst | 5 + .../saltext.github.states.github_repo.rst | 5 + docs/ref/utils/index.rst | 1 + .../utils/saltext.github.utils.gh.profile.rst | 5 + src/saltext/github/modules/github.py | 1018 ++--------- src/saltext/github/modules/github_org.py | 721 ++++++++ src/saltext/github/modules/github_repo.py | 1519 +++++++++++++++++ src/saltext/github/states/github.py | 969 +---------- src/saltext/github/states/github_org.py | 625 +++++++ src/saltext/github/states/github_repo.py | 1430 ++++++++++++++++ src/saltext/github/utils/gh/profile.py | 97 ++ src/saltext/github/utils/gh/repos.py | 9 +- tests/functional/modules/github/test_pages.py | 53 - tests/functional/modules/github/test_repos.py | 5 - .../functional/modules/github_org/__init__.py | 0 .../functional/modules/github_org/conftest.py | 6 + .../modules/github_org/test_members.py | 70 + .../modules/github_org/test_repos.py | 32 + .../modules/github_org/test_rulesets.py | 49 + .../modules/github_org/test_teams.py | 172 ++ .../modules/github_repo/__init__.py | 0 .../modules/github_repo/conftest.py | 6 + .../test_actions_secrets.py | 32 +- .../test_deploy_keys.py | 32 +- .../test_environments.py | 48 +- .../modules/github_repo/test_issues.py | 56 + .../{github => github_repo}/test_labels.py | 36 +- .../modules/github_repo/test_milestones.py | 39 + .../modules/github_repo/test_pages.py | 53 + .../modules/github_repo/test_pulls.py | 10 + .../modules/github_repo/test_repo.py | 69 + .../modules/github_repo/test_rulesets.py | 50 + .../test_workflow_permissions.py | 12 +- tests/functional/states/github/test_repo.py | 29 - .../functional/states/github_org/__init__.py | 0 .../functional/states/github_org/conftest.py | 11 + .../states/github_org/test_member.py | 70 + .../states/github_org/test_ruleset.py | 98 ++ .../functional/states/github_org/test_team.py | 218 +++ .../functional/states/github_repo/__init__.py | 0 .../functional/states/github_repo/conftest.py | 11 + .../test_actions_secret.py | 32 +- .../test_deploy_key.py | 42 +- .../test_environment.py | 62 +- .../{github => github_repo}/test_label.py | 36 +- .../{github => github_repo}/test_pages.py | 36 +- .../states/github_repo/test_repo.py | 155 ++ .../states/github_repo/test_ruleset.py | 102 ++ .../test_workflow_permissions.py | 30 +- 62 files changed, 6078 insertions(+), 2118 deletions(-) create mode 100644 changelog/+legacy_modules.deprecated.md create mode 100644 changelog/+scoped_modules.added.md create mode 100644 docs/ref/modules/saltext.github.modules.github_org.rst create mode 100644 docs/ref/modules/saltext.github.modules.github_repo.rst create mode 100644 docs/ref/states/saltext.github.states.github_org.rst create mode 100644 docs/ref/states/saltext.github.states.github_repo.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.profile.rst create mode 100644 src/saltext/github/modules/github_org.py create mode 100644 src/saltext/github/modules/github_repo.py create mode 100644 src/saltext/github/states/github_org.py create mode 100644 src/saltext/github/states/github_repo.py create mode 100644 src/saltext/github/utils/gh/profile.py delete mode 100644 tests/functional/modules/github/test_pages.py create mode 100644 tests/functional/modules/github_org/__init__.py create mode 100644 tests/functional/modules/github_org/conftest.py create mode 100644 tests/functional/modules/github_org/test_members.py create mode 100644 tests/functional/modules/github_org/test_repos.py create mode 100644 tests/functional/modules/github_org/test_rulesets.py create mode 100644 tests/functional/modules/github_org/test_teams.py create mode 100644 tests/functional/modules/github_repo/__init__.py create mode 100644 tests/functional/modules/github_repo/conftest.py rename tests/functional/modules/{github => github_repo}/test_actions_secrets.py (50%) rename tests/functional/modules/{github => github_repo}/test_deploy_keys.py (56%) rename tests/functional/modules/{github => github_repo}/test_environments.py (54%) create mode 100644 tests/functional/modules/github_repo/test_issues.py rename tests/functional/modules/{github => github_repo}/test_labels.py (55%) create mode 100644 tests/functional/modules/github_repo/test_milestones.py create mode 100644 tests/functional/modules/github_repo/test_pages.py create mode 100644 tests/functional/modules/github_repo/test_pulls.py create mode 100644 tests/functional/modules/github_repo/test_repo.py create mode 100644 tests/functional/modules/github_repo/test_rulesets.py rename tests/functional/modules/{github => github_repo}/test_workflow_permissions.py (63%) create mode 100644 tests/functional/states/github_org/__init__.py create mode 100644 tests/functional/states/github_org/conftest.py create mode 100644 tests/functional/states/github_org/test_member.py create mode 100644 tests/functional/states/github_org/test_ruleset.py create mode 100644 tests/functional/states/github_org/test_team.py create mode 100644 tests/functional/states/github_repo/__init__.py create mode 100644 tests/functional/states/github_repo/conftest.py rename tests/functional/states/{github => github_repo}/test_actions_secret.py (61%) rename tests/functional/states/{github => github_repo}/test_deploy_key.py (62%) rename tests/functional/states/{github => github_repo}/test_environment.py (67%) rename tests/functional/states/{github => github_repo}/test_label.py (69%) rename tests/functional/states/{github => github_repo}/test_pages.py (64%) create mode 100644 tests/functional/states/github_repo/test_repo.py create mode 100644 tests/functional/states/github_repo/test_ruleset.py rename tests/functional/states/{github => github_repo}/test_workflow_permissions.py (69%) diff --git a/changelog/+actions_secrets.added.md b/changelog/+actions_secrets.added.md index 6490ad62..c6ca60f8 100644 --- a/changelog/+actions_secrets.added.md +++ b/changelog/+actions_secrets.added.md @@ -1 +1 @@ -Added management of repository Actions secrets, encrypted client-side to the repository public key (``github.list_actions_secrets``/``get_actions_secret``/``set_actions_secret``/``remove_actions_secret`` and the ``actions_secret_present``/``actions_secret_absent`` states) +Added management of repository Actions secrets, encrypted client-side to the repository public key (``github_repo.list_actions_secrets``/``get_actions_secret``/``set_actions_secret``/``remove_actions_secret`` and the ``github_repo.actions_secret_present``/``actions_secret_absent`` states) diff --git a/changelog/+default_branch.added.md b/changelog/+default_branch.added.md index 0e9e876d..e2a82df3 100644 --- a/changelog/+default_branch.added.md +++ b/changelog/+default_branch.added.md @@ -1 +1 @@ -Added ``default_branch`` to ``github.edit_repo`` and the ``repo_present`` state +Added ``default_branch`` to ``github_repo.update`` and the ``github_repo.present`` state diff --git a/changelog/+deploy_keys.added.md b/changelog/+deploy_keys.added.md index 03d475c7..e0c575e3 100644 --- a/changelog/+deploy_keys.added.md +++ b/changelog/+deploy_keys.added.md @@ -1 +1 @@ -Added management of repository deploy keys (``github.list_deploy_keys``/``get_deploy_key``/``add_deploy_key``/``remove_deploy_key`` and the ``deploy_key_present``/``deploy_key_absent`` states) +Added management of repository deploy keys (``github_repo.list_deploy_keys``/``get_deploy_key``/``add_deploy_key``/``remove_deploy_key`` and the ``github_repo.deploy_key_present``/``deploy_key_absent`` states) diff --git a/changelog/+environments.added.md b/changelog/+environments.added.md index 4ca6c5ae..b5be6845 100644 --- a/changelog/+environments.added.md +++ b/changelog/+environments.added.md @@ -1 +1 @@ -Added management of repository environments, including deployment branch policies (``github.list_environments``/``get_environment``/``set_environment``/``remove_environment``/``list_deployment_branch_policies``/``add_deployment_branch_policy``/``remove_deployment_branch_policy`` and the ``environment_present``/``environment_absent`` states) +Added management of repository environments, including deployment branch policies (``github_repo.list_environments``/``get_environment``/``set_environment``/``remove_environment``/``list_deployment_branch_policies``/``add_deployment_branch_policy``/``remove_deployment_branch_policy`` and the ``github_repo.environment_present``/``environment_absent`` states) diff --git a/changelog/+labels.added.md b/changelog/+labels.added.md index 554b9349..dbab6376 100644 --- a/changelog/+labels.added.md +++ b/changelog/+labels.added.md @@ -1 +1 @@ -Added management of repository labels (``github.list_labels``/``get_label``/``add_label``/``edit_label``/``remove_label`` and the ``label_present``/``label_absent`` states) +Added management of repository labels (``github_repo.list_labels``/``get_label``/``add_label``/``edit_label``/``remove_label`` and the ``github_repo.label_present``/``label_absent`` states) diff --git a/changelog/+legacy_modules.deprecated.md b/changelog/+legacy_modules.deprecated.md new file mode 100644 index 00000000..4b6445e3 --- /dev/null +++ b/changelog/+legacy_modules.deprecated.md @@ -0,0 +1 @@ +Deprecated managing organization- and repository-scoped resources via the ``github`` execution and state modules. Use the corresponding functions of the new ``github_org`` and ``github_repo`` modules instead diff --git a/changelog/+pages.added.md b/changelog/+pages.added.md index f89b6820..f6293365 100644 --- a/changelog/+pages.added.md +++ b/changelog/+pages.added.md @@ -1 +1 @@ -Added management of GitHub Pages sites (``github.get_pages``/``setup_pages``/``update_pages``/``remove_pages`` and the ``pages_present``/``pages_absent`` states) +Added management of GitHub Pages sites (``github_repo.get_pages``/``setup_pages``/``update_pages``/``remove_pages`` and the ``github_repo.pages_present``/``pages_absent`` states) diff --git a/changelog/+scoped_modules.added.md b/changelog/+scoped_modules.added.md new file mode 100644 index 00000000..135631d5 --- /dev/null +++ b/changelog/+scoped_modules.added.md @@ -0,0 +1 @@ +Added scoped ``github_org`` and ``github_repo`` execution and state modules, which replace the organization- and repository-related functionality of the monolithic ``github`` modules. Unlike the legacy functions, they raise errors instead of logging them and returning ``False`` diff --git a/changelog/+workflow_permissions.added.md b/changelog/+workflow_permissions.added.md index 70cb9e51..e5695e6b 100644 --- a/changelog/+workflow_permissions.added.md +++ b/changelog/+workflow_permissions.added.md @@ -1 +1 @@ -Added management of default workflow permissions for the ``GITHUB_TOKEN`` (``github.get_workflow_permissions``/``set_workflow_permissions`` and the ``workflow_permissions_managed`` state) +Added management of default workflow permissions for the ``GITHUB_TOKEN`` (``github_repo.get_workflow_permissions``/``set_workflow_permissions`` and the ``github_repo.workflow_permissions_managed`` state) diff --git a/docs/ref/modules/index.rst b/docs/ref/modules/index.rst index 081527a6..88999e27 100644 --- a/docs/ref/modules/index.rst +++ b/docs/ref/modules/index.rst @@ -10,3 +10,5 @@ _________________ :toctree: github + github_org + github_repo diff --git a/docs/ref/modules/saltext.github.modules.github_org.rst b/docs/ref/modules/saltext.github.modules.github_org.rst new file mode 100644 index 00000000..67a98931 --- /dev/null +++ b/docs/ref/modules/saltext.github.modules.github_org.rst @@ -0,0 +1,5 @@ +``github_org`` +============== + +.. automodule:: saltext.github.modules.github_org + :members: diff --git a/docs/ref/modules/saltext.github.modules.github_repo.rst b/docs/ref/modules/saltext.github.modules.github_repo.rst new file mode 100644 index 00000000..41872c52 --- /dev/null +++ b/docs/ref/modules/saltext.github.modules.github_repo.rst @@ -0,0 +1,5 @@ +``github_repo`` +=============== + +.. automodule:: saltext.github.modules.github_repo + :members: diff --git a/docs/ref/states/index.rst b/docs/ref/states/index.rst index 89b97e99..0d3b8476 100644 --- a/docs/ref/states/index.rst +++ b/docs/ref/states/index.rst @@ -10,3 +10,5 @@ _____________ :toctree: github + github_org + github_repo diff --git a/docs/ref/states/saltext.github.states.github_org.rst b/docs/ref/states/saltext.github.states.github_org.rst new file mode 100644 index 00000000..5b0cc441 --- /dev/null +++ b/docs/ref/states/saltext.github.states.github_org.rst @@ -0,0 +1,5 @@ +``github_org`` +============== + +.. automodule:: saltext.github.states.github_org + :members: diff --git a/docs/ref/states/saltext.github.states.github_repo.rst b/docs/ref/states/saltext.github.states.github_repo.rst new file mode 100644 index 00000000..7eac79ce --- /dev/null +++ b/docs/ref/states/saltext.github.states.github_repo.rst @@ -0,0 +1,5 @@ +``github_repo`` +=============== + +.. automodule:: saltext.github.states.github_repo + :members: diff --git a/docs/ref/utils/index.rst b/docs/ref/utils/index.rst index f467b750..3ee36aa8 100644 --- a/docs/ref/utils/index.rst +++ b/docs/ref/utils/index.rst @@ -20,6 +20,7 @@ _________ utils.gh.labels utils.gh.milestones utils.gh.pages + utils.gh.profile utils.gh.pulls utils.gh.repos utils.gh.rulesets diff --git a/docs/ref/utils/saltext.github.utils.gh.profile.rst b/docs/ref/utils/saltext.github.utils.gh.profile.rst new file mode 100644 index 00000000..b4d53b12 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.profile.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.profile +=============================== + +.. automodule:: saltext.github.utils.gh.profile + :members: diff --git a/src/saltext/github/modules/github.py b/src/saltext/github/modules/github.py index e062d650..6560eb7d 100644 --- a/src/saltext/github/modules/github.py +++ b/src/saltext/github/modules/github.py @@ -3,6 +3,15 @@ :depends: PyGithub python module +.. deprecated:: 1.1.0 + + Managing organization- and repository-scoped resources via this module + is deprecated. Use the :py:mod:`github_org + ` and :py:mod:`github_repo + ` modules instead, which raise + errors instead of logging and returning ``False``. This module retains + functionality that is not tied to a single organization. + Configuration ------------- @@ -61,14 +70,9 @@ from saltext.github.utils import filter_unset try: - from saltext.github.utils.gh import actions as gh_actions - from saltext.github.utils.gh import client as gh_client - from saltext.github.utils.gh import deploy_keys as gh_deploy_keys - from saltext.github.utils.gh import environments as gh_environments from saltext.github.utils.gh import issues as gh_issues - from saltext.github.utils.gh import labels as gh_labels from saltext.github.utils.gh import milestones as gh_milestones - from saltext.github.utils.gh import pages as gh_pages + from saltext.github.utils.gh import profile as gh_profile from saltext.github.utils.gh import pulls as gh_pulls from saltext.github.utils.gh import repos as gh_repos from saltext.github.utils.gh import rulesets as gh_rulesets @@ -83,8 +87,6 @@ log = logging.getLogger(__name__) -API_URL_DEFAULT = "https://api.github.com" - __virtualname__ = "github" @@ -108,56 +110,23 @@ def _get_config_value(profile, config_name): config_name The configuration item's name to use to return configuration values. """ - if "gh_profile" not in __context__ or profile not in __context__["gh_profile"]: - if not __salt__["config.get"](profile): - raise CommandExecutionError( - f"Authentication information could not be found for the '{profile}' profile." - ) - __context__.setdefault("gh_profile", {})[profile] = {} - - cache = __context__["gh_profile"][profile] - if config_name not in cache: - # Unlike config.option, config.get resolves SDB URIs in scalar values, - # hence request each value separately and cache the rendered result. - cache[config_name] = __salt__["config.get"](f"{profile}:{config_name}", default=None) - - config_value = cache[config_name] - if config_value is None: - raise CommandExecutionError( - f"The '{config_name}' parameter was not found in the '{profile}' profile." - ) - - return config_value - - -def _get_api_url(profile): - """ - Return the configured GitHub API base URL, defaulting to the public one. - """ - try: - return _get_config_value(profile, "api_url").rstrip("/") - except CommandExecutionError: # pragma: no cover - return API_URL_DEFAULT + return gh_profile.get_config_value(profile, config_name, salt=__salt__, context=__context__) def _get_conn(profile): """ Return the GitHub connection, cached into __context__ for performance """ - token = _get_config_value(profile, "token") - org_name = _get_config_value(profile, "org_name") - api_url = _get_api_url(profile) - key = f"github.{token}:{org_name}:{api_url}" - - if key not in __context__: - __context__[key] = gh_client.GitHubConnection(token, org_name, api_url=api_url) - return __context__[key] + return gh_profile.get_conn(profile, salt=__salt__, context=__context__) def list_users(profile="github", ignore_cache=False): """ List all users within the organization. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_members ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -213,6 +182,9 @@ def add_user(name, profile="github"): """ Add a GitHub user. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.add_member ` instead. + name The user for which to obtain information. @@ -237,6 +209,9 @@ def remove_user(name, profile="github"): """ Remove a Github user by name. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.remove_member ` instead. + name The user for which to obtain information. @@ -261,6 +236,9 @@ def get_issue(issue_number, repo_name=None, profile="github", output="min"): """ Return information about a single issue in a named repository. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.get_issue ` instead. + issue_number The number of the issue to retrieve. @@ -308,6 +286,9 @@ def get_issue_comments(issue_number, repo_name=None, profile="github", since=Non """ Return information about the comments for a given issue in a named repository. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.get_issue_comments ` instead. + issue_number The number of the issue for which to retrieve comments. @@ -373,6 +354,9 @@ def get_issues( """ Returns information for all issues in a given repository, based on the search options. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.list_issues ` instead. + repo_name The name of the repository for which to list issues. This argument is required, either passed via the CLI, or defined in the configured @@ -479,6 +463,9 @@ def get_milestones( """ Return information about milestones for a given repository. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.list_milestones ` instead. + repo_name The name of the repository for which to list issues. This argument is required, either passed via the CLI, or defined in the configured @@ -541,6 +528,9 @@ def get_milestone(number=None, name=None, repo_name=None, profile="github", outp """ Return information about a single milestone in a named repository. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.get_milestone ` instead. + number The number of the milestone to retrieve. If provided, this option will be favored over ``name``. @@ -602,6 +592,9 @@ def get_repo_info(repo_name, profile="github", ignore_cache=False): """ Return information for a given repo. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.get_info ` instead. + repo_name The name of the repository. @@ -622,6 +615,9 @@ def get_repo_teams(repo_name, profile="github"): """ Return teams belonging to a repository. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.get_teams ` instead. + repo_name The name of the repository from which to retrieve teams. @@ -644,6 +640,9 @@ def list_repos(profile="github"): repositories within the organization Dependent upon the access rights of the profile token. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_repos ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -662,6 +661,9 @@ def list_private_repos(profile="github"): List private repositories within the organization. Dependent upon the access rights of the profile token. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_repos ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -679,6 +681,9 @@ def list_public_repos(profile="github"): """ List public repositories within the organization. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_repos ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -708,6 +713,9 @@ def add_repo( """ Create a new github repository. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.add_repo ` instead. + name The name of the team to be created. @@ -776,12 +784,14 @@ def edit_repo( has_issues=None, has_wiki=None, has_downloads=None, - default_branch=None, profile="github", ): """ Updates an existing Github repository. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.update ` instead. + name The name of the team to be created. @@ -804,11 +814,6 @@ def edit_repo( has_downloads Whether to enable downloads for this repository. - default_branch - The default branch of the repository. The branch must already exist. - - .. versionadded:: 1.1.0 - profile The name of the profile configuration to use. Defaults to ``github``. @@ -834,7 +839,6 @@ def edit_repo( has_issues=has_issues, has_wiki=has_wiki, has_downloads=has_downloads, - default_branch=default_branch, allow_privacy_change=allow_privacy_change, ) return True @@ -847,6 +851,9 @@ def remove_repo(name, profile="github"): """ Remove a Github repository. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.delete ` instead. + name The name of the repository to be removed. @@ -878,6 +885,9 @@ def get_team(name, profile="github"): Returns the team details if a team with the given name exists, or None otherwise. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.get_team ` instead. + name The team name for which to obtain information. @@ -904,6 +914,9 @@ def add_team( """ Create a new Github team within an organization. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.add_team ` instead. + name The name of the team to be created. @@ -948,6 +961,9 @@ def edit_team(name, description=None, privacy=None, permission=None, profile="gi """ Updates an existing Github team. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.edit_team ` instead. + name The name of the team to be edited. @@ -994,6 +1010,9 @@ def remove_team(name, profile="github"): """ Remove a github team. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.remove_team ` instead. + name The name of the team to be removed. @@ -1024,6 +1043,9 @@ def list_team_repos(team_name, profile="github", ignore_cache=False): Gets the repo details for a given team as a dict from repo_name to repo details. Note that repo names are always in lower case. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_team_repos ` instead. + team_name The name of the team from which to list repos. @@ -1056,6 +1078,9 @@ def add_team_repo(repo_name, team_name, profile="github", permission=None): """ Adds a repository to a team with team_name. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.add_team_repo ` instead. + repo_name The name of the repository to add. @@ -1100,6 +1125,9 @@ def remove_team_repo(repo_name, team_name, profile="github"): """ Removes a repository from a team with team_name. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.remove_team_repo ` instead. + repo_name The name of the repository to remove. @@ -1131,6 +1159,9 @@ def list_team_members(team_name, profile="github", ignore_cache=False): """ Gets the names of team members in lower case. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_team_members ` instead. + team_name The name of the team from which to list members. @@ -1163,6 +1194,9 @@ def list_members_without_mfa(profile="github", ignore_cache=False): """ List all members (in lower case) without MFA turned on. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_members_without_mfa ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -1183,6 +1217,9 @@ def is_team_member(name, team_name, profile="github"): Returns True if the github user is in the team with team_name, or False otherwise. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.is_team_member ` instead. + name The name of the user whose membership to check. @@ -1206,6 +1243,9 @@ def add_team_member(name, team_name, profile="github"): """ Adds a team member to a team with team_name. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.add_team_member ` instead. + name The name of the team member to add. @@ -1241,6 +1281,9 @@ def remove_team_member(name, team_name, profile="github"): """ Removes a team member from a team with team_name. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.remove_team_member ` instead. + name The name of the team member to remove. @@ -1272,6 +1315,9 @@ def list_teams(profile="github", ignore_cache=False): """ Lists all teams with the organization. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_teams ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -1302,6 +1348,9 @@ def get_prs( Returns information for all pull requests in a given repository, based on the search options provided. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.list_pulls ` instead. + repo_name The name of the repository for which to list pull requests. This argument is required, either passed via the CLI, or defined in the @@ -1459,6 +1508,10 @@ def get_ruleset(profile="github", **kwargs): """ Get information of specific ruleset. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.get_ruleset ` or + :py:func:`github_repo.get_ruleset ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -1503,6 +1556,10 @@ def delete_ruleset(profile="github", **kwargs): """ Delete ruleset + .. deprecated:: 1.1.0 + Use :py:func:`github_org.delete_ruleset ` or + :py:func:`github_repo.delete_ruleset ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -1547,6 +1604,10 @@ def update_ruleset(profile="github", **kwargs): """ Update ruleset + .. deprecated:: 1.1.0 + Use :py:func:`github_org.update_ruleset ` or + :py:func:`github_repo.update_ruleset ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -1600,6 +1661,10 @@ def add_ruleset(profile="github", **kwargs): """ Add ruleset + .. deprecated:: 1.1.0 + Use :py:func:`github_org.add_ruleset ` or + :py:func:`github_repo.add_ruleset ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -1646,6 +1711,10 @@ def list_rulesets(profile="github", *, per_page=None, **kwargs): """ List rulesets + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_rulesets ` or + :py:func:`github_repo.list_rulesets ` instead. + profile The name of the profile configuration to use. Defaults to ``github``. @@ -1690,846 +1759,3 @@ def list_rulesets(profile="github", *, per_page=None, **kwargs): return _ruleset_error_ret(err) except CommandExecutionError: return {"result": False, "comment": "Error getting rulesets"} - - -def _resolve_repo_name(repo_name, profile): - """ - Default ``repo_name`` to the profile configuration when unset. - """ - if repo_name is None: - return _get_config_value(profile, "repo_name") - return repo_name - - -def list_deploy_keys(repo_name=None, profile="github"): - """ - List the deploy keys of a repository. - - .. versionadded:: 1.1.0 - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.list_deploy_keys my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_deploy_keys.list_all(_get_conn(profile), repo_name) - - -def get_deploy_key(key_id, repo_name=None, profile="github"): - """ - Return the data of a single deploy key of a repository. - - .. versionadded:: 1.1.0 - - key_id - The ID of the deploy key. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.get_deploy_key 12345 repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_deploy_keys.get(_get_conn(profile), repo_name, key_id) - - -def add_deploy_key(title, key, read_only=False, repo_name=None, profile="github"): - """ - Add a deploy key to a repository. Returns the data of the created key. - - Note that deploy keys are immutable. To modify one, remove it and - add a replacement. - - .. versionadded:: 1.1.0 - - title - The display title of the deploy key. - - key - The SSH public key. - - read_only - Deny write access to the repository for this key. Defaults to false. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.add_deploy_key autorelease 'ssh-ed25519 AAAA...' read_only=false repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_deploy_keys.create( - _get_conn(profile), repo_name, title, key, read_only=bool(read_only) - ) - - -def remove_deploy_key(key_id, repo_name=None, profile="github"): - """ - Remove a deploy key from a repository. - - .. versionadded:: 1.1.0 - - key_id - The ID of the deploy key. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.remove_deploy_key 12345 repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - gh_deploy_keys.delete(_get_conn(profile), repo_name, key_id) - return True - - -def list_actions_secrets(repo_name=None, profile="github"): - """ - List the Actions secrets of a repository. Secret values are write-only, - so this only returns metadata (``name``, ``created_at``, ``updated_at``). - - .. versionadded:: 1.1.0 - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.list_actions_secrets my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_actions.list_secrets(_get_conn(profile), repo_name) - - -def get_actions_secret(name, repo_name=None, profile="github"): - """ - Return the metadata of a single Actions secret of a repository. - The value itself is write-only and cannot be retrieved. - - .. versionadded:: 1.1.0 - - name - The name of the secret. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.get_actions_secret MY_SECRET repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_actions.get_secret(_get_conn(profile), repo_name, name) - - -def set_actions_secret(name, value, repo_name=None, profile="github"): - """ - Create or update an Actions secret of a repository. The value is - encrypted to the repository public key before leaving the minion. - - .. versionadded:: 1.1.0 - - name - The name of the secret. - - value - The plaintext secret value. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.set_actions_secret MY_SECRET hunter2 repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - gh_actions.set_secret(_get_conn(profile), repo_name, name, value) - return True - - -def remove_actions_secret(name, repo_name=None, profile="github"): - """ - Remove an Actions secret from a repository. - - .. versionadded:: 1.1.0 - - name - The name of the secret. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.remove_actions_secret MY_SECRET repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - gh_actions.delete_secret(_get_conn(profile), repo_name, name) - return True - - -def get_workflow_permissions(repo_name=None, profile="github"): - """ - Return the default workflow permissions granted to the ``GITHUB_TOKEN`` - in a repository, a mapping with ``default_workflow_permissions`` - (``read``/``write``) and ``can_approve_pull_request_reviews``. - - .. versionadded:: 1.1.0 - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.get_workflow_permissions my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_actions.get_workflow_permissions(_get_conn(profile), repo_name) - - -def set_workflow_permissions( - default_workflow_permissions=None, - can_approve_pull_request_reviews=None, - repo_name=None, - profile="github", -): - """ - Update the default workflow permissions granted to the ``GITHUB_TOKEN`` - in a repository. - - .. versionadded:: 1.1.0 - - default_workflow_permissions - The default permissions for the ``GITHUB_TOKEN``, either ``read`` - or ``write``. - - can_approve_pull_request_reviews - Whether GitHub Actions can approve pull requests. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.set_workflow_permissions read can_approve_pull_request_reviews=true repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - gh_actions.set_workflow_permissions( - _get_conn(profile), - repo_name, - default_workflow_permissions=default_workflow_permissions, - can_approve_pull_request_reviews=can_approve_pull_request_reviews, - ) - return True - - -def list_environments(repo_name=None, profile="github"): - """ - List the environments of a repository. - - .. versionadded:: 1.1.0 - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.list_environments my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_environments.list_all(_get_conn(profile), repo_name) - - -def get_environment(name, repo_name=None, profile="github"): - """ - Return the data of a single environment of a repository. - - .. versionadded:: 1.1.0 - - name - The name of the environment. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.get_environment release repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_environments.get(_get_conn(profile), repo_name, name) - - -def set_environment( - name, - wait_timer=None, - prevent_self_review=None, - reviewers=None, - deployment_branch_policy=..., - repo_name=None, - profile="github", -): - """ - Create or update an environment of a repository. - Returns the data of the environment. - - .. versionadded:: 1.1.0 - - name - The name of the environment. - - wait_timer - The amount of time to delay a job after the job is initially - triggered, in minutes. - - prevent_self_review - Whether or not a user who created the job is prevented from - approving their own job. - - reviewers - The people or teams that may review jobs that reference the - environment, as a list of mappings with ``type`` (``User`` or - ``Team``) and ``id``. - - deployment_branch_policy - The type of deployment branch policy for this environment, either - ``none`` to allow all branches or a mapping with - ``protected_branches`` and ``custom_branch_policies`` booleans. - Branch name patterns for custom policies are managed separately, - see :py:func:`github.add_deployment_branch_policy - `. - If not passed at all, the current policy is left unchanged. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.set_environment release repo_name=my-repo \\ - deployment_branch_policy='{protected_branches: false, custom_branch_policies: true}' - """ - repo_name = _resolve_repo_name(repo_name, profile) - conn = _get_conn(profile) - if deployment_branch_policy is ...: - # PUT replaces the whole environment config, so carry over the - # current policy when the caller does not specify one. - try: - current = gh_environments.get(conn, repo_name, name) - deployment_branch_policy = gh_environments.normalize(current)[ - "deployment_branch_policy" - ] - except GitHubNotFoundError: - deployment_branch_policy = None - return gh_environments.set_( - conn, - repo_name, - name, - wait_timer=wait_timer, - prevent_self_review=prevent_self_review, - reviewers=reviewers, - deployment_branch_policy=deployment_branch_policy, - ) - - -def remove_environment(name, repo_name=None, profile="github"): - """ - Remove an environment from a repository. - - .. versionadded:: 1.1.0 - - name - The name of the environment. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.remove_environment release repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - gh_environments.delete(_get_conn(profile), repo_name, name) - return True - - -def list_deployment_branch_policies(environment, repo_name=None, profile="github"): - """ - List the deployment branch policies of an environment. - - .. versionadded:: 1.1.0 - - environment - The name of the environment. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.list_deployment_branch_policies release repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_environments.list_branch_policies(_get_conn(profile), repo_name, environment) - - -def add_deployment_branch_policy( - environment, name, type_="branch", repo_name=None, profile="github" -): - r""" - Add a deployment branch (or tag) policy to an environment. - Returns the data of the created policy. - - The environment's ``deployment_branch_policy`` must have - ``custom_branch_policies`` enabled. - - .. versionadded:: 1.1.0 - - environment - The name of the environment. - - name - The name pattern that branches (or tags) must match in order - to deploy to the environment. - - type\_ - Whether this rule targets a ``branch`` or ``tag``. - Defaults to ``branch``. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.add_deployment_branch_policy release main repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_environments.create_branch_policy( - _get_conn(profile), repo_name, environment, name, type_=type_ - ) - - -def remove_deployment_branch_policy(environment, policy_id, repo_name=None, profile="github"): - """ - Remove a deployment branch policy from an environment. - - .. versionadded:: 1.1.0 - - environment - The name of the environment. - - policy_id - The ID of the branch policy. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.remove_deployment_branch_policy release 12345 repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - gh_environments.delete_branch_policy(_get_conn(profile), repo_name, environment, policy_id) - return True - - -def list_labels(repo_name=None, profile="github"): - """ - List the labels of a repository. - - .. versionadded:: 1.1.0 - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.list_labels my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_labels.list_all(_get_conn(profile), repo_name) - - -def get_label(name, repo_name=None, profile="github"): - """ - Return the data of a single label of a repository. - - .. versionadded:: 1.1.0 - - name - The name of the label. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.get_label bug repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_labels.get(_get_conn(profile), repo_name, name) - - -def add_label(name, color=None, description=None, repo_name=None, profile="github"): - """ - Create a label in a repository. Returns the data of the created label. - - .. versionadded:: 1.1.0 - - name - The name of the label. - - color - The hexadecimal color code for the label, without the leading ``#``. - If unset, GitHub assigns a random one. - - description - A short description of the label. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.add_label release color=00ff00 repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_labels.create( - _get_conn(profile), repo_name, name, color=color, description=description - ) - - -def edit_label(name, new_name=None, color=None, description=None, repo_name=None, profile="github"): - """ - Update an existing label of a repository. - Returns the data of the updated label. - - .. versionadded:: 1.1.0 - - name - The (current) name of the label. - - new_name - A new name for the label. - - color - The hexadecimal color code for the label, without the leading ``#``. - - description - A short description of the label. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.edit_label release color=00ff00 repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_labels.update( - _get_conn(profile), - repo_name, - name, - new_name=new_name, - color=color, - description=description, - ) - - -def remove_label(name, repo_name=None, profile="github"): - """ - Delete a label from a repository. - - .. versionadded:: 1.1.0 - - name - The name of the label. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.remove_label release repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - gh_labels.delete(_get_conn(profile), repo_name, name) - return True - - -def get_pages(repo_name=None, profile="github"): - """ - Return information about the GitHub Pages site of a repository. - - .. versionadded:: 1.1.0 - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.get_pages my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_pages.get(_get_conn(profile), repo_name) - - -def setup_pages( - build_type=None, source_branch=None, source_path=None, repo_name=None, profile="github" -): - """ - Configure a GitHub Pages site for a repository. - Returns the data of the created site. - - .. versionadded:: 1.1.0 - - build_type - The process by which the site is built, either ``legacy`` - (deploy from a branch) or ``workflow`` (GitHub Actions). - - source_branch - For ``legacy`` builds, the repository branch to publish from. - - source_path - For ``legacy`` builds, the repository directory to publish from, - either ``/`` or ``/docs``. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.setup_pages build_type=workflow repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - return gh_pages.create( - _get_conn(profile), - repo_name, - build_type=build_type, - source=gh_pages.build_source(source_branch, source_path), - ) - - -def update_pages( - build_type=None, - source_branch=None, - source_path=None, - https_enforced=None, - repo_name=None, - profile="github", -): - """ - Update the GitHub Pages site configuration of a repository. - - .. versionadded:: 1.1.0 - - build_type - The process by which the site is built, either ``legacy`` - (deploy from a branch) or ``workflow`` (GitHub Actions). - - source_branch - For ``legacy`` builds, the repository branch to publish from. - - source_path - For ``legacy`` builds, the repository directory to publish from, - either ``/`` or ``/docs``. - - https_enforced - Whether HTTPS is enforced for the site. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.update_pages build_type=workflow repo_name=my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - gh_pages.update( - _get_conn(profile), - repo_name, - build_type=build_type, - source=gh_pages.build_source(source_branch, source_path), - https_enforced=https_enforced, - ) - return True - - -def remove_pages(repo_name=None, profile="github"): - """ - Delete the GitHub Pages site of a repository. - - .. versionadded:: 1.1.0 - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github.remove_pages my-repo - """ - repo_name = _resolve_repo_name(repo_name, profile) - gh_pages.delete(_get_conn(profile), repo_name) - return True diff --git a/src/saltext/github/modules/github_org.py b/src/saltext/github/modules/github_org.py new file mode 100644 index 00000000..3eea7fb0 --- /dev/null +++ b/src/saltext/github/modules/github_org.py @@ -0,0 +1,721 @@ +""" +Manage a GitHub organization: members, repositories, teams and +organization rulesets. + +.. versionadded:: 1.1.0 + +Unlike the legacy :py:mod:`github ` module, +the functions in this module raise errors (usually subclasses of +``CommandExecutionError``) instead of logging them and returning ``False``. + +:depends: PyGithub python module + +Configuration +------------- + +This module uses the same configuration profiles as the +:py:mod:`github ` module: + +.. code-block:: yaml + + github: + token: abc1234 + org_name: my_organization + + # optional: it can be dangerous to change the privacy of a repository + # in an automated way. set this to True to allow privacy modifications + allow_repo_privacy_changes: False + + # optional: the base URL of the GitHub API. Set this when targeting + # a GitHub Enterprise Server instance, e.g. + # https://github.example.com/api/v3 + api_url: https://api.github.com +""" + +import logging + +from salt.exceptions import CommandExecutionError + +try: + from saltext.github.utils.gh import profile as gh_profile + from saltext.github.utils.gh import repos as gh_repos + from saltext.github.utils.gh import rulesets as gh_rulesets + from saltext.github.utils.gh import teams as gh_teams + from saltext.github.utils.gh import users as gh_users + from saltext.github.utils.gh.exceptions import GitHubNotFoundError + + HAS_LIBS = True +except ImportError: # pragma: no cover + HAS_LIBS = False + +log = logging.getLogger(__name__) + +__virtualname__ = "github_org" + + +def __virtual__(): + if HAS_LIBS: + return __virtualname__ + return ( # pragma: no cover + False, + "The github_org execution module cannot be loaded: PyGithub library is not installed.", + ) + + +def _get_conn(profile): + return gh_profile.get_conn(profile, salt=__salt__, context=__context__) + + +def _get_team(conn, name): + """ + Return a team's cached details, raising when it does not exist. + """ + team = gh_teams.get(conn, name) + if team is None: + raise GitHubNotFoundError(f"Team {name} does not exist in organization {conn.org_name}.") + return team + + +def list_members(profile="github", ignore_cache=False): + """ + List the logins of all members of the organization. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + ignore_cache + Bypasses the use of cached members. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.list_members + """ + return gh_users.list_members(_get_conn(profile), ignore_cache=ignore_cache) + + +def list_members_without_mfa(profile="github", ignore_cache=False): + """ + List the logins (lowercase) of all organization members without + MFA turned on. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + ignore_cache + Bypasses the use of cached members. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.list_members_without_mfa + """ + return gh_users.list_members_without_mfa(_get_conn(profile), ignore_cache=ignore_cache) + + +def get_member(name, profile="github"): + """ + Return information about a user, including their organization + membership state (``in_org`` and ``membership_state``). + + name + The login of the user. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.get_member github-handle + """ + return gh_users.get_user_info(_get_conn(profile), name) + + +def add_member(name, profile="github"): + """ + Invite a user into the organization. Returns whether the resulting + membership is ``pending`` (i.e. an invitation that needs to be + accepted first). + + name + The login of the user to invite. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.add_member github-handle + """ + return gh_users.invite_member(_get_conn(profile), name) + + +def remove_member(name, profile="github"): + """ + Remove a user from the organization. + + name + The login of the user to remove. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.remove_member github-handle + """ + if not gh_users.remove_member(_get_conn(profile), name): + raise CommandExecutionError(f"Failed to remove member {name} from the organization.") + return True + + +def list_repos(private=None, profile="github"): + """ + List the names of all repositories within the organization the + profile token can see. + + private + Only list private (``true``) or public (``false``) repositories. + By default, all repositories are listed. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.list_repos + salt myminion github_org.list_repos private=true + """ + return [ + repo.name + for repo in gh_repos.list_all(_get_conn(profile)) + if private is None or repo.private is private + ] + + +def add_repo( + name, + description=None, + homepage=None, + private=None, + has_issues=None, + has_wiki=None, + has_downloads=None, + auto_init=None, + gitignore_template=None, + license_template=None, + profile="github", +): + """ + Create a new repository in the organization. + Returns information about the created repository. + + name + The name of the repository to create. + + description + The description of the repository. + + homepage + The URL with more information about the repository. + + private + The visiblity of the repository. + + has_issues + Whether to enable issues for this repository. + + has_wiki + Whether to enable the wiki for this repository. + + has_downloads + Whether to enable downloads for this repository. + + auto_init + Whether to create an initial commit with an empty README. + + gitignore_template + The desired language or platform for a .gitignore, e.g "Haskell". + + license_template + The desired LICENSE template to apply, e.g "mit" or "mozilla". + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.add_repo 'repo_name' + """ + return gh_repos.create( + _get_conn(profile), + name, + description=description, + homepage=homepage, + private=private, + has_issues=has_issues, + has_wiki=has_wiki, + has_downloads=has_downloads, + auto_init=auto_init, + gitignore_template=gitignore_template, + license_template=license_template, + ) + + +def list_teams(profile="github", ignore_cache=False): + """ + List all teams of the organization as a mapping of team name + to team details. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + ignore_cache + Bypasses the use of cached teams. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.list_teams + """ + return gh_teams.list_all(_get_conn(profile), ignore_cache=ignore_cache) + + +def get_team(name, profile="github"): + """ + Return the details of a single team. + + name + The name of the team. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.get_team 'team_name' + """ + return _get_team(_get_conn(profile), name) + + +def add_team( + name, + description=None, + repo_names=None, + privacy=None, + permission=None, + profile="github", +): + """ + Create a new team within the organization. + + name + The name of the team to be created. + + description + The description of the team. + + repo_names + The names of repositories to add the team to. + + privacy + The level of privacy for the team, can be ``secret`` or ``closed``. + + permission + The default permission for new repositories added to the team, can be + ``pull``, ``push`` or ``admin``. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.add_team 'team_name' + """ + gh_teams.create( + _get_conn(profile), + name, + description=description, + repo_names=repo_names, + privacy=privacy, + permission=permission, + ) + return True + + +def edit_team(name, description=None, privacy=None, permission=None, profile="github"): + """ + Update an existing team. + + name + The name of the team to be edited. + + description + The description of the team. + + privacy + The level of privacy for the team, can be ``secret`` or ``closed``. + + permission + The default permission for new repositories added to the team, can be + ``pull``, ``push`` or ``admin``. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.edit_team 'team_name' description='Team description' + """ + conn = _get_conn(profile) + gh_teams.update( + conn, + _get_team(conn, name), + name, + description=description, + privacy=privacy, + permission=permission, + ) + return True + + +def remove_team(name, profile="github"): + """ + Remove a team from the organization. + + name + The name of the team to be removed. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.remove_team 'team_name' + """ + conn = _get_conn(profile) + gh_teams.delete(conn, _get_team(conn, name)) + return True + + +def list_team_repos(team_name, profile="github", ignore_cache=False): + """ + Return the repositories of a team as a mapping of (lowercase) + repository name to repository details. + + team_name + The name of the team from which to list repos. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + ignore_cache + Bypasses the use of cached team repos. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.list_team_repos 'team_name' + """ + conn = _get_conn(profile) + return gh_teams.list_repos(conn, _get_team(conn, team_name), ignore_cache=ignore_cache) + + +def add_team_repo(repo_name, team_name, permission=None, profile="github"): + """ + Add a repository to a team, or update the permission the team has + on it. + + repo_name + The name of the repository to add. + + team_name + The name of the team to add the repository to. + + permission + The permission for team members within the repository, can be ``pull``, + ``push`` or ``admin``. If not specified, the default permission specified + on the team will be used. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.add_team_repo 'my_repo' 'team_name' + """ + conn = _get_conn(profile) + if not gh_teams.add_repo(conn, _get_team(conn, team_name), repo_name, permission=permission): + raise CommandExecutionError( + f"Failed setting permission {permission} on repo {repo_name} for team {team_name}." + ) + return True + + +def remove_team_repo(repo_name, team_name, profile="github"): + """ + Remove a repository from a team. + + repo_name + The name of the repository to remove. + + team_name + The name of the team to remove the repository from. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.remove_team_repo 'my_repo' 'team_name' + """ + conn = _get_conn(profile) + if not gh_teams.remove_repo(conn, _get_team(conn, team_name), repo_name): + raise CommandExecutionError(f"Failed to remove repo {repo_name} from team {team_name}.") + return True + + +def list_team_members(team_name, profile="github", ignore_cache=False): + """ + Return the logins of a team's members, in lower case. + + team_name + The name of the team from which to list members. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + ignore_cache + Bypasses the use of cached team members. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.list_team_members 'team_name' + """ + conn = _get_conn(profile) + return gh_teams.list_members(conn, _get_team(conn, team_name), ignore_cache=ignore_cache) + + +def is_team_member(name, team_name, profile="github"): + """ + Return whether a user is a member of a team. + + name + The login of the user whose membership to check. + + team_name + The name of the team to check membership in. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.is_team_member 'user_name' 'team_name' + """ + return name.lower() in list_team_members(team_name, profile=profile) + + +def add_team_member(name, team_name, profile="github"): + """ + Add an organization member to a team. + + name + The login of the user to add. + + team_name + The name of the team to add the user to. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.add_team_member 'user_name' 'team_name' + """ + conn = _get_conn(profile) + gh_teams.add_member(conn, _get_team(conn, team_name), name) + return True + + +def remove_team_member(name, team_name, profile="github"): + """ + Remove a member from a team. + + name + The login of the user to remove. + + team_name + The name of the team to remove the user from. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.remove_team_member 'user_name' 'team_name' + """ + conn = _get_conn(profile) + if not gh_teams.remove_member(conn, _get_team(conn, team_name), name): + raise CommandExecutionError(f"Failed to remove member {name} from team {team_name}.") + return True + + +def list_rulesets(per_page=None, headers=None, profile="github"): + """ + List all rulesets of the organization. + + per_page + Pagination size, maximum 100. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.list_rulesets + """ + conn = _get_conn(profile) + return gh_rulesets.list_all( + conn, "org", org_name=conn.org_name, headers=headers, per_page=per_page + ) + + +def get_ruleset(ruleset_id, headers=None, profile="github"): + """ + Return the data of a single organization ruleset. + + ruleset_id + The ID of the ruleset. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.get_ruleset 1234 + """ + conn = _get_conn(profile) + return gh_rulesets.get(conn, ruleset_id, "org", org_name=conn.org_name, headers=headers) + + +def add_ruleset(ruleset_params, headers=None, profile="github"): + """ + Create a new organization ruleset. + Returns the data of the created ruleset. + + ruleset_params + The ruleset definition. Must contain at least ``name`` and + ``enforcement``. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.add_ruleset '{name: test, enforcement: active}' + """ + conn = _get_conn(profile) + return gh_rulesets.create(conn, ruleset_params, "org", org_name=conn.org_name, headers=headers) + + +def update_ruleset(ruleset_id, ruleset_params, headers=None, profile="github"): + """ + Update an existing organization ruleset. + Returns the data of the updated ruleset. + + ruleset_id + The ID of the ruleset. + + ruleset_params + The updated ruleset definition. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.update_ruleset 1234 '{enforcement: disabled}' + """ + conn = _get_conn(profile) + return gh_rulesets.update( + conn, ruleset_id, ruleset_params, "org", org_name=conn.org_name, headers=headers + ) + + +def delete_ruleset(ruleset_id, headers=None, profile="github"): + """ + Delete an organization ruleset. + + ruleset_id + The ID of the ruleset. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_org.delete_ruleset 1234 + """ + conn = _get_conn(profile) + gh_rulesets.delete(conn, ruleset_id, "org", org_name=conn.org_name, headers=headers) + return True diff --git a/src/saltext/github/modules/github_repo.py b/src/saltext/github/modules/github_repo.py new file mode 100644 index 00000000..c0f36bc4 --- /dev/null +++ b/src/saltext/github/modules/github_repo.py @@ -0,0 +1,1519 @@ +""" +Manage a single GitHub repository within an organization: settings, +teams, issues, milestones, pull requests, rulesets, deploy keys, +Actions secrets and settings, environments, labels and Pages. + +.. versionadded:: 1.1.0 + +Unlike the legacy :py:mod:`github ` module, +the functions in this module raise errors (usually subclasses of +``CommandExecutionError``) instead of logging them and returning ``False``. + +:depends: PyGithub python module + +Configuration +------------- + +This module uses the same configuration profiles as the +:py:mod:`github ` module. All functions +operate on the repository named in ``repo_name``, which can also be +set in the profile: + +.. code-block:: yaml + + github: + token: abc1234 + org_name: my_organization + + # optional: the default repository to operate on when no + # repo_name is passed. + repo_name: my_repo + + # optional: it can be dangerous to change the privacy of a repository + # in an automated way. set this to True to allow privacy modifications + allow_repo_privacy_changes: False + + # optional: the base URL of the GitHub API. Set this when targeting + # a GitHub Enterprise Server instance, e.g. + # https://github.example.com/api/v3 + api_url: https://api.github.com +""" + +import logging + +from salt.exceptions import CommandExecutionError + +try: + from saltext.github.utils.gh import actions as gh_actions + from saltext.github.utils.gh import deploy_keys as gh_deploy_keys + from saltext.github.utils.gh import environments as gh_environments + from saltext.github.utils.gh import issues as gh_issues + from saltext.github.utils.gh import labels as gh_labels + from saltext.github.utils.gh import milestones as gh_milestones + from saltext.github.utils.gh import pages as gh_pages + from saltext.github.utils.gh import profile as gh_profile + from saltext.github.utils.gh import pulls as gh_pulls + from saltext.github.utils.gh import repos as gh_repos + from saltext.github.utils.gh import rulesets as gh_rulesets + from saltext.github.utils.gh.exceptions import GitHubNotFoundError + + HAS_LIBS = True +except ImportError: # pragma: no cover + HAS_LIBS = False + +log = logging.getLogger(__name__) + +__virtualname__ = "github_repo" + + +def __virtual__(): + if HAS_LIBS: + return __virtualname__ + return ( # pragma: no cover + False, + "The github_repo execution module cannot be loaded: PyGithub library is not installed.", + ) + + +def _get_conn(profile): + return gh_profile.get_conn(profile, salt=__salt__, context=__context__) + + +def _get_config_value(profile, config_name): + return gh_profile.get_config_value(profile, config_name, salt=__salt__, context=__context__) + + +def get_info(repo_name=None, ignore_cache=False, profile="github"): + """ + Return information about the repository. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + ignore_cache + Bypasses the use of cached repository information. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_info my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_repos.get_info(_get_conn(profile), repo_name, ignore_cache=ignore_cache) + + +def get_teams(repo_name=None, profile="github"): + """ + Return the teams the repository belongs to. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_teams my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_repos.get_teams(_get_conn(profile), repo_name) + + +def update( + repo_name=None, + description=None, + homepage=None, + private=None, + has_issues=None, + has_wiki=None, + has_downloads=None, + default_branch=None, + profile="github", +): + """ + Update the repository settings. + Returns the refreshed repository information. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + description + The description of the repository. + + homepage + The URL with more information about the repository. + + private + The visiblity of the repository. Changing this requires + ``allow_repo_privacy_changes`` to be enabled in the profile. + + has_issues + Whether to enable issues for this repository. + + has_wiki + Whether to enable the wiki for this repository. + + has_downloads + Whether to enable downloads for this repository. + + default_branch + The default branch of the repository. The branch must already exist. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.update my-repo description='Managed by Salt' + """ + repo_name = _resolve_repo_name(repo_name, profile) + try: + allow_privacy_change = _get_config_value(profile, "allow_repo_privacy_changes") + except CommandExecutionError: + allow_privacy_change = False + return gh_repos.update( + _get_conn(profile), + repo_name, + description=description, + homepage=homepage, + private=private, + has_issues=has_issues, + has_wiki=has_wiki, + has_downloads=has_downloads, + default_branch=default_branch, + allow_privacy_change=allow_privacy_change, + ) + + +def delete(repo_name=None, profile="github"): + """ + Delete the repository. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.delete my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_repos.delete(_get_conn(profile), repo_name) + return True + + +def list_issues( + repo_name=None, + milestone=None, + state="open", + assignee=None, + creator=None, + mentioned=None, + labels=None, + sort="created", + direction="desc", + since=None, + output="min", + per_page=None, + profile="github", +): + """ + Return the issues in the repository, based on the search options. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + milestone + The number of a GitHub milestone, or a string of either ``*`` or + ``none``. + + state + Indicates the state of the issues to return. Can be either ``open``, + ``closed``, or ``all``. Default is ``open``. + + assignee + Can be the name of a user. Pass in ``none`` (as a string) for issues + with no assigned user or ``*`` for issues assigned to any user. + + creator + The user that created the issue. + + mentioned + A user that's mentioned in the issue. + + labels + A string of comma separated label names. For example, ``bug,ui,@high``. + + sort + What to sort results by. Can be either ``created``, ``updated``, or + ``comments``. Default is ``created``. + + direction + The direction of the sort. Can be either ``asc`` or ``desc``. Default + is ``desc``. + + since + Only issues updated at or after this time are returned. This is a + timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. + + output + The amount of data returned for each issue. Defaults to ``min``. + Change to ``full`` to see all issue output. + + per_page + Pagination size, maximum 100. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.list_issues my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + issues = gh_issues.list_all( + _get_conn(profile), + repo_name, + milestone=milestone, + state=state, + assignee=assignee, + creator=creator, + mentioned=mentioned, + labels=labels, + sort=sort, + direction=direction, + since=since, + per_page=per_page, + ) + if output == "full": + return issues + return [gh_issues.format_issue(issue) for issue in issues or []] + + +def get_issue(number, repo_name=None, output="min", profile="github"): + """ + Return information about a single issue in the repository. + + number + The number of the issue to retrieve. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + output + The amount of data returned for the issue. Defaults to ``min``. + Change to ``full`` to see all issue output. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_issue 514 + """ + repo_name = _resolve_repo_name(repo_name, profile) + issue = gh_issues.get(_get_conn(profile), repo_name, number) + if output == "full": + return issue + return gh_issues.format_issue(issue) + + +def get_issue_comments(number, repo_name=None, since=None, output="min", profile="github"): + """ + Return the comments of an issue in the repository. + + number + The number of the issue for which to retrieve comments. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + since + Only comments updated at or after this time are returned. This is a + timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. + + output + The amount of data returned for each comment. Defaults to ``min``. + Change to ``full`` to see all comment output. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_issue_comments 514 + """ + repo_name = _resolve_repo_name(repo_name, profile) + comments = gh_issues.get_comments(_get_conn(profile), repo_name, number, since=since) + if output == "full": + return comments + return [ + { + "id": comment.get("id"), + "created_at": comment.get("created_at"), + "updated_at": comment.get("updated_at"), + "user_login": comment.get("user").get("login"), + } + for comment in comments or [] + ] + + +def list_milestones( + repo_name=None, + state="open", + sort="due_on", + direction="asc", + output="min", + per_page=None, + profile="github", +): + """ + Return the milestones of the repository. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + state + The state of the milestones. Either ``open``, ``closed``, or ``all``. + Default is ``open``. + + sort + What to sort results by. Either ``due_on`` or ``completeness``. Default + is ``due_on``. + + direction + The direction of the sort. Either ``asc`` or ``desc``. Default is ``asc``. + + output + The amount of data returned for each milestone. Defaults to ``min``. + Change to ``full`` to see all milestone output. + + per_page + Pagination size, maximum 100. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.list_milestones my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + milestones = gh_milestones.list_all( + _get_conn(profile), + repo_name, + state=state, + sort=sort, + direction=direction, + per_page=per_page, + ) + if output == "full": + return milestones + return [gh_milestones.format_milestone(milestone) for milestone in milestones or []] + + +def get_milestone(number=None, name=None, repo_name=None, output="min", profile="github"): + """ + Return information about a single milestone in the repository. + + number + The number of the milestone to retrieve. If provided, this option + will be favored over ``name``. + + name + The title of the milestone to retrieve. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + output + The amount of data returned for the milestone. Defaults to ``min``. + Change to ``full`` to see all milestone output. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_milestone 72 + salt myminion github_repo.get_milestone name=my_milestone + """ + if not (number or name): + raise CommandExecutionError("Either a milestone 'name' or 'number' must be provided.") + + repo_name = _resolve_repo_name(repo_name, profile) + if number: + milestone = gh_milestones.get(_get_conn(profile), repo_name, number) + if output == "full": + return milestone + return gh_milestones.format_milestone(milestone) + + milestones = gh_milestones.list_all(_get_conn(profile), repo_name, state="all") + for milestone in milestones or []: + if milestone.get("title") == name: + if output == "full": + return milestone + return gh_milestones.format_milestone(milestone) + raise GitHubNotFoundError(f"Milestone {name} was not found in repository {repo_name}.") + + +def list_pulls( + repo_name=None, + state="open", + head=None, + base=None, + sort="created", + direction="desc", + output="min", + per_page=None, + profile="github", +): + """ + Return the pull requests in the repository, based on the search + options provided. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + state + Indicates the state of the pull requests to return. Can be either + ``open``, ``closed``, or ``all``. Default is ``open``. + + head + Filter pull requests by head user and branch name in the format of + ``user:ref-name``. + + base + Filter pulls by base branch name. Example: ``gh-pages``. + + sort + What to sort results by. Can be either ``created``, ``updated``, + ``popularity`` (comment count), or ``long-running`` (age, filtering + by pull requests updated within the last month). Default is ``created``. + + direction + The direction of the sort. Can be either ``asc`` or ``desc``. Default + is ``desc``. + + output + The amount of data returned for each pull request. Defaults to ``min``. + Change to ``full`` to see all pull request output. + + per_page + Pagination size, maximum 100. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.list_pulls my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + pulls = gh_pulls.list_all( + _get_conn(profile), + repo_name, + state=state, + head=head, + base=base, + sort=sort, + direction=direction, + per_page=per_page, + ) + if output == "full": + return pulls + return [gh_pulls.format_pr(pr_) for pr_ in pulls or []] + + +def list_rulesets(repo_name=None, per_page=None, headers=None, profile="github"): + """ + List all rulesets of the repository. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + per_page + Pagination size, maximum 100. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.list_rulesets my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + conn = _get_conn(profile) + return gh_rulesets.list_all( + conn, + "repo", + owner=conn.org_name, + repo_name=repo_name, + headers=headers, + per_page=per_page, + ) + + +def get_ruleset(ruleset_id, repo_name=None, headers=None, profile="github"): + """ + Return the data of a single repository ruleset. + + ruleset_id + The ID of the ruleset. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_ruleset 1234 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + conn = _get_conn(profile) + return gh_rulesets.get( + conn, ruleset_id, "repo", owner=conn.org_name, repo_name=repo_name, headers=headers + ) + + +def add_ruleset(ruleset_params, repo_name=None, headers=None, profile="github"): + """ + Create a new repository ruleset. + Returns the data of the created ruleset. + + ruleset_params + The ruleset definition. Must contain at least ``name`` and + ``enforcement``. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.add_ruleset '{name: test, enforcement: active}' repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + conn = _get_conn(profile) + return gh_rulesets.create( + conn, ruleset_params, "repo", owner=conn.org_name, repo_name=repo_name, headers=headers + ) + + +def update_ruleset(ruleset_id, ruleset_params, repo_name=None, headers=None, profile="github"): + """ + Update an existing repository ruleset. + Returns the data of the updated ruleset. + + ruleset_id + The ID of the ruleset. + + ruleset_params + The updated ruleset definition. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.update_ruleset 1234 '{enforcement: disabled}' repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + conn = _get_conn(profile) + return gh_rulesets.update( + conn, + ruleset_id, + ruleset_params, + "repo", + owner=conn.org_name, + repo_name=repo_name, + headers=headers, + ) + + +def delete_ruleset(ruleset_id, repo_name=None, headers=None, profile="github"): + """ + Delete a repository ruleset. + + ruleset_id + The ID of the ruleset. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + headers + Extra headers to pass to the GitHub API. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.delete_ruleset 1234 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + conn = _get_conn(profile) + gh_rulesets.delete( + conn, ruleset_id, "repo", owner=conn.org_name, repo_name=repo_name, headers=headers + ) + return True + + +def _resolve_repo_name(repo_name, profile): + """ + Default ``repo_name`` to the profile configuration when unset. + """ + if repo_name is None: + return _get_config_value(profile, "repo_name") + return repo_name + + +def list_deploy_keys(repo_name=None, profile="github"): + """ + List the deploy keys of a repository. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.list_deploy_keys my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_deploy_keys.list_all(_get_conn(profile), repo_name) + + +def get_deploy_key(key_id, repo_name=None, profile="github"): + """ + Return the data of a single deploy key of a repository. + + key_id + The ID of the deploy key. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_deploy_key 12345 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_deploy_keys.get(_get_conn(profile), repo_name, key_id) + + +def add_deploy_key(title, key, read_only=False, repo_name=None, profile="github"): + """ + Add a deploy key to a repository. Returns the data of the created key. + + Note that deploy keys are immutable. To modify one, remove it and + add a replacement. + + title + The display title of the deploy key. + + key + The SSH public key. + + read_only + Deny write access to the repository for this key. Defaults to false. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.add_deploy_key autorelease 'ssh-ed25519 AAAA...' read_only=false repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_deploy_keys.create( + _get_conn(profile), repo_name, title, key, read_only=bool(read_only) + ) + + +def remove_deploy_key(key_id, repo_name=None, profile="github"): + """ + Remove a deploy key from a repository. + + key_id + The ID of the deploy key. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.remove_deploy_key 12345 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_deploy_keys.delete(_get_conn(profile), repo_name, key_id) + return True + + +def list_actions_secrets(repo_name=None, profile="github"): + """ + List the Actions secrets of a repository. Secret values are write-only, + so this only returns metadata (``name``, ``created_at``, ``updated_at``). + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.list_actions_secrets my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_actions.list_secrets(_get_conn(profile), repo_name) + + +def get_actions_secret(name, repo_name=None, profile="github"): + """ + Return the metadata of a single Actions secret of a repository. + The value itself is write-only and cannot be retrieved. + + name + The name of the secret. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_actions_secret MY_SECRET repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_actions.get_secret(_get_conn(profile), repo_name, name) + + +def set_actions_secret(name, value, repo_name=None, profile="github"): + """ + Create or update an Actions secret of a repository. The value is + encrypted to the repository public key before leaving the minion. + + name + The name of the secret. + + value + The plaintext secret value. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.set_actions_secret MY_SECRET hunter2 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_actions.set_secret(_get_conn(profile), repo_name, name, value) + return True + + +def remove_actions_secret(name, repo_name=None, profile="github"): + """ + Remove an Actions secret from a repository. + + name + The name of the secret. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.remove_actions_secret MY_SECRET repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_actions.delete_secret(_get_conn(profile), repo_name, name) + return True + + +def get_workflow_permissions(repo_name=None, profile="github"): + """ + Return the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository, a mapping with ``default_workflow_permissions`` + (``read``/``write``) and ``can_approve_pull_request_reviews``. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_workflow_permissions my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_actions.get_workflow_permissions(_get_conn(profile), repo_name) + + +def set_workflow_permissions( + default_workflow_permissions=None, + can_approve_pull_request_reviews=None, + repo_name=None, + profile="github", +): + """ + Update the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository. + + default_workflow_permissions + The default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Whether GitHub Actions can approve pull requests. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.set_workflow_permissions read can_approve_pull_request_reviews=true repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_actions.set_workflow_permissions( + _get_conn(profile), + repo_name, + default_workflow_permissions=default_workflow_permissions, + can_approve_pull_request_reviews=can_approve_pull_request_reviews, + ) + return True + + +def list_environments(repo_name=None, profile="github"): + """ + List the environments of a repository. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.list_environments my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_environments.list_all(_get_conn(profile), repo_name) + + +def get_environment(name, repo_name=None, profile="github"): + """ + Return the data of a single environment of a repository. + + name + The name of the environment. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_environment release repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_environments.get(_get_conn(profile), repo_name, name) + + +def set_environment( + name, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branch_policy=..., + repo_name=None, + profile="github", +): + """ + Create or update an environment of a repository. + Returns the data of the environment. + + name + The name of the environment. + + wait_timer + The amount of time to delay a job after the job is initially + triggered, in minutes. + + prevent_self_review + Whether or not a user who created the job is prevented from + approving their own job. + + reviewers + The people or teams that may review jobs that reference the + environment, as a list of mappings with ``type`` (``User`` or + ``Team``) and ``id``. + + deployment_branch_policy + The type of deployment branch policy for this environment, either + ``none`` to allow all branches or a mapping with + ``protected_branches`` and ``custom_branch_policies`` booleans. + Branch name patterns for custom policies are managed separately, + see :py:func:`github_repo.add_deployment_branch_policy + `. + If not passed at all, the current policy is left unchanged. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.set_environment release repo_name=my-repo \\ + deployment_branch_policy='{protected_branches: false, custom_branch_policies: true}' + """ + repo_name = _resolve_repo_name(repo_name, profile) + conn = _get_conn(profile) + if deployment_branch_policy is ...: + # PUT replaces the whole environment config, so carry over the + # current policy when the caller does not specify one. + try: + current = gh_environments.get(conn, repo_name, name) + deployment_branch_policy = gh_environments.normalize(current)[ + "deployment_branch_policy" + ] + except GitHubNotFoundError: + deployment_branch_policy = None + return gh_environments.set_( + conn, + repo_name, + name, + wait_timer=wait_timer, + prevent_self_review=prevent_self_review, + reviewers=reviewers, + deployment_branch_policy=deployment_branch_policy, + ) + + +def remove_environment(name, repo_name=None, profile="github"): + """ + Remove an environment from a repository. + + name + The name of the environment. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.remove_environment release repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_environments.delete(_get_conn(profile), repo_name, name) + return True + + +def list_deployment_branch_policies(environment, repo_name=None, profile="github"): + """ + List the deployment branch policies of an environment. + + environment + The name of the environment. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.list_deployment_branch_policies release repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_environments.list_branch_policies(_get_conn(profile), repo_name, environment) + + +def add_deployment_branch_policy( + environment, name, type_="branch", repo_name=None, profile="github" +): + r""" + Add a deployment branch (or tag) policy to an environment. + Returns the data of the created policy. + + The environment's ``deployment_branch_policy`` must have + ``custom_branch_policies`` enabled. + + environment + The name of the environment. + + name + The name pattern that branches (or tags) must match in order + to deploy to the environment. + + type\_ + Whether this rule targets a ``branch`` or ``tag``. + Defaults to ``branch``. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.add_deployment_branch_policy release main repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_environments.create_branch_policy( + _get_conn(profile), repo_name, environment, name, type_=type_ + ) + + +def remove_deployment_branch_policy(environment, policy_id, repo_name=None, profile="github"): + """ + Remove a deployment branch policy from an environment. + + environment + The name of the environment. + + policy_id + The ID of the branch policy. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.remove_deployment_branch_policy release 12345 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_environments.delete_branch_policy(_get_conn(profile), repo_name, environment, policy_id) + return True + + +def list_labels(repo_name=None, profile="github"): + """ + List the labels of a repository. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.list_labels my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_labels.list_all(_get_conn(profile), repo_name) + + +def get_label(name, repo_name=None, profile="github"): + """ + Return the data of a single label of a repository. + + name + The name of the label. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_label bug repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_labels.get(_get_conn(profile), repo_name, name) + + +def add_label(name, color=None, description=None, repo_name=None, profile="github"): + """ + Create a label in a repository. Returns the data of the created label. + + name + The name of the label. + + color + The hexadecimal color code for the label, without the leading ``#``. + If unset, GitHub assigns a random one. + + description + A short description of the label. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.add_label release color=00ff00 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_labels.create( + _get_conn(profile), repo_name, name, color=color, description=description + ) + + +def edit_label(name, new_name=None, color=None, description=None, repo_name=None, profile="github"): + """ + Update an existing label of a repository. + Returns the data of the updated label. + + name + The (current) name of the label. + + new_name + A new name for the label. + + color + The hexadecimal color code for the label, without the leading ``#``. + + description + A short description of the label. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.edit_label release color=00ff00 repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_labels.update( + _get_conn(profile), + repo_name, + name, + new_name=new_name, + color=color, + description=description, + ) + + +def remove_label(name, repo_name=None, profile="github"): + """ + Delete a label from a repository. + + name + The name of the label. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.remove_label release repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_labels.delete(_get_conn(profile), repo_name, name) + return True + + +def get_pages(repo_name=None, profile="github"): + """ + Return information about the GitHub Pages site of a repository. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.get_pages my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_pages.get(_get_conn(profile), repo_name) + + +def setup_pages( + build_type=None, source_branch=None, source_path=None, repo_name=None, profile="github" +): + """ + Configure a GitHub Pages site for a repository. + Returns the data of the created site. + + build_type + The process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.setup_pages build_type=workflow repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_pages.create( + _get_conn(profile), + repo_name, + build_type=build_type, + source=gh_pages.build_source(source_branch, source_path), + ) + + +def update_pages( + build_type=None, + source_branch=None, + source_path=None, + https_enforced=None, + repo_name=None, + profile="github", +): + """ + Update the GitHub Pages site configuration of a repository. + + build_type + The process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + https_enforced + Whether HTTPS is enforced for the site. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.update_pages build_type=workflow repo_name=my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_pages.update( + _get_conn(profile), + repo_name, + build_type=build_type, + source=gh_pages.build_source(source_branch, source_path), + https_enforced=https_enforced, + ) + return True + + +def remove_pages(repo_name=None, profile="github"): + """ + Delete the GitHub Pages site of a repository. + + repo_name + The name of the repository. This argument is required, either passed + via the CLI, or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + CLI Example: + + .. code-block:: bash + + salt myminion github_repo.remove_pages my-repo + """ + repo_name = _resolve_repo_name(repo_name, profile) + gh_pages.delete(_get_conn(profile), repo_name) + return True diff --git a/src/saltext/github/states/github.py b/src/saltext/github/states/github.py index bad15c6b..c382d920 100644 --- a/src/saltext/github/states/github.py +++ b/src/saltext/github/states/github.py @@ -1,7 +1,11 @@ """ -Manage organization teams/members, repositories and their resources -(rulesets, deploy keys, Actions secrets and settings, environments, -labels, Pages). +Manage organization teams/members, repositories and rulesets. + +.. deprecated:: 1.1.0 + + Use the :py:mod:`github_org ` and + :py:mod:`github_repo ` state modules + instead. """ import datetime @@ -10,16 +14,6 @@ from salt.exceptions import CommandExecutionError -from saltext.github.utils import filter_unset -from saltext.github.utils.gh.exceptions import GitHubNotFoundError - -try: - from saltext.github.utils.gh import environments as gh_environments -except ImportError: # pragma: no cover - # PyGithub is missing; __virtual__ fails since the execution module - # is not loaded either. - gh_environments = None - log = logging.getLogger(__name__) __virtualname__ = "github" @@ -38,6 +32,9 @@ def present(name, profile="github", **kwargs): """ Ensure a user is present + .. deprecated:: 1.1.0 + Use :py:func:`github_org.member_present ` instead. + .. code-block:: yaml ensure user test is present in github: @@ -87,6 +84,9 @@ def absent(name, profile="github", **kwargs): """ Ensure a github user is absent + .. deprecated:: 1.1.0 + Use :py:func:`github_org.member_absent ` instead. + .. code-block:: yaml ensure user test is absent in github: @@ -153,6 +153,9 @@ def team_present( # pylint: disable=too-many-statements """ Ensure a team is present + .. deprecated:: 1.1.0 + Use :py:func:`github_org.team_present ` instead. + name This is the name of the team in the organization. @@ -422,6 +425,9 @@ def team_absent(name, profile="github", **kwargs): """ Ensure a team is absent. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.team_absent ` instead. + Example: .. code-block:: yaml @@ -473,7 +479,6 @@ def repo_present( auto_init=False, gitignore_template=None, license_template=None, - default_branch=None, teams=None, profile="github", **kwargs, @@ -481,6 +486,9 @@ def repo_present( """ Ensure a repository is present + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.present ` instead. + name This is the name of the repository. @@ -512,12 +520,6 @@ def repo_present( license_template The desired LICENSE template to apply, e.g "mit" or "mozilla". - default_branch - The default branch of the repository. The branch must already exist, - so this is not applied when the repository is newly created. - - .. versionadded:: 1.1.0 - teams The teams for which this repo should belong to, specified as a dict of team name to permission (``pull``, ``push`` or ``admin``). @@ -553,7 +555,6 @@ def repo_present( "auto_init": auto_init, "gitignore_template": gitignore_template, "license_template": license_template, - "default_branch": default_branch, } # Keep track of current_teams if we've fetched them after creating a new repo @@ -598,8 +599,6 @@ def repo_present( ret["result"] = None else: add_params = dict(given_params) - # The default branch can only be switched to an existing branch - add_params.pop("default_branch", None) add_params.update(kwargs) result = __salt__["github.add_repo"](name, profile=profile, **add_params) @@ -714,6 +713,9 @@ def repo_absent(name, profile="github", **kwargs): """ Ensure a repo is absent. + .. deprecated:: 1.1.0 + Use :py:func:`github_repo.absent ` instead. + Example: .. code-block:: yaml @@ -766,6 +768,10 @@ def ruleset_absent( """ Ensure a ruleset is absent. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.ruleset_absent ` or + :py:func:`github_repo.ruleset_absent ` instead. + Example: .. code-block:: yaml @@ -847,6 +853,10 @@ def ruleset_present( """ Ensure a ruleset is present. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.ruleset_present ` or + :py:func:`github_repo.ruleset_present ` instead. + Example: .. code-block:: yaml @@ -944,916 +954,3 @@ def ruleset_present( ret["result"] = False ret["comment"] = "Could not update ruleset" return ret - - -def _key_body(key): - """ - Reduce an SSH public key to its comparable part (type + base64 body), - ignoring whitespace variations and the comment. - """ - return " ".join(key.split()[:2]) - - -def deploy_key_present(name, key, read_only=False, repo_name=None, profile="github"): - """ - Ensure a deploy key with this title and public key is present - in a repository. - - Since deploy keys are immutable in the GitHub API, a key whose - ``key`` or ``read_only`` setting differs is replaced (removed - and recreated). - - .. versionadded:: 1.1.0 - - Example: - - .. code-block:: yaml - - Autorelease deploy key is present: - github.deploy_key_present: - - name: autorelease - - key: ssh-ed25519 AAAA... - - repo_name: my-repo - - name - The display title of the deploy key. - - key - The SSH public key. - - read_only - Deny write access to the repository for this key. Defaults to false. - - repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - try: - keys = __salt__["github.list_deploy_keys"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list deploy keys: {err}" - return ret - - current = next((cur for cur in keys if cur["title"] == name), None) - - if current is not None: - if _key_body(current["key"]) == _key_body(key) and bool(current["read_only"]) is bool( - read_only - ): - ret["comment"] = f"Deploy key {name} is already present" - return ret - ret["changes"]["old"] = {"key": current["key"], "read_only": current["read_only"]} - ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Deploy key {name} would have been replaced" - return ret - try: - __salt__["github.remove_deploy_key"]( - current["id"], repo_name=repo_name, profile=profile - ) - __salt__["github.add_deploy_key"]( - name, key, read_only=read_only, repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to replace deploy key {name}: {err}" - return ret - ret["comment"] = f"Deploy key {name} has been replaced" - return ret - - ret["changes"]["old"] = None - ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Deploy key {name} would have been added" - return ret - try: - __salt__["github.add_deploy_key"]( - name, key, read_only=read_only, repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to add deploy key {name}: {err}" - return ret - ret["comment"] = f"Deploy key {name} has been added" - return ret - - -def deploy_key_absent(name, repo_name=None, profile="github"): - """ - Ensure no deploy key with this title is present in a repository. - - .. versionadded:: 1.1.0 - - name - The display title of the deploy key. - - repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - try: - keys = __salt__["github.list_deploy_keys"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list deploy keys: {err}" - return ret - - current = next((cur for cur in keys if cur["title"] == name), None) - if current is None: - ret["comment"] = f"Deploy key {name} is already absent" - return ret - - ret["changes"]["old"] = f"Deploy key {name} exists" - ret["changes"]["new"] = f"Deploy key {name} removed" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Deploy key {name} would have been removed" - return ret - try: - __salt__["github.remove_deploy_key"](current["id"], repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to remove deploy key {name}: {err}" - return ret - ret["comment"] = f"Deploy key {name} has been removed" - return ret - - -def actions_secret_present(name, value=None, force=False, repo_name=None, profile="github"): - """ - Ensure an Actions secret is present in a repository. - - Secret values are write-only in the GitHub API, so this state cannot - detect value drift. By default, an existing secret is left untouched. - Set ``force`` to push the value on every run. - - .. versionadded:: 1.1.0 - - name - The name of the secret. GitHub treats secret names - case-insensitively. - - value - The plaintext secret value. It is encrypted to the repository - public key before leaving the minion and never appears in the - state output. Required when the secret does not exist yet or - ``force`` is set. - - force - Push the value even when the secret already exists. - Defaults to false. - - repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - try: - secrets = __salt__["github.list_actions_secrets"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list Actions secrets: {err}" - return ret - - current = next((sec for sec in secrets if sec["name"].lower() == name.lower()), None) - - if current is not None and not force: - ret["comment"] = ( - f"Secret {name} is already present. Note that secret values are " - "write-only and cannot be verified, set force to push the value." - ) - return ret - - if value is None: - ret["result"] = False - ret["comment"] = f"A value is required to set secret {name}" - return ret - - if current is None: - ret["changes"]["old"] = None - ret["changes"]["new"] = f"Secret {name} set" - verb = "created" - else: - ret["changes"]["old"] = f"Secret {name} exists" - ret["changes"]["new"] = f"Secret {name} updated" - verb = "updated" - - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Secret {name} would have been {verb}" - return ret - try: - __salt__["github.set_actions_secret"](name, value, repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to set secret {name}: {err}" - return ret - ret["comment"] = f"Secret {name} has been {verb}" - return ret - - -def actions_secret_absent(name, repo_name=None, profile="github"): - """ - Ensure an Actions secret is absent from a repository. - - .. versionadded:: 1.1.0 - - name - The name of the secret. GitHub treats secret names - case-insensitively. - - repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - try: - secrets = __salt__["github.list_actions_secrets"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list Actions secrets: {err}" - return ret - - current = next((sec for sec in secrets if sec["name"].lower() == name.lower()), None) - if current is None: - ret["comment"] = f"Secret {name} is already absent" - return ret - - ret["changes"]["old"] = f"Secret {name} exists" - ret["changes"]["new"] = f"Secret {name} removed" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Secret {name} would have been removed" - return ret - try: - __salt__["github.remove_actions_secret"]( - current["name"], repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to remove secret {name}: {err}" - return ret - ret["comment"] = f"Secret {name} has been removed" - return ret - - -def workflow_permissions_managed( - name, - default_workflow_permissions=None, - can_approve_pull_request_reviews=None, - repo_name=None, - profile="github", -): - """ - Manage the default workflow permissions granted to the ``GITHUB_TOKEN`` - in a repository. - - .. versionadded:: 1.1.0 - - Example: - - .. code-block:: yaml - - Actions tokens are restricted in my-repo: - github.workflow_permissions_managed: - - name: my-repo - - default_workflow_permissions: read - - can_approve_pull_request_reviews: true - - name - The name of the repository, unless ``repo_name`` is passed. - - default_workflow_permissions - The default permissions for the ``GITHUB_TOKEN``, either ``read`` - or ``write``. - - can_approve_pull_request_reviews - Whether GitHub Actions can approve pull requests. - - repo_name - The name of the repository, if it differs from ``name``. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - repo_name = repo_name or name - - desired = filter_unset( - { - "default_workflow_permissions": default_workflow_permissions, - "can_approve_pull_request_reviews": can_approve_pull_request_reviews, - } - ) - if not desired: - ret["result"] = False - ret["comment"] = "At least one setting to manage must be specified" - return ret - - try: - current = __salt__["github.get_workflow_permissions"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get workflow permissions: {err}" - return ret - - diff = {key: val for key, val in desired.items() if current.get(key) != val} - if not diff: - ret["comment"] = "Workflow permissions are already set as specified" - return ret - - ret["changes"]["old"] = {key: current.get(key) for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = "Workflow permissions would have been updated" - return ret - try: - __salt__["github.set_workflow_permissions"](repo_name=repo_name, profile=profile, **diff) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to set workflow permissions: {err}" - return ret - ret["comment"] = "Workflow permissions have been updated" - return ret - - -def _desired_branch_policy(deployment_branches): - """ - Map the state's ``deployment_branches`` parameter onto the API's - ``deployment_branch_policy`` setting. - """ - if deployment_branches == "all": - return None - if deployment_branches == "protected": - return {"protected_branches": True, "custom_branch_policies": False} - if isinstance(deployment_branches, list): - return {"protected_branches": False, "custom_branch_policies": True} - raise CommandExecutionError( - "deployment_branches must be 'all', 'protected' or a list of branch " - f"name patterns, got: {deployment_branches!r}" - ) - - -def _converge_branch_policies(ret, name, deployment_branches, *, env_exists, repo_name, profile): - """ - Converge an environment's deployment branch name patterns, recording - changes in ``ret``. Returns false when the state should abort. - """ - current_policies = [] - if env_exists: - try: - current_policies = __salt__["github.list_deployment_branch_policies"]( - name, repo_name=repo_name, profile=profile - ) - except GitHubNotFoundError: - current_policies = [] - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list deployment branch policies: {err}" - return False - current_patterns = {policy["name"] for policy in current_policies} - desired_patterns = set(deployment_branches) - if current_patterns == desired_patterns: - return True - ret["changes"]["deployment_branches"] = { - "old": sorted(current_patterns), - "new": sorted(desired_patterns), - } - if __opts__["test"]: - ret["result"] = None - return True - try: - for pattern in desired_patterns - current_patterns: - __salt__["github.add_deployment_branch_policy"]( - name, pattern, repo_name=repo_name, profile=profile - ) - for policy in current_policies: - if policy["name"] not in desired_patterns: - __salt__["github.remove_deployment_branch_policy"]( - name, policy["id"], repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to converge deployment branch policies: {err}" - return False - return True - - -def environment_present( # pylint: disable=too-many-statements,too-many-branches - name, - wait_timer=None, - prevent_self_review=None, - reviewers=None, - deployment_branches=None, - repo_name=None, - profile="github", -): - """ - Ensure an environment is present in a repository and configured - as specified. Unspecified settings are left unmanaged. - - .. versionadded:: 1.1.0 - - Example: - - .. code-block:: yaml - - Release environment is present: - github.environment_present: - - name: release - - repo_name: my-repo - - deployment_branches: - - main - - name - The name of the environment. - - wait_timer - The amount of time to delay a job after the job is initially - triggered, in minutes. - - prevent_self_review - Whether or not a user who created the job is prevented from - approving their own job. - - reviewers - The people or teams that may review jobs that reference the - environment, as a list of mappings with ``type`` (``User`` or - ``Team``) and ``id``. - - deployment_branches - Which branches can deploy to this environment. Either ``all``, - ``protected`` (branches with branch protection rules) or a list - of branch name patterns. - - repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - try: - current = __salt__["github.get_environment"](name, repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - current = None - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get environment {name}: {err}" - return ret - - desired = filter_unset( - { - "wait_timer": wait_timer, - "prevent_self_review": prevent_self_review, - "reviewers": reviewers, - } - ) - if deployment_branches is not None: - try: - desired["deployment_branch_policy"] = _desired_branch_policy(deployment_branches) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - return ret - - env_diff = {} - if current is None: - ret["changes"]["environment"] = {"old": None, "new": f"Environment {name} created"} - set_kwargs = dict(desired) - else: - current_settings = gh_environments.normalize(current) - for key, val in desired.items(): - current_val = current_settings.get(key) - if key == "reviewers": - matches = sorted((reviewer["type"], reviewer["id"]) for reviewer in val) == sorted( - (reviewer["type"], reviewer["id"]) for reviewer in current_val - ) - else: - matches = current_val == val - if not matches: - env_diff[key] = val - ret["changes"][key] = {"old": current_val, "new": val} - # The API call replaces the whole configuration, so settings - # that are not managed here need to be carried over. - set_kwargs = {**current_settings, **desired} - - if env_diff or current is None: - if not __opts__["test"]: - try: - __salt__["github.set_environment"]( - name, - repo_name=repo_name, - profile=profile, - **set_kwargs, - ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to configure environment {name}: {err}" - return ret - - # Converge the branch name patterns for custom branch policies - if isinstance(deployment_branches, list) and not _converge_branch_policies( - ret, - name, - deployment_branches, - env_exists=current is not None, - repo_name=repo_name, - profile=profile, - ): - return ret - - if not ret["changes"]: - ret["comment"] = f"Environment {name} is already configured as specified" - elif __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Environment {name} would have been configured" - else: - ret["comment"] = f"Environment {name} has been configured" - return ret - - -def environment_absent(name, repo_name=None, profile="github"): - """ - Ensure an environment is absent from a repository. - - .. versionadded:: 1.1.0 - - name - The name of the environment. - - repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - try: - __salt__["github.get_environment"](name, repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Environment {name} is already absent" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get environment {name}: {err}" - return ret - - ret["changes"]["old"] = f"Environment {name} exists" - ret["changes"]["new"] = f"Environment {name} removed" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Environment {name} would have been removed" - return ret - try: - __salt__["github.remove_environment"](name, repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to remove environment {name}: {err}" - return ret - ret["comment"] = f"Environment {name} has been removed" - return ret - - -def _normalize_color(color): - """ - Normalize a label color for comparisons: lowercase, no leading ``#``. - """ - if color is None: - return None - return color.lstrip("#").lower() - - -def label_present(name, color=None, description=None, repo_name=None, profile="github"): - """ - Ensure a label is present in a repository and configured as - specified. Unspecified settings are left unmanaged. - - .. versionadded:: 1.1.0 - - name - The name of the label. - - color - The hexadecimal color code for the label. A leading ``#`` - is accepted. - - description - A short description of the label. - - repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - try: - current = __salt__["github.get_label"](name, repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - current = None - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get label {name}: {err}" - return ret - - if current is None: - ret["changes"]["old"] = None - ret["changes"]["new"] = f"Label {name} created" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Label {name} would have been created" - return ret - try: - __salt__["github.add_label"]( - name, color=color, description=description, repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to create label {name}: {err}" - return ret - ret["comment"] = f"Label {name} has been created" - return ret - - diff = {} - if color is not None and _normalize_color(current["color"]) != _normalize_color(color): - diff["color"] = color - if description is not None and current.get("description") != description: - diff["description"] = description - - if not diff: - ret["comment"] = f"Label {name} is already configured as specified" - return ret - - ret["changes"]["old"] = {key: current.get(key) for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Label {name} would have been updated" - return ret - try: - __salt__["github.edit_label"](name, repo_name=repo_name, profile=profile, **diff) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to update label {name}: {err}" - return ret - ret["comment"] = f"Label {name} has been updated" - return ret - - -def label_absent(name, repo_name=None, profile="github"): - """ - Ensure a label is absent from a repository. - - .. versionadded:: 1.1.0 - - name - The name of the label. - - repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - try: - __salt__["github.get_label"](name, repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Label {name} is already absent" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get label {name}: {err}" - return ret - - ret["changes"]["old"] = f"Label {name} exists" - ret["changes"]["new"] = f"Label {name} removed" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Label {name} would have been removed" - return ret - try: - __salt__["github.remove_label"](name, repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to remove label {name}: {err}" - return ret - ret["comment"] = f"Label {name} has been removed" - return ret - - -def pages_present( - name, - build_type=None, - source_branch=None, - source_path=None, - https_enforced=None, - repo_name=None, - profile="github", -): - """ - Ensure GitHub Pages are enabled for a repository and configured as - specified. Unspecified settings are left unmanaged. - - .. versionadded:: 1.1.0 - - Example: - - .. code-block:: yaml - - Pages are deployed via Actions in my-repo: - github.pages_present: - - name: my-repo - - build_type: workflow - - name - The name of the repository, unless ``repo_name`` is passed. - - build_type - The process by which the site is built, either ``legacy`` - (deploy from a branch) or ``workflow`` (GitHub Actions). - - source_branch - For ``legacy`` builds, the repository branch to publish from. - - source_path - For ``legacy`` builds, the repository directory to publish from, - either ``/`` or ``/docs``. - - https_enforced - Whether HTTPS is enforced for the site. - - repo_name - The name of the repository, if it differs from ``name``. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - repo_name = repo_name or name - - try: - current = __salt__["github.get_pages"](repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - current = None - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get Pages configuration: {err}" - return ret - - if current is None: - ret["changes"]["old"] = None - ret["changes"]["new"] = "Pages site created" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Pages would have been enabled for {repo_name}" - return ret - try: - __salt__["github.setup_pages"]( - build_type=build_type, - source_branch=source_branch, - source_path=source_path, - repo_name=repo_name, - profile=profile, - ) - if https_enforced is not None: - __salt__["github.update_pages"]( - https_enforced=https_enforced, repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to enable Pages for {repo_name}: {err}" - return ret - ret["comment"] = f"Pages have been enabled for {repo_name}" - return ret - - current_source = current.get("source") or {} - desired = filter_unset( - { - "build_type": build_type, - "source_branch": source_branch, - "source_path": source_path, - "https_enforced": https_enforced, - } - ) - current_flat = { - "build_type": current.get("build_type"), - "source_branch": current_source.get("branch"), - "source_path": current_source.get("path"), - "https_enforced": current.get("https_enforced"), - } - diff = {key: val for key, val in desired.items() if current_flat[key] != val} - - if not diff: - ret["comment"] = f"Pages for {repo_name} are already configured as specified" - return ret - - ret["changes"]["old"] = {key: current_flat[key] for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Pages configuration for {repo_name} would have been updated" - return ret - update_kwargs = dict(diff) - if "source_branch" in diff or "source_path" in diff: - # The source is submitted as a single object, carry over the - # unchanged half - update_kwargs.setdefault("source_branch", current_flat["source_branch"]) - update_kwargs.setdefault("source_path", current_flat["source_path"]) - try: - __salt__["github.update_pages"](repo_name=repo_name, profile=profile, **update_kwargs) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to update Pages configuration for {repo_name}: {err}" - return ret - ret["comment"] = f"Pages configuration for {repo_name} has been updated" - return ret - - -def pages_absent(name, repo_name=None, profile="github"): - """ - Ensure GitHub Pages are disabled for a repository. - - .. versionadded:: 1.1.0 - - name - The name of the repository, unless ``repo_name`` is passed. - - repo_name - The name of the repository, if it differs from ``name``. - - profile - The name of the profile configuration to use. Defaults to ``github``. - """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - repo_name = repo_name or name - - try: - __salt__["github.get_pages"](repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Pages are already disabled for {repo_name}" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get Pages configuration: {err}" - return ret - - ret["changes"]["old"] = f"Pages enabled for {repo_name}" - ret["changes"]["new"] = f"Pages disabled for {repo_name}" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Pages would have been disabled for {repo_name}" - return ret - try: - __salt__["github.remove_pages"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to disable Pages for {repo_name}: {err}" - return ret - ret["comment"] = f"Pages have been disabled for {repo_name}" - return ret diff --git a/src/saltext/github/states/github_org.py b/src/saltext/github/states/github_org.py new file mode 100644 index 00000000..1f6cf45c --- /dev/null +++ b/src/saltext/github/states/github_org.py @@ -0,0 +1,625 @@ +""" +Manage a GitHub organization: members, teams and organization rulesets. + +.. versionadded:: 1.1.0 +""" + +import datetime +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh.exceptions import GitHubNotFoundError + +log = logging.getLogger(__name__) + +__virtualname__ = "github_org" + + +def __virtual__(): + """ + Only load if the github_org module is available in __salt__ + """ + if "github_org.list_members" in __salt__: + return __virtualname__ + return (False, "github_org module could not be loaded") # pragma: no cover + + +def member_present(name, profile="github"): + """ + Ensure a user is a member of the organization. Since GitHub + memberships require an invitation, this cannot be achieved in a + single run: Once the user has been invited, this state reports + success while the membership state is ``pending``. + + Example: + + .. code-block:: yaml + + gitexample is a member of the org: + github_org.member_present: + - name: gitexample + + name + The GitHub login of the user. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + info = __salt__["github_org.get_member"](name, profile=profile) + except GitHubNotFoundError: + ret["result"] = False + ret["comment"] = f"User {name} does not exist" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get member {name}: {err}" + return ret + + if info["in_org"]: + ret["comment"] = f"User {name} is already a member of the organization" + return ret + if info["membership_state"] == "pending": + ret["comment"] = f"User {name} has already been invited" + return ret + + ret["changes"]["old"] = None + ret["changes"]["new"] = f"User {name} is a member of the organization" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"User {name} would have been invited" + return ret + try: + pending = __salt__["github_org.add_member"](name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to invite user {name}: {err}" + return ret + if pending: + ret["changes"]["new"] = f"User {name} has been invited to the organization" + ret["comment"] = f"User {name} has been invited" + else: + ret["comment"] = f"User {name} has been added" + return ret + + +def member_absent(name, profile="github"): + """ + Ensure a user is not a member of the organization. + + Example: + + .. code-block:: yaml + + gitexample is not a member of the org: + github_org.member_absent: + - name: gitexample + + name + The GitHub login of the user. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + info = __salt__["github_org.get_member"](name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"User {name} does not exist" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get member {name}: {err}" + return ret + + if not info["in_org"]: + ret["comment"] = f"User {name} is not a member of the organization" + return ret + + ret["changes"]["old"] = f"User {name} is a member of the organization" + ret["changes"]["new"] = f"User {name} is not a member of the organization" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"User {name} would have been removed from the organization" + return ret + try: + __salt__["github_org.remove_member"](name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to remove user {name} from the organization: {err}" + return ret + ret["comment"] = f"User {name} has been removed from the organization" + return ret + + +def _member_violates_mfa(member, member_info, mfa_deadline, members_without_mfa): + if member_info.get("mfa_exempt", False): + return False + enforce_mfa_from = datetime.datetime.strptime( + member_info.get("enforce_mfa_from", "1970/01/01"), "%Y/%m/%d" + ).replace(tzinfo=datetime.timezone.utc) + return member.lower() in members_without_mfa and (mfa_deadline > enforce_mfa_from) + + +def _team_converge_repos(ret, name, repo_names, test_comments, profile): + """ + Converge an existing team's repositories, recording changes in ``ret``. + Returns false when the state should abort. + """ + current_repos = set(__salt__["github_org.list_team_repos"](name, profile=profile)) + repo_names = set(repo_names or []) + + repos_to_add = repo_names - current_repos + repos_to_remove = current_repos - repo_names if repo_names else [] + + if repos_to_add: + if __opts__["test"]: + test_comments.append( + f"Team {name} will have the following repos added: {list(repos_to_add)}." + ) + ret["result"] = None + for repo_name in repos_to_add: + repo_change = { + "old": f"Repo {repo_name} is not in team {name}", + "new": f"Repo {repo_name} is in team {name}", + } + if __opts__["test"]: + ret["changes"][repo_name] = repo_change + else: + try: + __salt__["github_org.add_team_repo"](repo_name, name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to add repo {repo_name} to team {name}: {err}" + return False + ret["changes"][repo_name] = repo_change + + if repos_to_remove: + if __opts__["test"]: + test_comments.append( + f"Team {name} will have the following repos removed: {list(repos_to_remove)}." + ) + ret["result"] = None + for repo_name in repos_to_remove: + repo_change = { + "old": f"Repo {repo_name} is in team {name}", + "new": f"Repo {repo_name} is not in team {name}", + } + if __opts__["test"]: + ret["changes"][repo_name] = repo_change + else: + try: + __salt__["github_org.remove_team_repo"](repo_name, name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to remove repo {repo_name} from team {name}: {err}" + return False + ret["changes"][repo_name] = repo_change + return True + + +def _team_converge_members( # pylint: disable=too-many-arguments + ret, name, members, enforce_mfa, no_mfa_grace_seconds, test_comments, profile +): + """ + Converge a team's members, recording changes in ``ret``. + Returns false when the state should abort. + """ + manage_members = members is not None + + mfa_deadline = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta( + seconds=no_mfa_grace_seconds + ) + members_no_mfa = __salt__["github_org.list_members_without_mfa"](profile=profile) + + members_lower = {} + for member_name, info in (members or {}).items(): + members_lower[member_name.lower()] = info + + member_change = False + current_members = __salt__["github_org.list_team_members"](name, profile=profile) + + for member, member_info in (members or {}).items(): + log.info("Checking member %s in team %s", member, name) + + if member.lower() not in current_members: + if enforce_mfa and _member_violates_mfa( + member, member_info, mfa_deadline, members_no_mfa + ): + if __opts__["test"]: + test_comments.append( + f"User {member} will not be added to the team because they do not have MFA." + ) + else: # Add to team + member_change = True + member_add = { + "old": f"User {member} is not in team {name}", + "new": f"User {member} is in team {name}", + } + if __opts__["test"]: + test_comments.append(f"User {member} set to be added to the team.") + ret["result"] = None + ret["changes"][member] = member_add + else: + try: + __salt__["github_org.add_team_member"](member, name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to add user {member} to team {name}: {err}" + return False + ret["changes"][member] = member_add + + for member in current_members: + mfa_violation = False + if member in members_lower: + mfa_violation = _member_violates_mfa( + member, members_lower[member], mfa_deadline, members_no_mfa + ) + if manage_members and member not in members_lower or (enforce_mfa and mfa_violation): + # Remove from team + member_change = True + extra_changes = " due to MFA violation" if mfa_violation else "" + member_removal = { + "old": f"User {member} is in team {name}", + "new": f"User {member} is not in team {name}{extra_changes}", + } + if __opts__["test"]: + if mfa_violation: + test_comments.append( + f"User {member} set to be removed from the team " + "because they do not have MFA." + ) + else: + test_comments.append(f"User {member} set to be removed from the team.") + ret["result"] = None + ret["changes"][member] = member_removal + else: + try: + __salt__["github_org.remove_team_member"](member, name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to remove user {member} from team {name}: {err}" + return False + ret["changes"][member] = member_removal + + if member_change: # Refresh team cache + __salt__["github_org.list_team_members"](name, profile=profile, ignore_cache=True) + return True + + +def team_present( + name, + description=None, + repo_names=None, + privacy="secret", + permission="pull", + members=None, + enforce_mfa=False, + no_mfa_grace_seconds=0, + profile="github", +): + """ + Ensure a team is present. + + name + The name of the team in the organization. + + description + The description of the team. + + repo_names + The names of repositories to add the team to. + + privacy + The level of privacy for the team, can be ``secret`` or ``closed``. Defaults + to secret. + + permission + The default permission for new repositories added to the team, can be + ``pull``, ``push`` or ``admin``. Defaults to pull. + + members + The members belonging to the team, specified as a dict of member name to + optional configuration. Options include ``enforce_mfa_from`` and ``mfa_exempt``. + + enforce_mfa + Whether to enforce MFA requirements on members of the team. If True then + all members without ``mfa_exempt: True`` configured will be removed from + the team. Note that ``no_mfa_grace_seconds`` may be set to allow members + a grace period. + + no_mfa_grace_seconds + The number of seconds of grace time that a member will have to enable MFA + before being removed from the team. The grace period will begin from + ``enforce_mfa_from`` on the member configuration, which defaults to + 1970/01/01. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + Example: + + .. code-block:: yaml + + Ensure team test is present in github: + github_org.team_present: + - name: test + - members: + user1: {} + user2: {} + + Ensure team test_mfa is present in github: + github_org.team_present: + - name: test_mfa + - members: + user1: + enforce_mfa_from: 2016/06/15 + - enforce_mfa: True + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + target = __salt__["github_org.get_team"](name, profile=profile) + except GitHubNotFoundError: + target = None + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get team {name}: {err}" + return ret + test_comments = [] + + if target: # Team already exists + parameters = {} + if description is not None and target["description"] != description: + parameters["description"] = description + if permission is not None and target["permission"] != permission: + parameters["permission"] = permission + if privacy is not None and target["privacy"] != privacy: + parameters["privacy"] = privacy + + if parameters: + team_change = { + "old": f"Team properties were {target}", + "new": f"Team properties (that changed) are {parameters}", + } + if __opts__["test"]: + test_comments.append(f"Team properties are set to be edited: {parameters}") + ret["result"] = None + ret["changes"]["team"] = team_change + else: + try: + __salt__["github_org.edit_team"](name, profile=profile, **parameters) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to update team properties: {err}" + return ret + ret["changes"]["team"] = team_change + + if not _team_converge_repos(ret, name, repo_names, test_comments, profile): + return ret + + else: # Team does not exist - it will be created. + if __opts__["test"]: + # Note: member/repo changes are not projected for new teams + ret["comment"] = f"Team {name} is set to be created." + ret["changes"]["team"] = {"old": None, "new": f"Team {name} has been created"} + ret["result"] = None + return ret + + try: + __salt__["github_org.add_team"]( + name, + description=description, + repo_names=repo_names, + permission=permission, + privacy=privacy, + profile=profile, + ) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to create team {name}: {err}" + return ret + ret["changes"]["team"] = {"old": None, "new": f"Team {name} has been created"} + + if not _team_converge_members( + ret, name, members, enforce_mfa, no_mfa_grace_seconds, test_comments, profile + ): + return ret + + if test_comments: + ret["comment"] = "\n".join(test_comments) + return ret + + +def team_absent(name, profile="github"): + """ + Ensure a team is absent. + + Example: + + .. code-block:: yaml + + Ensure team test is absent in github: + github_org.team_absent: + - name: test + + name + The name of the team in the organization. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + __salt__["github_org.get_team"](name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Team {name} does not exist" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get team {name}: {err}" + return ret + + ret["changes"]["old"] = f"Team {name} exists" + ret["changes"]["new"] = f"Team {name} deleted" + if __opts__["test"]: + ret["comment"] = f"Team {name} would have been deleted" + ret["result"] = None + return ret + + try: + __salt__["github_org.remove_team"](name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to delete team {name}: {err}" + return ret + ret["comment"] = f"Deleted team {name}" + return ret + + +def ruleset_present(name, ruleset_params=None, profile="github"): + """ + Ensure an organization ruleset is present and configured as specified. + Only the parameters included in ``ruleset_params`` are compared, + server-populated fields are ignored. + + Example: + + .. code-block:: yaml + + Ensure ruleset test is present in the org: + github_org.ruleset_present: + - name: test + - ruleset_params: + target: branch + enforcement: active + + name + The name of the ruleset. + + ruleset_params + The ruleset definition. Must contain at least ``enforcement`` + when the ruleset needs to be created. The ``name`` is set from + this state's ``name``. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + ruleset_params = dict(ruleset_params or {}) + ruleset_params["name"] = name + + try: + rulesets = __salt__["github_org.list_rulesets"](profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list rulesets: {err}" + return ret + + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + + if ruleset is None: + ret["changes"]["old"] = None + ret["changes"]["new"] = f"Ruleset {name} created" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been created" + return ret + try: + __salt__["github_org.add_ruleset"](ruleset_params, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to create ruleset {name}: {err}" + return ret + ret["comment"] = f"Ruleset {name} has been created" + return ret + + try: + ruleset_info = __salt__["github_org.get_ruleset"](ruleset["id"], profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get ruleset {name}: {err}" + return ret + + # Only diff the parameters that are being managed. The server + # response carries additional server-populated fields (id, + # timestamps, source, ...) that must not register as drift. + diff = {key: val for key, val in ruleset_params.items() if ruleset_info.get(key) != val} + if not diff: + ret["comment"] = f"Ruleset {name} is already configured as specified" + return ret + + ret["changes"]["old"] = {key: ruleset_info.get(key) for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been updated" + return ret + try: + __salt__["github_org.update_ruleset"](ruleset["id"], ruleset_params, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to update ruleset {name}: {err}" + return ret + ret["comment"] = f"Ruleset {name} has been updated" + return ret + + +def ruleset_absent(name, profile="github"): + """ + Ensure an organization ruleset is absent. + + Example: + + .. code-block:: yaml + + Ensure ruleset test is absent in the org: + github_org.ruleset_absent: + - name: test + + name + The name of the ruleset. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + rulesets = __salt__["github_org.list_rulesets"](profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list rulesets: {err}" + return ret + + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + if ruleset is None: + ret["comment"] = f"Ruleset {name} does not exist" + return ret + + ret["changes"]["old"] = f"Ruleset {name} exists" + ret["changes"]["new"] = f"Ruleset {name} deleted" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been deleted" + return ret + try: + __salt__["github_org.delete_ruleset"](ruleset["id"], profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to delete ruleset {name}: {err}" + return ret + ret["comment"] = f"Deleted ruleset {name}" + return ret diff --git a/src/saltext/github/states/github_repo.py b/src/saltext/github/states/github_repo.py new file mode 100644 index 00000000..10b9d026 --- /dev/null +++ b/src/saltext/github/states/github_repo.py @@ -0,0 +1,1430 @@ +""" +Manage a single GitHub repository within an organization: the repository +itself, its rulesets, deploy keys, Actions secrets and settings, +environments, labels and Pages. + +.. versionadded:: 1.1.0 + +All states default ``repo_name`` to the value configured in the profile. +For the states managing per-repository singletons (:py:func:`present +`, :py:func:`absent +`, :py:func:`pages_present +`, ...), ``name`` +doubles as the repository name instead. +""" + +import logging +import time + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils import filter_unset +from saltext.github.utils.gh.exceptions import GitHubNotFoundError + +try: + from saltext.github.utils.gh import environments as gh_environments +except ImportError: # pragma: no cover + # PyGithub is missing; __virtual__ fails since the execution module + # is not loaded either. + gh_environments = None + +log = logging.getLogger(__name__) + +__virtualname__ = "github_repo" + + +def __virtual__(): + """ + Only load if the github_repo module is available in __salt__ + """ + if "github_repo.get_info" in __salt__: + return __virtualname__ + return (False, "github_repo module could not be loaded") # pragma: no cover + + +def _converge_teams(ret, name, teams, current_teams, profile): + """ + Converge the teams a repository belongs to, recording changes in + ``ret``. Returns false when the state should abort. + """ + current_team_names = {team["name"] for team in current_teams} + + # First remove any teams that aren't present + for team_name in current_team_names: + if team_name in teams: + continue + team_change = { + "old": f"Repo {name} is in team {team_name}", + "new": f"Repo {name} is not in team {team_name}", + } + + if __opts__["test"]: + ret["changes"][team_name] = team_change + ret["result"] = None + else: + try: + __salt__["github_org.remove_team_repo"](name, team_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to remove repo {name} from team {team_name}: {err}" + return False + ret["changes"][team_name] = team_change + + # Next add or modify any necessary teams + for team_name, permission in teams.items(): + if team_name not in current_team_names: # Need to add repo to team + team_change = { + "old": f"Repo {name} is not in team {team_name}", + "new": f"Repo {name} is in team {team_name}", + } + if __opts__["test"]: + ret["changes"][team_name] = team_change + ret["result"] = None + else: + try: + __salt__["github_org.add_team_repo"]( + name, team_name, permission=permission, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to add repo {name} to team {team_name}: {err}" + return False + ret["changes"][team_name] = team_change + else: + try: + current_permission = ( + __salt__["github_org.list_team_repos"](team_name, profile=profile) + .get(name.lower(), {}) + .get("permission") + ) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = ( + f"Failed to determine current permission for team {team_name} " + f"in repo {name}: {err}" + ) + return False + if current_permission == permission: + continue + team_change = { + "old": f"Repo {name} in team {team_name} has permission {current_permission}", + "new": f"Repo {name} in team {team_name} has permission {permission}", + } + if __opts__["test"]: + ret["changes"][team_name] = team_change + ret["result"] = None + else: + try: + __salt__["github_org.add_team_repo"]( + name, team_name, permission=permission, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = ( + f"Failed to set permission on repo {name} " + f"for team {team_name} to {permission}: {err}" + ) + return False + ret["changes"][team_name] = team_change + return True + + +def present( # pylint: disable=too-many-statements,too-many-branches + name, + description=None, + homepage=None, + private=None, + has_issues=None, + has_wiki=None, + has_downloads=None, + auto_init=False, + gitignore_template=None, + license_template=None, + default_branch=None, + teams=None, + profile="github", +): + """ + Ensure a repository is present and configured as specified. + + name + The name of the repository. + + description + The description of the repository. + + homepage + The URL with more information about the repository. + + private + The visiblity of the repository. Changing this on an existing + repository requires ``allow_repo_privacy_changes`` to be enabled + in the profile. + + has_issues + Whether to enable issues for this repository. + + has_wiki + Whether to enable the wiki for this repository. + + has_downloads + Whether to enable downloads for this repository. + + auto_init + Whether to create an initial commit with an empty README + when creating the repository. + + gitignore_template + The desired language or platform for a .gitignore, e.g "Haskell". + Only applies when creating the repository. + + license_template + The desired LICENSE template to apply, e.g "mit" or "mozilla". + Only applies when creating the repository. + + default_branch + The default branch of the repository. The branch must already exist, + so this is not applied when the repository is newly created. + + teams + The teams this repo should belong to, specified as a dict of + team name to permission (``pull``, ``push`` or ``admin``). + Unlisted teams are removed from the repository. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + Example: + + .. code-block:: yaml + + Ensure repo my-repo is present in github: + github_repo.present: + - name: my-repo + - description: My very important repository + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + # This is an optimization to cache all repos in the organization up front. + # The first use of this state will collect all of the repos and save a bunch + # of API calls for future use. + __salt__["github_org.list_repos"](profile=profile) + + try: + target = __salt__["github_repo.get_info"](repo_name=name, profile=profile) + except GitHubNotFoundError: + target = None + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get repo {name}: {err}" + return ret + + given_params = { + "description": description, + "homepage": homepage, + "private": private, + "has_issues": has_issues, + "has_wiki": has_wiki, + "has_downloads": has_downloads, + "auto_init": auto_init, + "gitignore_template": gitignore_template, + "license_template": license_template, + "default_branch": default_branch, + } + + # Keep track of current_teams if we've fetched them after creating a new repo + current_teams = None + + if target: # Repo already exists + # Some params are only valid on repo creation + ignore_params = ["auto_init", "gitignore_template", "license_template"] + parameters = {} + old_parameters = {} + for param_name, param_value in given_params.items(): + if ( + param_value is not None + and param_name not in ignore_params + and target[param_name] is not param_value + and target[param_name] != param_value + ): + parameters[param_name] = param_value + old_parameters[param_name] = target[param_name] + + if parameters: + repo_change = { + "old": f"Repo properties were {old_parameters}", + "new": f"Repo properties (that changed) are {parameters}", + } + if __opts__["test"]: + ret["changes"]["repo"] = repo_change + ret["result"] = None + else: + try: + __salt__["github_repo.update"](repo_name=name, profile=profile, **parameters) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to update repo properties: {err}" + return ret + ret["changes"]["repo"] = repo_change + + else: # Repo does not exist - it will be created. + repo_change = {"old": None, "new": f"Repo {name} has been created"} + if __opts__["test"]: + ret["changes"]["repo"] = repo_change + ret["result"] = None + else: + add_params = { + param: value + for param, value in given_params.items() + # The default branch can only be switched to an existing branch + if param != "default_branch" + } + try: + __salt__["github_org.add_repo"](name, profile=profile, **add_params) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to create repo {name}: {err}" + return ret + + # Turns out that trying to fetch teams for a new repo can 404 immediately + # after repo creation, so this waits until we can fetch teams successfully + # before continuing. + for attempt in range(3): + time.sleep(1) + try: + current_teams = __salt__["github_repo.get_teams"]( + repo_name=name, profile=profile + ) + break + except CommandExecutionError: + log.info("Attempt %s to fetch new repo %s failed", attempt, name) + + if current_teams is None: + ret["result"] = False + ret["comment"] = f"Failed to verify repo {name} after creation." + return ret + + ret["changes"]["repo"] = repo_change + + if teams is not None: + if __opts__["test"] and not target: + # Assume no teams if we're in test mode and the repo doesn't exist + current_teams = [] + elif current_teams is None: + try: + current_teams = __salt__["github_repo.get_teams"](repo_name=name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get teams of repo {name}: {err}" + return ret + if not _converge_teams(ret, name, teams, current_teams, profile): + return ret + return ret + + +def absent(name, profile="github"): + """ + Ensure a repository is absent. + + Example: + + .. code-block:: yaml + + Ensure repo test is absent in github: + github_repo.absent: + - name: test + + name + The name of the repository in the organization. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + __salt__["github_repo.get_info"](repo_name=name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Repo {name} does not exist" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get repo {name}: {err}" + return ret + + ret["changes"]["old"] = f"Repo {name} exists" + ret["changes"]["new"] = f"Repo {name} deleted" + if __opts__["test"]: + ret["comment"] = f"Repo {name} would have been deleted" + ret["result"] = None + return ret + + try: + __salt__["github_repo.delete"](repo_name=name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = ( + f"Failed to delete repo {name}: {err}. " + "Ensure the delete_repo scope is enabled if using OAuth." + ) + return ret + ret["comment"] = f"Deleted repo {name}" + return ret + + +def ruleset_present(name, ruleset_params=None, repo_name=None, profile="github"): + """ + Ensure a repository ruleset is present and configured as specified. + Only the parameters included in ``ruleset_params`` are compared, + server-populated fields are ignored. + + Example: + + .. code-block:: yaml + + Ensure ruleset test is present in my-repo: + github_repo.ruleset_present: + - name: test + - repo_name: my-repo + - ruleset_params: + target: branch + enforcement: active + + name + The name of the ruleset. + + ruleset_params + The ruleset definition. Must contain at least ``enforcement`` + when the ruleset needs to be created. The ``name`` is set from + this state's ``name``. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + ruleset_params = dict(ruleset_params or {}) + ruleset_params["name"] = name + + try: + rulesets = __salt__["github_repo.list_rulesets"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list rulesets: {err}" + return ret + + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + + if ruleset is None: + ret["changes"]["old"] = None + ret["changes"]["new"] = f"Ruleset {name} created" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been created" + return ret + try: + __salt__["github_repo.add_ruleset"]( + ruleset_params, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to create ruleset {name}: {err}" + return ret + ret["comment"] = f"Ruleset {name} has been created" + return ret + + try: + ruleset_info = __salt__["github_repo.get_ruleset"]( + ruleset["id"], repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get ruleset {name}: {err}" + return ret + + # Only diff the parameters that are being managed. The server + # response carries additional server-populated fields (id, + # timestamps, source, ...) that must not register as drift. + diff = {key: val for key, val in ruleset_params.items() if ruleset_info.get(key) != val} + if not diff: + ret["comment"] = f"Ruleset {name} is already configured as specified" + return ret + + ret["changes"]["old"] = {key: ruleset_info.get(key) for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been updated" + return ret + try: + __salt__["github_repo.update_ruleset"]( + ruleset["id"], ruleset_params, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to update ruleset {name}: {err}" + return ret + ret["comment"] = f"Ruleset {name} has been updated" + return ret + + +def ruleset_absent(name, repo_name=None, profile="github"): + """ + Ensure a repository ruleset is absent. + + Example: + + .. code-block:: yaml + + Ensure ruleset test is absent in my-repo: + github_repo.ruleset_absent: + - name: test + - repo_name: my-repo + + name + The name of the ruleset. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + rulesets = __salt__["github_repo.list_rulesets"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list rulesets: {err}" + return ret + + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + if ruleset is None: + ret["comment"] = f"Ruleset {name} does not exist" + return ret + + ret["changes"]["old"] = f"Ruleset {name} exists" + ret["changes"]["new"] = f"Ruleset {name} deleted" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been deleted" + return ret + try: + __salt__["github_repo.delete_ruleset"](ruleset["id"], repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to delete ruleset {name}: {err}" + return ret + ret["comment"] = f"Deleted ruleset {name}" + return ret + + +def _key_body(key): + """ + Reduce an SSH public key to its comparable part (type + base64 body), + ignoring whitespace variations and the comment. + """ + return " ".join(key.split()[:2]) + + +def deploy_key_present(name, key, read_only=False, repo_name=None, profile="github"): + """ + Ensure a deploy key with this title and public key is present + in a repository. + + Since deploy keys are immutable in the GitHub API, a key whose + ``key`` or ``read_only`` setting differs is replaced (removed + and recreated). + + Example: + + .. code-block:: yaml + + Autorelease deploy key is present: + github_repo.deploy_key_present: + - name: autorelease + - key: ssh-ed25519 AAAA... + - repo_name: my-repo + + name + The display title of the deploy key. + + key + The SSH public key. + + read_only + Deny write access to the repository for this key. Defaults to false. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + keys = __salt__["github_repo.list_deploy_keys"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list deploy keys: {err}" + return ret + + current = next((cur for cur in keys if cur["title"] == name), None) + + if current is not None: + if _key_body(current["key"]) == _key_body(key) and bool(current["read_only"]) is bool( + read_only + ): + ret["comment"] = f"Deploy key {name} is already present" + return ret + ret["changes"]["old"] = {"key": current["key"], "read_only": current["read_only"]} + ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been replaced" + return ret + try: + __salt__["github_repo.remove_deploy_key"]( + current["id"], repo_name=repo_name, profile=profile + ) + __salt__["github_repo.add_deploy_key"]( + name, key, read_only=read_only, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to replace deploy key {name}: {err}" + return ret + ret["comment"] = f"Deploy key {name} has been replaced" + return ret + + ret["changes"]["old"] = None + ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been added" + return ret + try: + __salt__["github_repo.add_deploy_key"]( + name, key, read_only=read_only, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to add deploy key {name}: {err}" + return ret + ret["comment"] = f"Deploy key {name} has been added" + return ret + + +def deploy_key_absent(name, repo_name=None, profile="github"): + """ + Ensure no deploy key with this title is present in a repository. + + name + The display title of the deploy key. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + keys = __salt__["github_repo.list_deploy_keys"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list deploy keys: {err}" + return ret + + current = next((cur for cur in keys if cur["title"] == name), None) + if current is None: + ret["comment"] = f"Deploy key {name} is already absent" + return ret + + ret["changes"]["old"] = f"Deploy key {name} exists" + ret["changes"]["new"] = f"Deploy key {name} removed" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been removed" + return ret + try: + __salt__["github_repo.remove_deploy_key"]( + current["id"], repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to remove deploy key {name}: {err}" + return ret + ret["comment"] = f"Deploy key {name} has been removed" + return ret + + +def actions_secret_present(name, value=None, force=False, repo_name=None, profile="github"): + """ + Ensure an Actions secret is present in a repository. + + Secret values are write-only in the GitHub API, so this state cannot + detect value drift. By default, an existing secret is left untouched. + Set ``force`` to push the value on every run. + + name + The name of the secret. GitHub treats secret names + case-insensitively. + + value + The plaintext secret value. It is encrypted to the repository + public key before leaving the minion and never appears in the + state output. Required when the secret does not exist yet or + ``force`` is set. + + force + Push the value even when the secret already exists. + Defaults to false. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + secrets = __salt__["github_repo.list_actions_secrets"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list Actions secrets: {err}" + return ret + + current = next((sec for sec in secrets if sec["name"].lower() == name.lower()), None) + + if current is not None and not force: + ret["comment"] = ( + f"Secret {name} is already present. Note that secret values are " + "write-only and cannot be verified, set force to push the value." + ) + return ret + + if value is None: + ret["result"] = False + ret["comment"] = f"A value is required to set secret {name}" + return ret + + if current is None: + ret["changes"]["old"] = None + ret["changes"]["new"] = f"Secret {name} set" + verb = "created" + else: + ret["changes"]["old"] = f"Secret {name} exists" + ret["changes"]["new"] = f"Secret {name} updated" + verb = "updated" + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Secret {name} would have been {verb}" + return ret + try: + __salt__["github_repo.set_actions_secret"]( + name, value, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to set secret {name}: {err}" + return ret + ret["comment"] = f"Secret {name} has been {verb}" + return ret + + +def actions_secret_absent(name, repo_name=None, profile="github"): + """ + Ensure an Actions secret is absent from a repository. + + name + The name of the secret. GitHub treats secret names + case-insensitively. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + secrets = __salt__["github_repo.list_actions_secrets"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list Actions secrets: {err}" + return ret + + current = next((sec for sec in secrets if sec["name"].lower() == name.lower()), None) + if current is None: + ret["comment"] = f"Secret {name} is already absent" + return ret + + ret["changes"]["old"] = f"Secret {name} exists" + ret["changes"]["new"] = f"Secret {name} removed" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Secret {name} would have been removed" + return ret + try: + __salt__["github_repo.remove_actions_secret"]( + current["name"], repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to remove secret {name}: {err}" + return ret + ret["comment"] = f"Secret {name} has been removed" + return ret + + +def workflow_permissions_managed( + name, + default_workflow_permissions=None, + can_approve_pull_request_reviews=None, + repo_name=None, + profile="github", +): + """ + Manage the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository. + + Example: + + .. code-block:: yaml + + Actions tokens are restricted in my-repo: + github_repo.workflow_permissions_managed: + - name: my-repo + - default_workflow_permissions: read + - can_approve_pull_request_reviews: true + + name + The name of the repository, unless ``repo_name`` is passed. + + default_workflow_permissions + The default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Whether GitHub Actions can approve pull requests. + + repo_name + The name of the repository, if it differs from ``name``. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + repo_name = repo_name or name + + desired = filter_unset( + { + "default_workflow_permissions": default_workflow_permissions, + "can_approve_pull_request_reviews": can_approve_pull_request_reviews, + } + ) + if not desired: + ret["result"] = False + ret["comment"] = "At least one setting to manage must be specified" + return ret + + try: + current = __salt__["github_repo.get_workflow_permissions"]( + repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get workflow permissions: {err}" + return ret + + diff = {key: val for key, val in desired.items() if current.get(key) != val} + if not diff: + ret["comment"] = "Workflow permissions are already set as specified" + return ret + + ret["changes"]["old"] = {key: current.get(key) for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = "Workflow permissions would have been updated" + return ret + try: + __salt__["github_repo.set_workflow_permissions"]( + repo_name=repo_name, profile=profile, **diff + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to set workflow permissions: {err}" + return ret + ret["comment"] = "Workflow permissions have been updated" + return ret + + +def _desired_branch_policy(deployment_branches): + """ + Map the state's ``deployment_branches`` parameter onto the API's + ``deployment_branch_policy`` setting. + """ + if deployment_branches == "all": + return None + if deployment_branches == "protected": + return {"protected_branches": True, "custom_branch_policies": False} + if isinstance(deployment_branches, list): + return {"protected_branches": False, "custom_branch_policies": True} + raise CommandExecutionError( + "deployment_branches must be 'all', 'protected' or a list of branch " + f"name patterns, got: {deployment_branches!r}" + ) + + +def _converge_branch_policies(ret, name, deployment_branches, *, env_exists, repo_name, profile): + """ + Converge an environment's deployment branch name patterns, recording + changes in ``ret``. Returns false when the state should abort. + """ + current_policies = [] + if env_exists: + try: + current_policies = __salt__["github_repo.list_deployment_branch_policies"]( + name, repo_name=repo_name, profile=profile + ) + except GitHubNotFoundError: + current_policies = [] + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to list deployment branch policies: {err}" + return False + current_patterns = {policy["name"] for policy in current_policies} + desired_patterns = set(deployment_branches) + if current_patterns == desired_patterns: + return True + ret["changes"]["deployment_branches"] = { + "old": sorted(current_patterns), + "new": sorted(desired_patterns), + } + if __opts__["test"]: + ret["result"] = None + return True + try: + for pattern in desired_patterns - current_patterns: + __salt__["github_repo.add_deployment_branch_policy"]( + name, pattern, repo_name=repo_name, profile=profile + ) + for policy in current_policies: + if policy["name"] not in desired_patterns: + __salt__["github_repo.remove_deployment_branch_policy"]( + name, policy["id"], repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to converge deployment branch policies: {err}" + return False + return True + + +def environment_present( # pylint: disable=too-many-statements,too-many-branches + name, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branches=None, + repo_name=None, + profile="github", +): + """ + Ensure an environment is present in a repository and configured + as specified. Unspecified settings are left unmanaged. + + Example: + + .. code-block:: yaml + + Release environment is present: + github_repo.environment_present: + - name: release + - repo_name: my-repo + - deployment_branches: + - main + + name + The name of the environment. + + wait_timer + The amount of time to delay a job after the job is initially + triggered, in minutes. + + prevent_self_review + Whether or not a user who created the job is prevented from + approving their own job. + + reviewers + The people or teams that may review jobs that reference the + environment, as a list of mappings with ``type`` (``User`` or + ``Team``) and ``id``. + + deployment_branches + Which branches can deploy to this environment. Either ``all``, + ``protected`` (branches with branch protection rules) or a list + of branch name patterns. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + current = __salt__["github_repo.get_environment"]( + name, repo_name=repo_name, profile=profile + ) + except GitHubNotFoundError: + current = None + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get environment {name}: {err}" + return ret + + desired = filter_unset( + { + "wait_timer": wait_timer, + "prevent_self_review": prevent_self_review, + "reviewers": reviewers, + } + ) + if deployment_branches is not None: + try: + desired["deployment_branch_policy"] = _desired_branch_policy(deployment_branches) + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + return ret + + env_diff = {} + if current is None: + ret["changes"]["environment"] = {"old": None, "new": f"Environment {name} created"} + set_kwargs = dict(desired) + else: + current_settings = gh_environments.normalize(current) + for key, val in desired.items(): + current_val = current_settings.get(key) + if key == "reviewers": + matches = sorted((reviewer["type"], reviewer["id"]) for reviewer in val) == sorted( + (reviewer["type"], reviewer["id"]) for reviewer in current_val + ) + else: + matches = current_val == val + if not matches: + env_diff[key] = val + ret["changes"][key] = {"old": current_val, "new": val} + # The API call replaces the whole configuration, so settings + # that are not managed here need to be carried over. + set_kwargs = {**current_settings, **desired} + + if env_diff or current is None: + if not __opts__["test"]: + try: + __salt__["github_repo.set_environment"]( + name, + repo_name=repo_name, + profile=profile, + **set_kwargs, + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to configure environment {name}: {err}" + return ret + + # Converge the branch name patterns for custom branch policies + if isinstance(deployment_branches, list) and not _converge_branch_policies( + ret, + name, + deployment_branches, + env_exists=current is not None, + repo_name=repo_name, + profile=profile, + ): + return ret + + if not ret["changes"]: + ret["comment"] = f"Environment {name} is already configured as specified" + elif __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Environment {name} would have been configured" + else: + ret["comment"] = f"Environment {name} has been configured" + return ret + + +def environment_absent(name, repo_name=None, profile="github"): + """ + Ensure an environment is absent from a repository. + + name + The name of the environment. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + __salt__["github_repo.get_environment"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Environment {name} is already absent" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get environment {name}: {err}" + return ret + + ret["changes"]["old"] = f"Environment {name} exists" + ret["changes"]["new"] = f"Environment {name} removed" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Environment {name} would have been removed" + return ret + try: + __salt__["github_repo.remove_environment"](name, repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to remove environment {name}: {err}" + return ret + ret["comment"] = f"Environment {name} has been removed" + return ret + + +def _normalize_color(color): + """ + Normalize a label color for comparisons: lowercase, no leading ``#``. + """ + if color is None: + return None + return color.lstrip("#").lower() + + +def label_present(name, color=None, description=None, repo_name=None, profile="github"): + """ + Ensure a label is present in a repository and configured as + specified. Unspecified settings are left unmanaged. + + name + The name of the label. + + color + The hexadecimal color code for the label. A leading ``#`` + is accepted. + + description + A short description of the label. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + current = __salt__["github_repo.get_label"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + current = None + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get label {name}: {err}" + return ret + + if current is None: + ret["changes"]["old"] = None + ret["changes"]["new"] = f"Label {name} created" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Label {name} would have been created" + return ret + try: + __salt__["github_repo.add_label"]( + name, color=color, description=description, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to create label {name}: {err}" + return ret + ret["comment"] = f"Label {name} has been created" + return ret + + diff = {} + if color is not None and _normalize_color(current["color"]) != _normalize_color(color): + diff["color"] = color + if description is not None and current.get("description") != description: + diff["description"] = description + + if not diff: + ret["comment"] = f"Label {name} is already configured as specified" + return ret + + ret["changes"]["old"] = {key: current.get(key) for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Label {name} would have been updated" + return ret + try: + __salt__["github_repo.edit_label"](name, repo_name=repo_name, profile=profile, **diff) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to update label {name}: {err}" + return ret + ret["comment"] = f"Label {name} has been updated" + return ret + + +def label_absent(name, repo_name=None, profile="github"): + """ + Ensure a label is absent from a repository. + + name + The name of the label. + + repo_name + The name of the repository. This argument is required, either passed + here or defined in the configured profile. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + __salt__["github_repo.get_label"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Label {name} is already absent" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get label {name}: {err}" + return ret + + ret["changes"]["old"] = f"Label {name} exists" + ret["changes"]["new"] = f"Label {name} removed" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Label {name} would have been removed" + return ret + try: + __salt__["github_repo.remove_label"](name, repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to remove label {name}: {err}" + return ret + ret["comment"] = f"Label {name} has been removed" + return ret + + +def pages_present( + name, + build_type=None, + source_branch=None, + source_path=None, + https_enforced=None, + repo_name=None, + profile="github", +): + """ + Ensure GitHub Pages are enabled for a repository and configured as + specified. Unspecified settings are left unmanaged. + + Example: + + .. code-block:: yaml + + Pages are deployed via Actions in my-repo: + github_repo.pages_present: + - name: my-repo + - build_type: workflow + + name + The name of the repository, unless ``repo_name`` is passed. + + build_type + The process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + https_enforced + Whether HTTPS is enforced for the site. + + repo_name + The name of the repository, if it differs from ``name``. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + repo_name = repo_name or name + + try: + current = __salt__["github_repo.get_pages"](repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + current = None + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get Pages configuration: {err}" + return ret + + if current is None: + ret["changes"]["old"] = None + ret["changes"]["new"] = "Pages site created" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Pages would have been enabled for {repo_name}" + return ret + try: + __salt__["github_repo.setup_pages"]( + build_type=build_type, + source_branch=source_branch, + source_path=source_path, + repo_name=repo_name, + profile=profile, + ) + if https_enforced is not None: + __salt__["github_repo.update_pages"]( + https_enforced=https_enforced, repo_name=repo_name, profile=profile + ) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to enable Pages for {repo_name}: {err}" + return ret + ret["comment"] = f"Pages have been enabled for {repo_name}" + return ret + + current_source = current.get("source") or {} + desired = filter_unset( + { + "build_type": build_type, + "source_branch": source_branch, + "source_path": source_path, + "https_enforced": https_enforced, + } + ) + current_flat = { + "build_type": current.get("build_type"), + "source_branch": current_source.get("branch"), + "source_path": current_source.get("path"), + "https_enforced": current.get("https_enforced"), + } + diff = {key: val for key, val in desired.items() if current_flat[key] != val} + + if not diff: + ret["comment"] = f"Pages for {repo_name} are already configured as specified" + return ret + + ret["changes"]["old"] = {key: current_flat[key] for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Pages configuration for {repo_name} would have been updated" + return ret + update_kwargs = dict(diff) + if "source_branch" in diff or "source_path" in diff: + # The source is submitted as a single object, carry over the + # unchanged half + update_kwargs.setdefault("source_branch", current_flat["source_branch"]) + update_kwargs.setdefault("source_path", current_flat["source_path"]) + try: + __salt__["github_repo.update_pages"](repo_name=repo_name, profile=profile, **update_kwargs) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to update Pages configuration for {repo_name}: {err}" + return ret + ret["comment"] = f"Pages configuration for {repo_name} has been updated" + return ret + + +def pages_absent(name, repo_name=None, profile="github"): + """ + Ensure GitHub Pages are disabled for a repository. + + name + The name of the repository, unless ``repo_name`` is passed. + + repo_name + The name of the repository, if it differs from ``name``. + + profile + The name of the profile configuration to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + repo_name = repo_name or name + + try: + __salt__["github_repo.get_pages"](repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Pages are already disabled for {repo_name}" + return ret + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = f"Failed to get Pages configuration: {err}" + return ret + + ret["changes"]["old"] = f"Pages enabled for {repo_name}" + ret["changes"]["new"] = f"Pages disabled for {repo_name}" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Pages would have been disabled for {repo_name}" + return ret + try: + __salt__["github_repo.remove_pages"](repo_name=repo_name, profile=profile) + except CommandExecutionError as err: + ret["result"] = False + ret["changes"] = {} + ret["comment"] = f"Failed to disable Pages for {repo_name}: {err}" + return ret + ret["comment"] = f"Pages have been disabled for {repo_name}" + return ret diff --git a/src/saltext/github/utils/gh/profile.py b/src/saltext/github/utils/gh/profile.py new file mode 100644 index 00000000..c0842400 --- /dev/null +++ b/src/saltext/github/utils/gh/profile.py @@ -0,0 +1,97 @@ +""" +Resolution of Salt profile configuration into GitHub connections, +shared between the ``github``, ``github_org`` and ``github_repo`` +execution modules. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh.client import API_URL_DEFAULT +from saltext.github.utils.gh.client import GitHubConnection + +log = logging.getLogger(__name__) + + +def get_config_value(profile, config_name, *, salt, context): + """ + Return a profile's configuration value based on the supplied + configuration name. Values are rendered via ``config.get`` + (resolving SDB URIs) and cached in ``context``. + + profile + The profile name that contains configuration information. + + config_name + The configuration item's name to use to return configuration values. + + salt + The ``__salt__`` dunder of the calling module. + + context + The ``__context__`` dunder of the calling module. + """ + if "gh_profile" not in context or profile not in context["gh_profile"]: + if not salt["config.get"](profile): + raise CommandExecutionError( + f"Authentication information could not be found for the '{profile}' profile." + ) + context.setdefault("gh_profile", {})[profile] = {} + + cache = context["gh_profile"][profile] + if config_name not in cache: + # Unlike config.option, config.get resolves SDB URIs in scalar values, + # hence request each value separately and cache the rendered result. + cache[config_name] = salt["config.get"](f"{profile}:{config_name}", default=None) + + config_value = cache[config_name] + if config_value is None: + raise CommandExecutionError( + f"The '{config_name}' parameter was not found in the '{profile}' profile." + ) + + return config_value + + +def get_api_url(profile, *, salt, context): + """ + Return the configured GitHub API base URL, defaulting to the public one. + + profile + The profile name that contains configuration information. + + salt + The ``__salt__`` dunder of the calling module. + + context + The ``__context__`` dunder of the calling module. + """ + try: + return get_config_value(profile, "api_url", salt=salt, context=context).rstrip("/") + except CommandExecutionError: + return API_URL_DEFAULT + + +def get_conn(profile, *, salt, context): + """ + Return a :class:`~saltext.github.utils.gh.client.GitHubConnection` + for a profile, cached in ``context`` for performance. + + profile + The profile name that contains configuration information. + + salt + The ``__salt__`` dunder of the calling module. + + context + The ``__context__`` dunder of the calling module. + """ + token = get_config_value(profile, "token", salt=salt, context=context) + org_name = get_config_value(profile, "org_name", salt=salt, context=context) + api_url = get_api_url(profile, salt=salt, context=context) + key = f"github.{token}:{org_name}:{api_url}" + + if key not in context: + context[key] = GitHubConnection(token, org_name, api_url=api_url) + return context[key] diff --git a/src/saltext/github/utils/gh/repos.py b/src/saltext/github/utils/gh/repos.py index 157ebd5d..76ec4d27 100644 --- a/src/saltext/github/utils/gh/repos.py +++ b/src/saltext/github/utils/gh/repos.py @@ -167,7 +167,12 @@ def create( "license_template": license_template, } ) - conn.org.create_repo(name, **parameters) + repo = conn.org.create_repo(name, **parameters) + info = to_dict(repo) + conn.cache[("repo_info", repo.name.lower())] = info + if "repos" in conn.cache: + conn.cache["repos"].append(repo) + return info @api_request @@ -218,7 +223,7 @@ def update( } ) repo.edit(name, **parameters) - get_info(conn, name, ignore_cache=True) # Refresh cache + return get_info(conn, name, ignore_cache=True) # Refresh cache @api_request diff --git a/tests/functional/modules/github/test_pages.py b/tests/functional/modules/github/test_pages.py deleted file mode 100644 index 11314f49..00000000 --- a/tests/functional/modules/github/test_pages.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest -from salt.exceptions import CommandExecutionError - - -def test_get_pages(github): - ret = github.get_pages(repo_name="repo-b") - assert ret["build_type"] == "legacy" - assert ret["source"] == {"branch": "gh-pages", "path": "/"} - - -def test_get_pages_missing(github): - with pytest.raises(CommandExecutionError, match="Not Found"): - github.get_pages() # repo-a has no Pages site - - -def test_setup_pages(github, fake_org): - ret = github.setup_pages(build_type="workflow") - assert ret["build_type"] == "workflow" - assert fake_org.get_repo("repo-a").pages.build_type == "workflow" - - -def test_setup_pages_legacy_source(github, fake_org): - github.setup_pages(build_type="legacy", source_branch="docs", source_path="/docs") - pages = fake_org.get_repo("repo-a").pages - assert pages.source == {"branch": "docs", "path": "/docs"} - - -def test_update_pages(github, fake_org): - assert github.update_pages(build_type="workflow", repo_name="repo-b") is True - pages = fake_org.get_repo("repo-b").pages - assert pages.build_type == "workflow" - # Unspecified settings are left untouched - assert pages.source == {"branch": "gh-pages", "path": "/"} - - -def test_update_pages_https(github, fake_org): - assert github.update_pages(https_enforced=True, repo_name="repo-b") is True - assert fake_org.get_repo("repo-b").pages.https_enforced is True - - -def test_update_pages_missing(github): - with pytest.raises(CommandExecutionError, match="Not Found"): - github.update_pages(build_type="workflow") - - -def test_remove_pages(github, fake_org): - assert github.remove_pages(repo_name="repo-b") is True - assert fake_org.get_repo("repo-b").pages is None - - -def test_remove_pages_missing(github): - with pytest.raises(CommandExecutionError, match="Not Found"): - github.remove_pages() diff --git a/tests/functional/modules/github/test_repos.py b/tests/functional/modules/github/test_repos.py index 109aba5f..df5a91e9 100644 --- a/tests/functional/modules/github/test_repos.py +++ b/tests/functional/modules/github/test_repos.py @@ -64,11 +64,6 @@ def test_edit_repo(github, fake_org): assert github.get_repo_info("repo-a")["description"] == "Updated" -def test_edit_repo_default_branch(github, fake_org): - assert github.edit_repo("repo-a", default_branch="develop") is True - assert fake_org.get_repo("repo-a").default_branch == "develop" - - def test_edit_repo_privacy_guard(github): with pytest.raises(CommandExecutionError, match="privacy"): github.edit_repo("repo-a", private=True, profile="github_noprivacy") diff --git a/tests/functional/modules/github_org/__init__.py b/tests/functional/modules/github_org/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/modules/github_org/conftest.py b/tests/functional/modules/github_org/conftest.py new file mode 100644 index 00000000..57ee5b5e --- /dev/null +++ b/tests/functional/modules/github_org/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def github_org(modules): + return modules.github_org diff --git a/tests/functional/modules/github_org/test_members.py b/tests/functional/modules/github_org/test_members.py new file mode 100644 index 00000000..feef27cb --- /dev/null +++ b/tests/functional/modules/github_org/test_members.py @@ -0,0 +1,70 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_members(github_org): + assert sorted(github_org.list_members()) == ["user1", "user2"] + + +def test_list_members_uses_cache(github_org, fake_org): + assert sorted(github_org.list_members()) == ["user1", "user2"] + fake_org.add_member("newuser") + # Cached result is returned until the cache is bypassed + assert sorted(github_org.list_members()) == ["user1", "user2"] + assert sorted(github_org.list_members(ignore_cache=True)) == ["newuser", "user1", "user2"] + + +def test_get_member(github_org): + ret = github_org.get_member("user1") + assert ret["login"] == "user1" + assert ret["company"] == "Example Corp" + assert ret["in_org"] is True + assert ret["membership_state"] == "active" + + +def test_get_member_nonmember(github_org): + ret = github_org.get_member("newuser") + assert ret["in_org"] is False + assert ret["membership_state"] == "nonexistent" + + +def test_get_member_unknown_user(github_org): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.get_member("ghost") + + +def test_add_member(github_org, fake_org): + assert github_org.add_member("newuser") is True # membership is pending + assert fake_org.get_member("newuser").state == "pending" + ret = github_org.get_member("newuser") + assert ret["membership_state"] == "pending" + assert ret["in_org"] is False + + +def test_add_member_unknown_user(github_org): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.add_member("ghost") + + +def test_remove_member(github_org, fake_org): + assert github_org.remove_member("user2") is True + assert "user2" not in [member.data.login for member in fake_org.members] + assert sorted(github_org.list_members(ignore_cache=True)) == ["user1"] + + +def test_remove_member_not_a_member(github_org): + # Idempotent: the user is not a member afterwards + assert github_org.remove_member("newuser") is True + + +def test_remove_member_unknown_user(github_org): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.remove_member("ghost") + + +def test_list_members_without_mfa(github_org, fake_state): + assert github_org.list_members_without_mfa() == ["user2"] + fake_state.get_user("user1").data.two_factor_authentication = False + # Cached result is returned until the cache is bypassed + assert github_org.list_members_without_mfa() == ["user2"] + assert sorted(github_org.list_members_without_mfa(ignore_cache=True)) == ["user1", "user2"] diff --git a/tests/functional/modules/github_org/test_repos.py b/tests/functional/modules/github_org/test_repos.py new file mode 100644 index 00000000..bb548e7a --- /dev/null +++ b/tests/functional/modules/github_org/test_repos.py @@ -0,0 +1,32 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_repos(github_org): + assert set(github_org.list_repos()) == {"repo-a", "repo-b"} + + +def test_list_repos_private_filter(github_org): + assert github_org.list_repos(private=True) == ["repo-b"] + assert github_org.list_repos(private=False) == ["repo-a"] + + +def test_add_repo(github_org, fake_org): + ret = github_org.add_repo("new-repo", description="Brand new") + assert ret["name"] == "new-repo" + assert ret["description"] == "Brand new" + assert fake_org.get_repo("new-repo").description == "Brand new" + + +def test_add_repo_refreshes_caches(github_org, modules): + assert set(github_org.list_repos()) == {"repo-a", "repo-b"} + github_org.add_repo("new-repo", description="Brand new") + # Both the repo list and single-repo caches know the new repo + assert "new-repo" in github_org.list_repos() + assert modules.github_repo.get_info(repo_name="new-repo")["description"] == "Brand new" + + +def test_add_repo_duplicate(github_org): + # The API refuses to create a repo with an existing name + with pytest.raises(CommandExecutionError): + github_org.add_repo("repo-a") diff --git a/tests/functional/modules/github_org/test_rulesets.py b/tests/functional/modules/github_org/test_rulesets.py new file mode 100644 index 00000000..77d5e099 --- /dev/null +++ b/tests/functional/modules/github_org/test_rulesets.py @@ -0,0 +1,49 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_rulesets(github_org): + ret = github_org.list_rulesets() + assert [ruleset["name"] for ruleset in ret] == ["org-rules"] + + +def test_get_ruleset(github_org, fake_org): + existing = fake_org.rulesets["org-rules"] + ret = github_org.get_ruleset(existing.id) + assert ret["name"] == "org-rules" + assert ret["enforcement"] == "active" + assert ret["source_type"] == "Organization" + + +def test_get_ruleset_missing(github_org): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.get_ruleset(1234) + + +def test_add_ruleset(github_org, fake_org): + ret = github_org.add_ruleset({"name": "new-rules", "enforcement": "evaluate"}) + assert ret["name"] == "new-rules" + assert fake_org.rulesets["new-rules"].enforcement == "evaluate" + + +def test_add_ruleset_missing_params(github_org): + with pytest.raises(CommandExecutionError, match="required"): + github_org.add_ruleset({"target": "branch"}) + + +def test_update_ruleset(github_org, fake_org): + existing = fake_org.rulesets["org-rules"] + ret = github_org.update_ruleset(existing.id, {"enforcement": "disabled"}) + assert ret["enforcement"] == "disabled" + assert fake_org.rulesets["org-rules"].enforcement == "disabled" + + +def test_delete_ruleset(github_org, fake_org): + existing = fake_org.rulesets["org-rules"] + assert github_org.delete_ruleset(existing.id) is True + assert "org-rules" not in fake_org.rulesets + + +def test_delete_ruleset_missing(github_org): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.delete_ruleset(1234) diff --git a/tests/functional/modules/github_org/test_teams.py b/tests/functional/modules/github_org/test_teams.py new file mode 100644 index 00000000..5d1fbd86 --- /dev/null +++ b/tests/functional/modules/github_org/test_teams.py @@ -0,0 +1,172 @@ +import pytest +from salt.exceptions import CommandExecutionError + +from tests.support.github import Team + + +def test_list_teams(github_org): + ret = github_org.list_teams() + assert "Team A" in ret + assert ret["Team A"]["slug"] == "team-a" + + +def test_list_teams_uses_cache(github_org, fake_org): + assert "Team A" in github_org.list_teams() + fake_org.add_team("Team B") + # Cached result is returned until the cache is bypassed + assert "Team B" not in github_org.list_teams() + assert "Team B" in github_org.list_teams(ignore_cache=True) + + +def test_get_team(github_org): + ret = github_org.get_team("Team A") + assert ret["slug"] == "team-a" + assert ret["description"] == "Awesome Team: Team A" + + +def test_get_team_missing(github_org): + with pytest.raises(CommandExecutionError, match="does not exist"): + github_org.get_team("ghost") + + +def test_add_team(github_org, fake_org): + assert github_org.add_team("Team B", description="Team B", privacy="closed") is True + assert fake_org.get_team("team-b").description == "Team B" + # add_team refreshes the team cache itself + assert github_org.get_team("Team B") + + +def test_add_team_with_repos(github_org, fake_org): + assert github_org.add_team("Team B", repo_names=["testorg/repo-b"]) is True + assert fake_org.get_team("team-b").repos["repo-b"] == "pull" + + +def test_add_team_duplicate(github_org): + # The API refuses to create a team whose slug already exists + with pytest.raises(CommandExecutionError): + github_org.add_team("Team A") + + +def test_edit_team(github_org, fake_org): + assert ( + github_org.edit_team("Team A", description="Renovated", privacy="secret", permission="push") + is True + ) + team = fake_org.get_team("team-a") + assert team.description == "Renovated" + assert team.privacy == "secret" + assert team.permission == "push" + + +def test_edit_team_missing(github_org): + with pytest.raises(CommandExecutionError, match="does not exist"): + github_org.edit_team("ghost", description="Nope") + + +def test_remove_team(github_org, fake_org): + assert github_org.remove_team("Team A") is True + assert "team-a" not in [team.slug for team in fake_org.teams] + + +def test_remove_team_missing(github_org): + with pytest.raises(CommandExecutionError, match="does not exist"): + github_org.remove_team("ghost") + + +def test_remove_team_stale_cache(github_org, fake_org): + # A team known to the module cache but deleted on the server side + github_org.list_teams() + fake_org.remove_team("team-a") + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.remove_team("Team A") + + +def test_list_team_repos(github_org): + ret = github_org.list_team_repos("Team A") + assert set(ret) == {"repo-a"} + assert ret["repo-a"]["permission"] == "pull" + + +def test_list_team_repos_missing_team(github_org): + with pytest.raises(CommandExecutionError, match="does not exist"): + github_org.list_team_repos("ghost") + + +def test_list_team_repos_uses_cache(github_org, fake_org): + assert set(github_org.list_team_repos("Team A")) == {"repo-a"} + fake_org.get_team("team-a").add_repo("repo-b", "admin") + # Cached result is returned until the cache is bypassed + assert set(github_org.list_team_repos("Team A")) == {"repo-a"} + refreshed = github_org.list_team_repos("Team A", ignore_cache=True) + assert set(refreshed) == {"repo-a", "repo-b"} + assert refreshed["repo-b"]["permission"] == "admin" + + +def test_add_team_repo(github_org, fake_org): + assert github_org.add_team_repo("repo-b", "Team A", permission="push") is True + assert fake_org.get_team("team-a").repos["repo-b"] == "push" + assert github_org.list_team_repos("Team A")["repo-b"]["permission"] == "push" + + +def test_add_team_repo_missing_team(github_org): + with pytest.raises(CommandExecutionError, match="does not exist"): + github_org.add_team_repo("repo-a", "ghost") + + +def test_add_team_repo_missing_repo(github_org): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.add_team_repo("ghost", "Team A") + + +def test_remove_team_repo(github_org, fake_org): + assert github_org.remove_team_repo("repo-a", "Team A") is True + assert "repo-a" not in fake_org.get_team("team-a").repos + + +def test_remove_team_repo_missing_repo(github_org): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.remove_team_repo("ghost", "Team A") + + +def test_list_team_members(github_org): + assert github_org.list_team_members("Team A") == ["user1"] + + +def test_list_team_members_missing_team(github_org): + with pytest.raises(CommandExecutionError, match="does not exist"): + github_org.list_team_members("ghost") + + +def test_list_team_members_uses_cache(github_org, fake_org): + assert github_org.list_team_members("Team A") == ["user1"] + fake_org.get_team("team-a").add_member("user2") + # Cached result is returned until the cache is bypassed + assert github_org.list_team_members("Team A") == ["user1"] + assert sorted(github_org.list_team_members("Team A", ignore_cache=True)) == ["user1", "user2"] + + +def test_is_team_member(github_org): + assert github_org.is_team_member("user1", "Team A") is True + assert github_org.is_team_member("user2", "Team A") is False + + +def test_add_team_member(github_org, fake_org): + assert github_org.add_team_member("user2", "Team A") is True + assert "user2" in fake_org.get_team("team-a").data.members + + +def test_add_team_member_missing_user(github_org): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.add_team_member("ghost", "Team A") + + +def test_remove_team_member(github_org, fake_org): + assert github_org.remove_team_member("user1", "Team A") is True + assert "user1" not in fake_org.get_team("team-a").members + + +def test_remove_team_member_not_removed(github_org, monkeypatch): + # The API reports success, but the member is still present afterwards + monkeypatch.setattr(Team, "remove_member", lambda *args, **kwargs: None) + with pytest.raises(CommandExecutionError, match="Failed to remove member"): + github_org.remove_team_member("user1", "Team A") diff --git a/tests/functional/modules/github_repo/__init__.py b/tests/functional/modules/github_repo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/modules/github_repo/conftest.py b/tests/functional/modules/github_repo/conftest.py new file mode 100644 index 00000000..fb65d5f5 --- /dev/null +++ b/tests/functional/modules/github_repo/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def github_repo(modules): + return modules.github_repo diff --git a/tests/functional/modules/github/test_actions_secrets.py b/tests/functional/modules/github_repo/test_actions_secrets.py similarity index 50% rename from tests/functional/modules/github/test_actions_secrets.py rename to tests/functional/modules/github_repo/test_actions_secrets.py index 0c8d40cd..4e7a42fc 100644 --- a/tests/functional/modules/github/test_actions_secrets.py +++ b/tests/functional/modules/github_repo/test_actions_secrets.py @@ -2,49 +2,49 @@ from salt.exceptions import CommandExecutionError -def test_list_actions_secrets(github): - ret = github.list_actions_secrets() +def test_list_actions_secrets(github_repo): + ret = github_repo.list_actions_secrets() assert [secret["name"] for secret in ret] == ["EXISTING_SECRET"] # Secret values are write-only assert "value" not in ret[0] -def test_get_actions_secret(github): +def test_get_actions_secret(github_repo): # Secret names are case-insensitive - ret = github.get_actions_secret("existing_secret") + ret = github_repo.get_actions_secret("existing_secret") assert ret["name"] == "EXISTING_SECRET" assert "value" not in ret -def test_get_actions_secret_missing(github): +def test_get_actions_secret_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): - github.get_actions_secret("GHOST") + github_repo.get_actions_secret("GHOST") -def test_set_actions_secret_new(github, fake_org): - assert github.set_actions_secret("NEW_SECRET", "s3cr3t") is True +def test_set_actions_secret_new(github_repo, fake_org): + assert github_repo.set_actions_secret("NEW_SECRET", "s3cr3t") is True # The fake decrypts the submitted sealed box, proving the # encryption roundtrip end-to-end assert fake_org.get_repo("repo-a").get_secret("NEW_SECRET").value == "s3cr3t" -def test_set_actions_secret_update(github, fake_org): - assert github.set_actions_secret("EXISTING_SECRET", "rotated") is True +def test_set_actions_secret_update(github_repo, fake_org): + assert github_repo.set_actions_secret("EXISTING_SECRET", "rotated") is True assert fake_org.get_repo("repo-a").get_secret("EXISTING_SECRET").value == "rotated" -def test_set_actions_secret_encrypted_in_transit(github, fake_github): - github.set_actions_secret("NEW_SECRET", "s3cr3t") +def test_set_actions_secret_encrypted_in_transit(github_repo, fake_github): + github_repo.set_actions_secret("NEW_SECRET", "s3cr3t") puts = fake_github.filter_requests(method="PUT", path="*/actions/secrets/NEW_SECRET") assert puts assert "s3cr3t" not in str(puts[0].body) -def test_remove_actions_secret(github, fake_org): - assert github.remove_actions_secret("EXISTING_SECRET") is True +def test_remove_actions_secret(github_repo, fake_org): + assert github_repo.remove_actions_secret("EXISTING_SECRET") is True assert "EXISTING_SECRET" not in fake_org.get_repo("repo-a").secrets -def test_remove_actions_secret_missing(github): +def test_remove_actions_secret_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): - github.remove_actions_secret("GHOST") + github_repo.remove_actions_secret("GHOST") diff --git a/tests/functional/modules/github/test_deploy_keys.py b/tests/functional/modules/github_repo/test_deploy_keys.py similarity index 56% rename from tests/functional/modules/github/test_deploy_keys.py rename to tests/functional/modules/github_repo/test_deploy_keys.py index 5b49606c..5215385f 100644 --- a/tests/functional/modules/github/test_deploy_keys.py +++ b/tests/functional/modules/github_repo/test_deploy_keys.py @@ -5,31 +5,31 @@ from tests.support.github import DEPLOY_KEY_B -def test_list_deploy_keys(github): - ret = github.list_deploy_keys() +def test_list_deploy_keys(github_repo): + ret = github_repo.list_deploy_keys() assert [key["title"] for key in ret] == ["existing-key"] assert ret[0]["key"] == DEPLOY_KEY_A assert ret[0]["read_only"] is True -def test_list_deploy_keys_other_repo(github): - assert github.list_deploy_keys(repo_name="repo-b") == [] +def test_list_deploy_keys_other_repo(github_repo): + assert github_repo.list_deploy_keys(repo_name="repo-b") == [] -def test_get_deploy_key(github, fake_org): +def test_get_deploy_key(github_repo, fake_org): existing = next(iter(fake_org.get_repo("repo-a").deploy_keys)) - ret = github.get_deploy_key(existing.id) + ret = github_repo.get_deploy_key(existing.id) assert ret["title"] == "existing-key" assert ret["key"] == DEPLOY_KEY_A -def test_get_deploy_key_missing(github): +def test_get_deploy_key_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): - github.get_deploy_key(1234) + github_repo.get_deploy_key(1234) -def test_add_deploy_key(github, fake_org): - ret = github.add_deploy_key("autorelease", DEPLOY_KEY_B) +def test_add_deploy_key(github_repo, fake_org): + ret = github_repo.add_deploy_key("autorelease", DEPLOY_KEY_B) assert ret["title"] == "autorelease" assert ret["read_only"] is False repo = fake_org.get_repo("repo-a") @@ -37,19 +37,19 @@ def test_add_deploy_key(github, fake_org): assert added.key == DEPLOY_KEY_B -def test_add_deploy_key_read_only(github, fake_org): - github.add_deploy_key("autorelease", DEPLOY_KEY_B, read_only=True) +def test_add_deploy_key_read_only(github_repo, fake_org): + github_repo.add_deploy_key("autorelease", DEPLOY_KEY_B, read_only=True) repo = fake_org.get_repo("repo-a") added = next(key for key in repo.deploy_keys if key.title == "autorelease") assert added.read_only is True -def test_remove_deploy_key(github, fake_org): +def test_remove_deploy_key(github_repo, fake_org): existing = next(iter(fake_org.get_repo("repo-a").deploy_keys)) - assert github.remove_deploy_key(existing.id) is True + assert github_repo.remove_deploy_key(existing.id) is True assert len(fake_org.get_repo("repo-a").deploy_keys) == 0 -def test_remove_deploy_key_missing(github): +def test_remove_deploy_key_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): - github.remove_deploy_key(1234) + github_repo.remove_deploy_key(1234) diff --git a/tests/functional/modules/github/test_environments.py b/tests/functional/modules/github_repo/test_environments.py similarity index 54% rename from tests/functional/modules/github/test_environments.py rename to tests/functional/modules/github_repo/test_environments.py index 85d8a4ac..af679140 100644 --- a/tests/functional/modules/github/test_environments.py +++ b/tests/functional/modules/github_repo/test_environments.py @@ -4,74 +4,74 @@ CUSTOM_POLICY = {"protected_branches": False, "custom_branch_policies": True} -def test_list_environments(github): - ret = github.list_environments() +def test_list_environments(github_repo): + ret = github_repo.list_environments() assert [env["name"] for env in ret] == ["release"] -def test_get_environment(github): - ret = github.get_environment("release") +def test_get_environment(github_repo): + ret = github_repo.get_environment("release") assert ret["name"] == "release" assert ret["deployment_branch_policy"] == CUSTOM_POLICY -def test_get_environment_missing(github): +def test_get_environment_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): - github.get_environment("ghost") + github_repo.get_environment("ghost") -def test_set_environment_create(github, fake_org): - ret = github.set_environment("staging", wait_timer=5) +def test_set_environment_create(github_repo, fake_org): + ret = github_repo.set_environment("staging", wait_timer=5) assert ret["name"] == "staging" env = fake_org.get_repo("repo-a").get_environment("staging") assert env.wait_timer == 5 assert env.deployment_branch_policy is None -def test_set_environment_carries_over_policy(github, fake_org): +def test_set_environment_carries_over_policy(github_repo, fake_org): # A PUT replaces the whole config, the wrapper is expected to carry # over the current branch policy when it is not specified. - github.set_environment("release", wait_timer=10) + github_repo.set_environment("release", wait_timer=10) env = fake_org.get_repo("repo-a").get_environment("release") assert env.wait_timer == 10 assert env.deployment_branch_policy == CUSTOM_POLICY -def test_set_environment_reset_policy(github, fake_org): - github.set_environment("release", deployment_branch_policy=None) +def test_set_environment_reset_policy(github_repo, fake_org): + github_repo.set_environment("release", deployment_branch_policy=None) env = fake_org.get_repo("repo-a").get_environment("release") assert env.deployment_branch_policy is None -def test_set_environment_reviewers(github, fake_org): - github.set_environment("release", reviewers=[{"type": "User", "id": 42}]) +def test_set_environment_reviewers(github_repo, fake_org): + github_repo.set_environment("release", reviewers=[{"type": "User", "id": 42}]) env = fake_org.get_repo("repo-a").get_environment("release") assert env.reviewers == [{"type": "User", "id": 42}] -def test_remove_environment(github, fake_org): - assert github.remove_environment("release") is True +def test_remove_environment(github_repo, fake_org): + assert github_repo.remove_environment("release") is True assert "release" not in fake_org.get_repo("repo-a").environments -def test_remove_environment_missing(github): +def test_remove_environment_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): - github.remove_environment("ghost") + github_repo.remove_environment("ghost") -def test_list_deployment_branch_policies(github): - ret = github.list_deployment_branch_policies("release") +def test_list_deployment_branch_policies(github_repo): + ret = github_repo.list_deployment_branch_policies("release") assert [policy["name"] for policy in ret] == ["main"] -def test_add_deployment_branch_policy(github, fake_org): - ret = github.add_deployment_branch_policy("release", "release/*") +def test_add_deployment_branch_policy(github_repo, fake_org): + ret = github_repo.add_deployment_branch_policy("release", "release/*") assert ret["name"] == "release/*" env = fake_org.get_repo("repo-a").get_environment("release") assert {policy.name for policy in env.branch_policies} == {"main", "release/*"} -def test_remove_deployment_branch_policy(github, fake_org): +def test_remove_deployment_branch_policy(github_repo, fake_org): existing = next(iter(fake_org.get_repo("repo-a").get_environment("release").branch_policies)) - assert github.remove_deployment_branch_policy("release", existing.id) is True + assert github_repo.remove_deployment_branch_policy("release", existing.id) is True assert len(fake_org.get_repo("repo-a").get_environment("release").branch_policies) == 0 diff --git a/tests/functional/modules/github_repo/test_issues.py b/tests/functional/modules/github_repo/test_issues.py new file mode 100644 index 00000000..4684b5bd --- /dev/null +++ b/tests/functional/modules/github_repo/test_issues.py @@ -0,0 +1,56 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_issues(github_repo): + ret = github_repo.list_issues() + # Only open issues by default, pull requests are excluded + assert [issue["title"] for issue in ret] == ["Found a bug"] + assert ret[0]["user"] == "user1" + assert ret[0]["assignee"] == "user2" + assert ret[0]["labels"] == ["bug", "prio:high"] + assert ret[0]["milestone"] == "v1.0" + + +def test_list_issues_all(github_repo): + ret = github_repo.list_issues(state="all") + assert {issue["title"] for issue in ret} == {"Found a bug", "Stale issue"} + + +def test_list_issues_full_output(github_repo): + ret = github_repo.list_issues(output="full") + assert ret[0]["number"] == 1 + assert ret[0]["body"] == "" + + +def test_list_issues_missing_repo(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.list_issues(repo_name="ghost") + + +def test_get_issue(github_repo): + ret = github_repo.get_issue(1) + assert ret["title"] == "Found a bug" + assert ret["issue_number"] == 1 + + +def test_get_issue_full_output(github_repo): + ret = github_repo.get_issue(1, output="full") + assert ret["number"] == 1 + + +def test_get_issue_missing(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.get_issue(42) + + +def test_get_issue_comments(github_repo): + ret = github_repo.get_issue_comments(1) + assert len(ret) == 2 + assert {comment["user_login"] for comment in ret} == {"user1", "user2"} + assert "body" not in ret[0] + + +def test_get_issue_comments_full_output(github_repo): + ret = github_repo.get_issue_comments(1, output="full") + assert {comment["body"] for comment in ret} == {"Working on it", "Thanks!"} diff --git a/tests/functional/modules/github/test_labels.py b/tests/functional/modules/github_repo/test_labels.py similarity index 55% rename from tests/functional/modules/github/test_labels.py rename to tests/functional/modules/github_repo/test_labels.py index efd42ca1..9066da5f 100644 --- a/tests/functional/modules/github/test_labels.py +++ b/tests/functional/modules/github_repo/test_labels.py @@ -2,31 +2,31 @@ from salt.exceptions import CommandExecutionError -def test_list_labels(github): - ret = github.list_labels() +def test_list_labels(github_repo): + ret = github_repo.list_labels() assert [label["name"] for label in ret] == ["bug"] -def test_get_label(github): - ret = github.get_label("bug") +def test_get_label(github_repo): + ret = github_repo.get_label("bug") assert ret["color"] == "d73a4a" assert ret["description"] == "Something is broken" -def test_get_label_missing(github): +def test_get_label_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): - github.get_label("ghost") + github_repo.get_label("ghost") -def test_get_label_special_chars(github, fake_org): +def test_get_label_special_chars(github_repo, fake_org): # Label names are URL-quoted on the wire fake_org.get_repo("repo-a").add_label("prio: high", color="ff0000") - ret = github.get_label("prio: high") + ret = github_repo.get_label("prio: high") assert ret["name"] == "prio: high" -def test_add_label(github, fake_org): - ret = github.add_label("release", color="#00FF00", description="Release automation") +def test_add_label(github_repo, fake_org): + ret = github_repo.add_label("release", color="#00FF00", description="Release automation") assert ret["name"] == "release" label = fake_org.get_repo("repo-a").get_label("release") # The color is normalized before submission @@ -34,25 +34,25 @@ def test_add_label(github, fake_org): assert label.description == "Release automation" -def test_edit_label(github, fake_org): - github.edit_label("bug", color="ff0000") +def test_edit_label(github_repo, fake_org): + github_repo.edit_label("bug", color="ff0000") label = fake_org.get_repo("repo-a").get_label("bug") assert label.color == "ff0000" assert label.description == "Something is broken" -def test_edit_label_rename(github, fake_org): - github.edit_label("bug", new_name="defect") +def test_edit_label_rename(github_repo, fake_org): + github_repo.edit_label("bug", new_name="defect") repo = fake_org.get_repo("repo-a") assert "defect" in repo.labels assert "bug" not in repo.labels -def test_remove_label(github, fake_org): - assert github.remove_label("bug") is True +def test_remove_label(github_repo, fake_org): + assert github_repo.remove_label("bug") is True assert "bug" not in fake_org.get_repo("repo-a").labels -def test_remove_label_missing(github): +def test_remove_label_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): - github.remove_label("ghost") + github_repo.remove_label("ghost") diff --git a/tests/functional/modules/github_repo/test_milestones.py b/tests/functional/modules/github_repo/test_milestones.py new file mode 100644 index 00000000..83054e3c --- /dev/null +++ b/tests/functional/modules/github_repo/test_milestones.py @@ -0,0 +1,39 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_milestones(github_repo): + ret = github_repo.list_milestones() + assert [milestone["title"] for milestone in ret] == ["v1.0", "v2.0"] + # min output drops presentation fields + assert "creator" not in ret[0] + + +def test_list_milestones_full_output(github_repo): + ret = github_repo.list_milestones(output="full") + assert ret[0]["creator"]["login"] == "user1" + + +def test_get_milestone_by_number(github_repo): + ret = github_repo.get_milestone(1) + assert ret["title"] == "v1.0" + + +def test_get_milestone_by_name(github_repo): + ret = github_repo.get_milestone(name="v2.0") + assert ret["title"] == "v2.0" + + +def test_get_milestone_missing_number(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.get_milestone(42) + + +def test_get_milestone_missing_name(github_repo): + with pytest.raises(CommandExecutionError, match="was not found"): + github_repo.get_milestone(name="ghost") + + +def test_get_milestone_requires_identifier(github_repo): + with pytest.raises(CommandExecutionError, match="'name' or 'number'"): + github_repo.get_milestone() diff --git a/tests/functional/modules/github_repo/test_pages.py b/tests/functional/modules/github_repo/test_pages.py new file mode 100644 index 00000000..24d3e162 --- /dev/null +++ b/tests/functional/modules/github_repo/test_pages.py @@ -0,0 +1,53 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_get_pages(github_repo): + ret = github_repo.get_pages(repo_name="repo-b") + assert ret["build_type"] == "legacy" + assert ret["source"] == {"branch": "gh-pages", "path": "/"} + + +def test_get_pages_missing(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.get_pages() # repo-a has no Pages site + + +def test_setup_pages(github_repo, fake_org): + ret = github_repo.setup_pages(build_type="workflow") + assert ret["build_type"] == "workflow" + assert fake_org.get_repo("repo-a").pages.build_type == "workflow" + + +def test_setup_pages_legacy_source(github_repo, fake_org): + github_repo.setup_pages(build_type="legacy", source_branch="docs", source_path="/docs") + pages = fake_org.get_repo("repo-a").pages + assert pages.source == {"branch": "docs", "path": "/docs"} + + +def test_update_pages(github_repo, fake_org): + assert github_repo.update_pages(build_type="workflow", repo_name="repo-b") is True + pages = fake_org.get_repo("repo-b").pages + assert pages.build_type == "workflow" + # Unspecified settings are left untouched + assert pages.source == {"branch": "gh-pages", "path": "/"} + + +def test_update_pages_https(github_repo, fake_org): + assert github_repo.update_pages(https_enforced=True, repo_name="repo-b") is True + assert fake_org.get_repo("repo-b").pages.https_enforced is True + + +def test_update_pages_missing(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.update_pages(build_type="workflow") + + +def test_remove_pages(github_repo, fake_org): + assert github_repo.remove_pages(repo_name="repo-b") is True + assert fake_org.get_repo("repo-b").pages is None + + +def test_remove_pages_missing(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.remove_pages() diff --git a/tests/functional/modules/github_repo/test_pulls.py b/tests/functional/modules/github_repo/test_pulls.py new file mode 100644 index 00000000..37383046 --- /dev/null +++ b/tests/functional/modules/github_repo/test_pulls.py @@ -0,0 +1,10 @@ +def test_list_pulls(github_repo): + ret = github_repo.list_pulls() + assert [pr_["title"] for pr_ in ret] == ["Fix the bug"] + assert ret[0]["user"] == "user2" + assert ret[0]["base_branch"] == "main" + + +def test_list_pulls_full_output(github_repo): + ret = github_repo.list_pulls(output="full") + assert ret[0]["head"]["ref"] == "bugfix" diff --git a/tests/functional/modules/github_repo/test_repo.py b/tests/functional/modules/github_repo/test_repo.py new file mode 100644 index 00000000..42f0fe9c --- /dev/null +++ b/tests/functional/modules/github_repo/test_repo.py @@ -0,0 +1,69 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_get_info(github_repo): + # repo_name defaults to the profile's repo-a + ret = github_repo.get_info() + assert ret["name"] == "repo-a" + assert ret["full_name"] == "testorg/repo-a" + assert ret["description"] == "Test repo repo-a" + assert ret["private"] is False + assert ret["default_branch"] == "main" + + +def test_get_info_missing(github_repo): + with pytest.raises(CommandExecutionError, match="could not be found"): + github_repo.get_info(repo_name="ghost") + + +def test_get_info_uses_cache(github_repo, fake_org): + assert github_repo.get_info()["description"] == "Test repo repo-a" + fake_org.get_repo("repo-a").data.description = "mutated" + # Cached result is returned until the cache is bypassed + assert github_repo.get_info()["description"] == "Test repo repo-a" + assert github_repo.get_info(ignore_cache=True)["description"] == "mutated" + + +def test_get_teams(github_repo): + ret = github_repo.get_teams() + assert [team["name"] for team in ret] == ["Team A"] + + +def test_get_teams_missing_repo(github_repo): + with pytest.raises(CommandExecutionError, match="could not be found"): + github_repo.get_teams(repo_name="ghost") + + +def test_update(github_repo, fake_org): + ret = github_repo.update(description="Updated") + # The refreshed repo information is returned + assert ret["description"] == "Updated" + assert fake_org.get_repo("repo-a").description == "Updated" + assert github_repo.get_info()["description"] == "Updated" + + +def test_update_default_branch(github_repo, fake_org): + ret = github_repo.update(default_branch="develop") + assert ret["default_branch"] == "develop" + assert fake_org.get_repo("repo-a").default_branch == "develop" + + +def test_update_privacy_guard(github_repo): + with pytest.raises(CommandExecutionError, match="privacy"): + github_repo.update(private=True, profile="github_noprivacy") + + +def test_update_missing(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.update(repo_name="ghost", description="Nope") + + +def test_delete(github_repo, fake_org): + assert github_repo.delete(repo_name="repo-b") is True + assert "repo-b" not in fake_org.repos + + +def test_delete_missing(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.delete(repo_name="ghost") diff --git a/tests/functional/modules/github_repo/test_rulesets.py b/tests/functional/modules/github_repo/test_rulesets.py new file mode 100644 index 00000000..50ccb9ed --- /dev/null +++ b/tests/functional/modules/github_repo/test_rulesets.py @@ -0,0 +1,50 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def _ruleset_id(fake_org): + return fake_org.get_repo("repo-a").rulesets["repo-rules"].id + + +def test_list_rulesets(github_repo): + ret = github_repo.list_rulesets() + assert [ruleset["name"] for ruleset in ret] == ["repo-rules"] + + +def test_get_ruleset(github_repo, fake_org): + ret = github_repo.get_ruleset(_ruleset_id(fake_org)) + assert ret["name"] == "repo-rules" + assert ret["enforcement"] == "disabled" + assert ret["source_type"] == "Repository" + + +def test_get_ruleset_missing(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.get_ruleset(1234) + + +def test_add_ruleset(github_repo, fake_org): + ret = github_repo.add_ruleset({"name": "new-rules", "enforcement": "active"}) + assert ret["name"] == "new-rules" + assert fake_org.get_repo("repo-a").rulesets["new-rules"].enforcement == "active" + + +def test_add_ruleset_missing_params(github_repo): + with pytest.raises(CommandExecutionError, match="required"): + github_repo.add_ruleset({"target": "branch"}) + + +def test_update_ruleset(github_repo, fake_org): + ret = github_repo.update_ruleset(_ruleset_id(fake_org), {"enforcement": "active"}) + assert ret["enforcement"] == "active" + assert fake_org.get_repo("repo-a").rulesets["repo-rules"].enforcement == "active" + + +def test_delete_ruleset(github_repo, fake_org): + assert github_repo.delete_ruleset(_ruleset_id(fake_org)) is True + assert "repo-rules" not in fake_org.get_repo("repo-a").rulesets + + +def test_delete_ruleset_missing(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.delete_ruleset(1234) diff --git a/tests/functional/modules/github/test_workflow_permissions.py b/tests/functional/modules/github_repo/test_workflow_permissions.py similarity index 63% rename from tests/functional/modules/github/test_workflow_permissions.py rename to tests/functional/modules/github_repo/test_workflow_permissions.py index 9129f29a..1c5b6cba 100644 --- a/tests/functional/modules/github/test_workflow_permissions.py +++ b/tests/functional/modules/github_repo/test_workflow_permissions.py @@ -1,14 +1,14 @@ -def test_get_workflow_permissions(github): - ret = github.get_workflow_permissions() +def test_get_workflow_permissions(github_repo): + ret = github_repo.get_workflow_permissions() assert ret == { "default_workflow_permissions": "write", "can_approve_pull_request_reviews": False, } -def test_set_workflow_permissions(github, fake_org): +def test_set_workflow_permissions(github_repo, fake_org): assert ( - github.set_workflow_permissions( + github_repo.set_workflow_permissions( default_workflow_permissions="read", can_approve_pull_request_reviews=True ) is True @@ -18,8 +18,8 @@ def test_set_workflow_permissions(github, fake_org): assert repo.data.can_approve_pull_request_reviews is True -def test_set_workflow_permissions_partial(github, fake_org): - assert github.set_workflow_permissions(can_approve_pull_request_reviews=True) is True +def test_set_workflow_permissions_partial(github_repo, fake_org): + assert github_repo.set_workflow_permissions(can_approve_pull_request_reviews=True) is True repo = fake_org.get_repo("repo-a") # The other setting is left untouched assert repo.data.default_workflow_permissions == "write" diff --git a/tests/functional/states/github/test_repo.py b/tests/functional/states/github/test_repo.py index 52a36e8c..d5670cea 100644 --- a/tests/functional/states/github/test_repo.py +++ b/tests/functional/states/github/test_repo.py @@ -102,35 +102,6 @@ def test_repo_present_team_removal(github, fake_github, fake_org, testmode): assert ("repo-a" in fake_org.get_team("team-a").repos) is testmode -def test_repo_present_default_branch(github, fake_github, fake_org, testmode): - before = len(fake_github.mutations()) - ret = github.repo_present(name="repo-a", default_branch="develop", test=testmode) - assert ret.result is not False - assert (ret.result is None) is testmode - assert "repo" in ret.changes - assert (len(fake_github.mutations()) == before) is testmode - expected = "main" if testmode else "develop" - assert fake_org.get_repo("repo-a").default_branch == expected - - -def test_repo_present_default_branch_idempotent(github, fake_github, testmode): - ret = github.repo_present(name="repo-a", default_branch="main", test=testmode) - assert ret.result is True - assert not ret.changes - assert not fake_github.mutations() - - -def test_repo_present_create_ignores_default_branch(github, fake_org, _instant_sleep, testmode): - # The default branch can only be switched to an existing branch, - # so it is not applied on repository creation - ret = github.repo_present(name="new-repo", default_branch="develop", test=testmode) - assert ret.result is not False - assert (ret.result is None) is testmode - assert "repo" in ret.changes - if not testmode: - assert fake_org.get_repo("new-repo").default_branch == "main" - - def test_repo_present_missing_team(github): ret = github.repo_present(name="repo-b", teams={"Ghost": "pull"}) assert ret.result is False diff --git a/tests/functional/states/github_org/__init__.py b/tests/functional/states/github_org/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/states/github_org/conftest.py b/tests/functional/states/github_org/conftest.py new file mode 100644 index 00000000..7559e005 --- /dev/null +++ b/tests/functional/states/github_org/conftest.py @@ -0,0 +1,11 @@ +import pytest + + +@pytest.fixture +def github_org(states): + return states.github_org + + +@pytest.fixture(params=(False, True)) +def testmode(request): + return request.param diff --git a/tests/functional/states/github_org/test_member.py b/tests/functional/states/github_org/test_member.py new file mode 100644 index 00000000..cd371b43 --- /dev/null +++ b/tests/functional/states/github_org/test_member.py @@ -0,0 +1,70 @@ +import pytest + +from tests.support.github import NotFound + + +def test_member_present_invites_user(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.member_present(name="newuser", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert ret.changes["old"] is None + if testmode: + assert "would have been invited" in ret.comment + with pytest.raises(NotFound): + fake_org.get_member("newuser") + else: + assert fake_org.get_member("newuser").state == "pending" + + rerun = github_org.member_present(name="newuser") + assert rerun.result is True + assert not rerun.changes + assert "already been invited" in rerun.comment + + +def test_member_present_existing_member(github_org, fake_github, testmode): + ret = github_org.member_present(name="user1", test=testmode) + assert ret.result is True + assert not ret.changes + assert "already a member" in ret.comment + assert not fake_github.mutations() + + +def test_member_present_unknown_user(github_org, testmode): + ret = github_org.member_present(name="ghost", test=testmode) + assert ret.result is False + assert "does not exist" in ret.comment + + +def test_member_absent_removes_member(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.member_absent(name="user2", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert ret.changes + if testmode: + assert "would have been removed" in ret.comment + fake_org.get_member("user2") + else: + with pytest.raises(NotFound): + fake_org.get_member("user2") + + rerun = github_org.member_absent(name="user2") + assert rerun.result is True + assert not rerun.changes + + +def test_member_absent_nonmember(github_org, fake_github, testmode): + ret = github_org.member_absent(name="newuser", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_member_absent_unknown_user(github_org, testmode): + ret = github_org.member_absent(name="ghost", test=testmode) + assert ret.result is True + assert not ret.changes + assert "does not exist" in ret.comment diff --git a/tests/functional/states/github_org/test_ruleset.py b/tests/functional/states/github_org/test_ruleset.py new file mode 100644 index 00000000..2c2e40fa --- /dev/null +++ b/tests/functional/states/github_org/test_ruleset.py @@ -0,0 +1,98 @@ +def test_ruleset_present_idempotent(github_org, fake_github, testmode): + ret = github_org.ruleset_present( + name="org-rules", + ruleset_params={"target": "branch", "enforcement": "active"}, + test=testmode, + ) + assert ret.result is True + assert not ret.changes + assert "already configured" in ret.comment + assert not fake_github.mutations() + + +def test_ruleset_present_ignores_server_populated_fields(github_org, testmode): + # id/created_at/source etc. in the server response must not register + # as drift when only managed params are compared + ret = github_org.ruleset_present( + name="org-rules", ruleset_params={"enforcement": "active"}, test=testmode + ) + assert ret.result is True + assert not ret.changes + + +def test_ruleset_present_converges_drift(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.ruleset_present( + name="org-rules", + ruleset_params={"target": "branch", "enforcement": "evaluate"}, + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["old"] == {"enforcement": "active"} + assert ret.changes["new"] == {"enforcement": "evaluate"} + assert (len(fake_github.mutations()) == before) is testmode + expected = "active" if testmode else "evaluate" + assert fake_org.rulesets["org-rules"].enforcement == expected + if not testmode: + rerun = github_org.ruleset_present( + name="org-rules", ruleset_params={"target": "branch", "enforcement": "evaluate"} + ) + assert rerun.result is True + assert not rerun.changes + + +def test_ruleset_present_create(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.ruleset_present( + name="new-rules", + ruleset_params={"target": "branch", "enforcement": "active"}, + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["old"] is None + assert (len(fake_github.mutations()) == before) is testmode + assert ("new-rules" in fake_org.rulesets) is not testmode + + +def test_ruleset_present_create_failure(github_org): + ret = github_org.ruleset_present( + name="fresh-rules", ruleset_params={"enforcement": "active", "bogus_param": True} + ) + assert ret.result is False + assert "Failed to create ruleset" in ret.comment + assert not ret.changes + + +def test_ruleset_present_update_failure(github_org): + # Parameters unknown to the API are rejected with a validation error + ret = github_org.ruleset_present( + name="org-rules", ruleset_params={"enforcement": "evaluate", "bogus_param": True} + ) + assert ret.result is False + assert "Failed to update ruleset" in ret.comment + assert not ret.changes + + +def test_ruleset_absent(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.ruleset_absent(name="org-rules", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert bool(list(fake_org.rulesets)) is testmode + assert ret.changes == {"old": "Ruleset org-rules exists", "new": "Ruleset org-rules deleted"} + if testmode: + assert "would have been deleted" in ret.comment + else: + rerun = github_org.ruleset_absent(name="org-rules") + assert rerun.result is True + assert not rerun.changes + + +def test_ruleset_absent_missing(github_org, fake_github, testmode): + ret = github_org.ruleset_absent(name="ghost-rules", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() diff --git a/tests/functional/states/github_org/test_team.py b/tests/functional/states/github_org/test_team.py new file mode 100644 index 00000000..a5568fd7 --- /dev/null +++ b/tests/functional/states/github_org/test_team.py @@ -0,0 +1,218 @@ +import pytest + +from tests.support.github import NotFound + + +def test_team_present_create(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.team_present( + name="team-b", description="Team B", privacy="closed", members={"user1": {}}, test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert "team" in ret.changes + if testmode: + # member changes are not projected for new teams + assert "set to be created" in ret.comment + assert "team-b" not in fake_org.teams + else: + assert "user1" in ret.changes + created = fake_org.get_team("team-b") + assert set(created.data.members) == {"user1"} + + +def test_team_present_idempotent(github_org, fake_github, testmode): + ret = github_org.team_present( + name="Team A", + description="Awesome Team: Team A", + privacy="closed", + permission="pull", + members={"user1": {}}, + test=testmode, + ) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_team_present_converges_drift(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.team_present( + name="Team A", + description="Updated A", + privacy="closed", + permission="pull", + members={"user2": {}}, + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert "team" in ret.changes + assert "user1" in ret.changes # removed (managed members) + assert "user2" in ret.changes # added + team = fake_org.get_team("team-a") + if testmode: + assert "set to be edited" in ret.comment + assert "User user2 set to be added" in ret.comment + assert "User user1 set to be removed" in ret.comment + assert team.description == "Awesome Team: Team A" + assert set(team.data.members) == {"user1"} + else: + assert team.description == "Updated A" + assert set(team.data.members) == {"user2"} + + +def test_team_present_permission_privacy_drift(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.team_present( + name="Team A", + description="Awesome Team: Team A", + privacy="secret", + permission="push", + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert "team" in ret.changes + team = fake_org.get_team("team-a") + if testmode: + assert team.permission == "pull" + assert team.privacy == "closed" + else: + assert team.permission == "push" + assert team.privacy == "secret" + + +def test_team_present_create_failure(github_org): + # "team a" is not in the team cache (keyed by name), but its slug + # collides with the existing "Team A" on creation + ret = github_org.team_present(name="team a") + assert ret.result is False + assert "Failed to create team" in ret.comment + + +def test_team_present_manages_repos(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.team_present( + name="Team A", + description="Awesome Team: Team A", + privacy="closed", + repo_names=["repo-b"], + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert "repo-b" in ret.changes # added + assert "repo-a" in ret.changes # removed (not listed) + team = fake_org.get_team("team-a") + if testmode: + assert "repos added" in ret.comment + assert "repos removed" in ret.comment + assert "repo-a" in team.repos + assert "repo-b" not in team.repos + else: + assert "repo-b" in team.repos + assert "repo-a" not in team.repos + + +def test_team_present_add_repo_failure(github_org): + ret = github_org.team_present( + name="Team A", + description="Awesome Team: Team A", + privacy="closed", + repo_names=["ghost"], + ) + assert ret.result is False + assert "Failed to add repo ghost" in ret.comment + + +def test_team_present_add_member_failure(github_org): + ret = github_org.team_present( + name="Team A", + description="Awesome Team: Team A", + privacy="closed", + members={"user1": {}, "ghost": {}}, + ) + assert ret.result is False + assert "Failed to add user ghost" in ret.comment + + +def test_team_present_enforce_mfa_blocks_addition(github_org, fake_github, fake_org, testmode): + # user2 has no MFA and is not in the team: they are not added, which + # test mode explains in a comment + before = len(fake_github.mutations()) + ret = github_org.team_present( + name="Team A", + description="Awesome Team: Team A", + privacy="closed", + permission="pull", + members={"user1": {}, "user2": {}}, + enforce_mfa=True, + test=testmode, + ) + assert ret.result is True # nothing else changes in either mode + assert len(fake_github.mutations()) == before + assert set(fake_org.get_team("team-a").data.members) == {"user1"} + assert ("will not be added to the team because they do not have MFA" in ret.comment) is testmode + + +def test_team_present_enforce_mfa_removal(github_org, fake_github, fake_org, testmode): + # user2 has no MFA and is already in the team + fake_org.get_team("team-a").add_member("user2") + before = len(fake_github.mutations()) + ret = github_org.team_present( + name="Team A", + description="Awesome Team: Team A", + privacy="closed", + permission="pull", + members={"user1": {}, "user2": {}}, + enforce_mfa=True, + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert "due to MFA violation" in ret.changes["user2"]["new"] + team = fake_org.get_team("team-a") + if testmode: + assert "set to be removed from the team because they do not have MFA" in ret.comment + assert "user2" in team.members + else: + assert "user2" not in team.members + + +def test_team_present_mfa_exempt_member(github_org, fake_org): + # mfa_exempt members are added even when MFA is enforced + ret = github_org.team_present( + name="Team A", + description="Awesome Team: Team A", + privacy="closed", + members={"user1": {}, "user2": {"mfa_exempt": True}}, + enforce_mfa=True, + ) + assert ret.result is True + assert "user2" in ret.changes + assert "user2" in fake_org.get_team("team-a").members + + +def test_team_absent(github_org, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_org.team_absent(name="Team A", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert ret.changes == {"old": "Team Team A exists", "new": "Team Team A deleted"} + if testmode: + assert "would have been deleted" in ret.comment + fake_org.get_team("team-a") + else: + with pytest.raises(NotFound): + fake_org.get_team("team-a") + + rerun = github_org.team_absent(name="Team A") + assert rerun.result is True + assert not rerun.changes diff --git a/tests/functional/states/github_repo/__init__.py b/tests/functional/states/github_repo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/states/github_repo/conftest.py b/tests/functional/states/github_repo/conftest.py new file mode 100644 index 00000000..1763b9d1 --- /dev/null +++ b/tests/functional/states/github_repo/conftest.py @@ -0,0 +1,11 @@ +import pytest + + +@pytest.fixture +def github_repo(states): + return states.github_repo + + +@pytest.fixture(params=(False, True)) +def testmode(request): + return request.param diff --git a/tests/functional/states/github/test_actions_secret.py b/tests/functional/states/github_repo/test_actions_secret.py similarity index 61% rename from tests/functional/states/github/test_actions_secret.py rename to tests/functional/states/github_repo/test_actions_secret.py index e20290c6..5f27271c 100644 --- a/tests/functional/states/github/test_actions_secret.py +++ b/tests/functional/states/github_repo/test_actions_secret.py @@ -1,6 +1,6 @@ -def test_actions_secret_present_create(github, fake_github, fake_org, testmode): +def test_actions_secret_present_create(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.actions_secret_present(name="NEW_SECRET", value="s3cr3t", test=testmode) + ret = github_repo.actions_secret_present(name="NEW_SECRET", value="s3cr3t", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes @@ -12,14 +12,14 @@ def test_actions_secret_present_create(github, fake_github, fake_org, testmode): assert "NEW_SECRET" not in repo.secrets else: assert repo.get_secret("NEW_SECRET").value == "s3cr3t" - rerun = github.actions_secret_present(name="NEW_SECRET", value="s3cr3t") + rerun = github_repo.actions_secret_present(name="NEW_SECRET", value="s3cr3t") assert rerun.result is True assert not rerun.changes -def test_actions_secret_present_existing_untouched(github, fake_github, fake_org, testmode): +def test_actions_secret_present_existing_untouched(github_repo, fake_github, fake_org, testmode): # Secret values are write-only, so an existing secret counts as present - ret = github.actions_secret_present(name="existing_secret", value="other", test=testmode) + ret = github_repo.actions_secret_present(name="existing_secret", value="other", test=testmode) assert ret.result is True assert not ret.changes assert "write-only" in ret.comment @@ -27,9 +27,9 @@ def test_actions_secret_present_existing_untouched(github, fake_github, fake_org assert fake_org.get_repo("repo-a").get_secret("EXISTING_SECRET").value == "hunter2" -def test_actions_secret_present_force(github, fake_github, fake_org, testmode): +def test_actions_secret_present_force(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.actions_secret_present( + ret = github_repo.actions_secret_present( name="EXISTING_SECRET", value="rotated", force=True, test=testmode ) assert ret.result is not False @@ -41,35 +41,35 @@ def test_actions_secret_present_force(github, fake_github, fake_org, testmode): assert fake_org.get_repo("repo-a").get_secret("EXISTING_SECRET").value == expected -def test_actions_secret_present_requires_value(github, fake_github): - ret = github.actions_secret_present(name="ANOTHER_SECRET") +def test_actions_secret_present_requires_value(github_repo, fake_github): + ret = github_repo.actions_secret_present(name="ANOTHER_SECRET") assert ret.result is False assert "value is required" in ret.comment assert not fake_github.mutations() -def test_actions_secret_present_missing_repo(github): - ret = github.actions_secret_present(name="NEW_SECRET", value="s3cr3t", repo_name="ghost") +def test_actions_secret_present_missing_repo(github_repo): + ret = github_repo.actions_secret_present(name="NEW_SECRET", value="s3cr3t", repo_name="ghost") assert ret.result is False assert "Failed to list Actions secrets" in ret.comment -def test_actions_secret_absent(github, fake_github, fake_org, testmode): +def test_actions_secret_absent(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.actions_secret_absent(name="existing_secret", test=testmode) + ret = github_repo.actions_secret_absent(name="existing_secret", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes assert (len(fake_github.mutations()) == before) is testmode assert ("EXISTING_SECRET" in fake_org.get_repo("repo-a").secrets) is testmode if not testmode: - rerun = github.actions_secret_absent(name="existing_secret") + rerun = github_repo.actions_secret_absent(name="existing_secret") assert rerun.result is True assert not rerun.changes -def test_actions_secret_absent_already(github, fake_github, testmode): - ret = github.actions_secret_absent(name="GHOST", test=testmode) +def test_actions_secret_absent_already(github_repo, fake_github, testmode): + ret = github_repo.actions_secret_absent(name="GHOST", test=testmode) assert ret.result is True assert not ret.changes assert not fake_github.mutations() diff --git a/tests/functional/states/github/test_deploy_key.py b/tests/functional/states/github_repo/test_deploy_key.py similarity index 62% rename from tests/functional/states/github/test_deploy_key.py rename to tests/functional/states/github_repo/test_deploy_key.py index 521eb41a..120577c8 100644 --- a/tests/functional/states/github/test_deploy_key.py +++ b/tests/functional/states/github_repo/test_deploy_key.py @@ -2,9 +2,9 @@ from tests.support.github import DEPLOY_KEY_B -def test_deploy_key_present_create(github, fake_github, fake_org, testmode): +def test_deploy_key_present_create(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.deploy_key_present(name="autorelease", key=DEPLOY_KEY_B, test=testmode) + ret = github_repo.deploy_key_present(name="autorelease", key=DEPLOY_KEY_B, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes["old"] is None @@ -16,8 +16,8 @@ def test_deploy_key_present_create(github, fake_github, fake_org, testmode): assert added[0].key == DEPLOY_KEY_B -def test_deploy_key_present_idempotent(github, fake_github, testmode): - ret = github.deploy_key_present( +def test_deploy_key_present_idempotent(github_repo, fake_github, testmode): + ret = github_repo.deploy_key_present( name="existing-key", key=DEPLOY_KEY_A, read_only=True, test=testmode ) assert ret.result is True @@ -25,17 +25,19 @@ def test_deploy_key_present_idempotent(github, fake_github, testmode): assert not fake_github.mutations() -def test_deploy_key_present_ignores_key_comment(github, fake_github, testmode): +def test_deploy_key_present_ignores_key_comment(github_repo, fake_github, testmode): key = " ".join(DEPLOY_KEY_A.split()[:2]) + " another-comment" - ret = github.deploy_key_present(name="existing-key", key=key, read_only=True, test=testmode) + ret = github_repo.deploy_key_present( + name="existing-key", key=key, read_only=True, test=testmode + ) assert ret.result is True assert not ret.changes assert not fake_github.mutations() -def test_deploy_key_present_replaces_changed_key(github, fake_github, fake_org, testmode): +def test_deploy_key_present_replaces_changed_key(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.deploy_key_present( + ret = github_repo.deploy_key_present( name="existing-key", key=DEPLOY_KEY_B, read_only=True, test=testmode ) assert ret.result is not False @@ -48,14 +50,18 @@ def test_deploy_key_present_replaces_changed_key(github, fake_github, fake_org, expected = DEPLOY_KEY_A if testmode else DEPLOY_KEY_B assert current.key == expected if not testmode: - rerun = github.deploy_key_present(name="existing-key", key=DEPLOY_KEY_B, read_only=True) + rerun = github_repo.deploy_key_present( + name="existing-key", key=DEPLOY_KEY_B, read_only=True + ) assert rerun.result is True assert not rerun.changes -def test_deploy_key_present_replaces_changed_read_only(github, fake_github, fake_org, testmode): +def test_deploy_key_present_replaces_changed_read_only( + github_repo, fake_github, fake_org, testmode +): before = len(fake_github.mutations()) - ret = github.deploy_key_present(name="existing-key", key=DEPLOY_KEY_A, test=testmode) + ret = github_repo.deploy_key_present(name="existing-key", key=DEPLOY_KEY_A, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes["new"]["read_only"] is False @@ -66,28 +72,28 @@ def test_deploy_key_present_replaces_changed_read_only(github, fake_github, fake assert current.read_only is testmode -def test_deploy_key_present_missing_repo(github): - ret = github.deploy_key_present(name="autorelease", key=DEPLOY_KEY_B, repo_name="ghost") +def test_deploy_key_present_missing_repo(github_repo): + ret = github_repo.deploy_key_present(name="autorelease", key=DEPLOY_KEY_B, repo_name="ghost") assert ret.result is False assert "Failed to list deploy keys" in ret.comment -def test_deploy_key_absent(github, fake_github, fake_org, testmode): +def test_deploy_key_absent(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.deploy_key_absent(name="existing-key", test=testmode) + ret = github_repo.deploy_key_absent(name="existing-key", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes assert (len(fake_github.mutations()) == before) is testmode assert bool(fake_org.get_repo("repo-a").deploy_keys) is testmode if not testmode: - rerun = github.deploy_key_absent(name="existing-key") + rerun = github_repo.deploy_key_absent(name="existing-key") assert rerun.result is True assert not rerun.changes -def test_deploy_key_absent_already(github, fake_github, testmode): - ret = github.deploy_key_absent(name="ghost", test=testmode) +def test_deploy_key_absent_already(github_repo, fake_github, testmode): + ret = github_repo.deploy_key_absent(name="ghost", test=testmode) assert ret.result is True assert not ret.changes assert not fake_github.mutations() diff --git a/tests/functional/states/github/test_environment.py b/tests/functional/states/github_repo/test_environment.py similarity index 67% rename from tests/functional/states/github/test_environment.py rename to tests/functional/states/github_repo/test_environment.py index 750ba71e..004a1d41 100644 --- a/tests/functional/states/github/test_environment.py +++ b/tests/functional/states/github_repo/test_environment.py @@ -2,9 +2,9 @@ PROTECTED_POLICY = {"protected_branches": True, "custom_branch_policies": False} -def test_environment_present_create(github, fake_github, fake_org, testmode): +def test_environment_present_create(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.environment_present(name="staging", test=testmode) + ret = github_repo.environment_present(name="staging", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert "environment" in ret.changes @@ -12,9 +12,9 @@ def test_environment_present_create(github, fake_github, fake_org, testmode): assert ("staging" in fake_org.get_repo("repo-a").environments) is not testmode -def test_environment_present_create_with_branches(github, fake_github, fake_org, testmode): +def test_environment_present_create_with_branches(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.environment_present( + ret = github_repo.environment_present( name="staging", deployment_branches=["main", "staging/*"], test=testmode ) assert ret.result is not False @@ -28,23 +28,25 @@ def test_environment_present_create_with_branches(github, fake_github, fake_org, env = fake_org.get_repo("repo-a").get_environment("staging") assert env.deployment_branch_policy == CUSTOM_POLICY assert {policy.name for policy in env.branch_policies} == {"main", "staging/*"} - rerun = github.environment_present( + rerun = github_repo.environment_present( name="staging", deployment_branches=["main", "staging/*"] ) assert rerun.result is True assert not rerun.changes -def test_environment_present_idempotent(github, fake_github, testmode): - ret = github.environment_present(name="release", deployment_branches=["main"], test=testmode) +def test_environment_present_idempotent(github_repo, fake_github, testmode): + ret = github_repo.environment_present( + name="release", deployment_branches=["main"], test=testmode + ) assert ret.result is True assert not ret.changes assert not fake_github.mutations() -def test_environment_present_branch_pattern_drift(github, fake_github, fake_org, testmode): +def test_environment_present_branch_pattern_drift(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.environment_present( + ret = github_repo.environment_present( name="release", deployment_branches=["main", "hotfix/*"], test=testmode ) assert ret.result is not False @@ -56,8 +58,10 @@ def test_environment_present_branch_pattern_drift(github, fake_github, fake_org, assert {policy.name for policy in env.branch_policies} == expected -def test_environment_present_removes_stale_patterns(github, fake_org, testmode): - ret = github.environment_present(name="release", deployment_branches=["develop"], test=testmode) +def test_environment_present_removes_stale_patterns(github_repo, fake_org, testmode): + ret = github_repo.environment_present( + name="release", deployment_branches=["develop"], test=testmode + ) assert ret.result is not False assert (ret.result is None) is testmode assert "deployment_branches" in ret.changes @@ -66,9 +70,11 @@ def test_environment_present_removes_stale_patterns(github, fake_org, testmode): assert {policy.name for policy in env.branch_policies} == expected -def test_environment_present_wait_timer_drift_keeps_policy(github, fake_github, fake_org, testmode): +def test_environment_present_wait_timer_drift_keeps_policy( + github_repo, fake_github, fake_org, testmode +): before = len(fake_github.mutations()) - ret = github.environment_present(name="release", wait_timer=30, test=testmode) + ret = github_repo.environment_present(name="release", wait_timer=30, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes == {"wait_timer": {"old": 0, "new": 30}} @@ -81,8 +87,10 @@ def test_environment_present_wait_timer_drift_keeps_policy(github, fake_github, assert {policy.name for policy in env.branch_policies} == {"main"} -def test_environment_present_protected_branches(github, fake_org, testmode): - ret = github.environment_present(name="release", deployment_branches="protected", test=testmode) +def test_environment_present_protected_branches(github_repo, fake_org, testmode): + ret = github_repo.environment_present( + name="release", deployment_branches="protected", test=testmode + ) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes["deployment_branch_policy"]["new"] == PROTECTED_POLICY @@ -91,8 +99,8 @@ def test_environment_present_protected_branches(github, fake_org, testmode): assert env.deployment_branch_policy == expected -def test_environment_present_all_branches(github, fake_org, testmode): - ret = github.environment_present(name="release", deployment_branches="all", test=testmode) +def test_environment_present_all_branches(github_repo, fake_org, testmode): + ret = github_repo.environment_present(name="release", deployment_branches="all", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes["deployment_branch_policy"]["new"] is None @@ -101,42 +109,42 @@ def test_environment_present_all_branches(github, fake_org, testmode): assert env.deployment_branch_policy == expected -def test_environment_present_reviewers(github, fake_org, testmode): +def test_environment_present_reviewers(github_repo, fake_org, testmode): reviewers = [{"type": "User", "id": 42}] - ret = github.environment_present(name="release", reviewers=reviewers, test=testmode) + ret = github_repo.environment_present(name="release", reviewers=reviewers, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes["reviewers"]["new"] == reviewers if not testmode: assert fake_org.get_repo("repo-a").get_environment("release").reviewers == reviewers - rerun = github.environment_present(name="release", reviewers=reviewers) + rerun = github_repo.environment_present(name="release", reviewers=reviewers) assert rerun.result is True assert not rerun.changes -def test_environment_present_invalid_branches(github, fake_github): - ret = github.environment_present(name="release", deployment_branches=42) +def test_environment_present_invalid_branches(github_repo, fake_github): + ret = github_repo.environment_present(name="release", deployment_branches=42) assert ret.result is False assert "deployment_branches" in ret.comment assert not fake_github.mutations() -def test_environment_absent(github, fake_github, fake_org, testmode): +def test_environment_absent(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.environment_absent(name="release", test=testmode) + ret = github_repo.environment_absent(name="release", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes assert (len(fake_github.mutations()) == before) is testmode assert ("release" in fake_org.get_repo("repo-a").environments) is testmode if not testmode: - rerun = github.environment_absent(name="release") + rerun = github_repo.environment_absent(name="release") assert rerun.result is True assert not rerun.changes -def test_environment_absent_already(github, fake_github, testmode): - ret = github.environment_absent(name="ghost", test=testmode) +def test_environment_absent_already(github_repo, fake_github, testmode): + ret = github_repo.environment_absent(name="ghost", test=testmode) assert ret.result is True assert not ret.changes assert not fake_github.mutations() diff --git a/tests/functional/states/github/test_label.py b/tests/functional/states/github_repo/test_label.py similarity index 69% rename from tests/functional/states/github/test_label.py rename to tests/functional/states/github_repo/test_label.py index 3e937f9f..0b1796c4 100644 --- a/tests/functional/states/github/test_label.py +++ b/tests/functional/states/github_repo/test_label.py @@ -1,6 +1,6 @@ -def test_label_present_create(github, fake_github, fake_org, testmode): +def test_label_present_create(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.label_present( + ret = github_repo.label_present( name="release", color="#00FF00", description="Release automation", test=testmode ) assert ret.result is not False @@ -14,16 +14,16 @@ def test_label_present_create(github, fake_github, fake_org, testmode): label = repo.get_label("release") assert label.color == "00ff00" assert label.description == "Release automation" - rerun = github.label_present( + rerun = github_repo.label_present( name="release", color="#00FF00", description="Release automation" ) assert rerun.result is True assert not rerun.changes -def test_label_present_idempotent(github, fake_github, testmode): +def test_label_present_idempotent(github_repo, fake_github, testmode): # Color comparison ignores case and a leading # - ret = github.label_present( + ret = github_repo.label_present( name="bug", color="#D73A4A", description="Something is broken", test=testmode ) assert ret.result is True @@ -31,16 +31,16 @@ def test_label_present_idempotent(github, fake_github, testmode): assert not fake_github.mutations() -def test_label_present_unmanaged_settings(github, fake_github, testmode): - ret = github.label_present(name="bug", test=testmode) +def test_label_present_unmanaged_settings(github_repo, fake_github, testmode): + ret = github_repo.label_present(name="bug", test=testmode) assert ret.result is True assert not ret.changes assert not fake_github.mutations() -def test_label_present_color_drift(github, fake_github, fake_org, testmode): +def test_label_present_color_drift(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.label_present(name="bug", color="ff0000", test=testmode) + ret = github_repo.label_present(name="bug", color="ff0000", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes == {"old": {"color": "d73a4a"}, "new": {"color": "ff0000"}} @@ -52,8 +52,8 @@ def test_label_present_color_drift(github, fake_github, fake_org, testmode): assert label.description == "Something is broken" -def test_label_present_description_drift(github, fake_org, testmode): - ret = github.label_present(name="bug", description="Defect", test=testmode) +def test_label_present_description_drift(github_repo, fake_org, testmode): + ret = github_repo.label_present(name="bug", description="Defect", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes["new"] == {"description": "Defect"} @@ -62,30 +62,30 @@ def test_label_present_description_drift(github, fake_org, testmode): assert label.description == expected -def test_label_present_missing_repo(github): +def test_label_present_missing_repo(github_repo): # A missing repo is indistinguishable from a missing label (404), # so the failure surfaces during the creation attempt - ret = github.label_present(name="bug", repo_name="ghost") + ret = github_repo.label_present(name="bug", repo_name="ghost") assert ret.result is False assert "Failed to create label" in ret.comment -def test_label_absent(github, fake_github, fake_org, testmode): +def test_label_absent(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.label_absent(name="bug", test=testmode) + ret = github_repo.label_absent(name="bug", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes assert (len(fake_github.mutations()) == before) is testmode assert ("bug" in fake_org.get_repo("repo-a").labels) is testmode if not testmode: - rerun = github.label_absent(name="bug") + rerun = github_repo.label_absent(name="bug") assert rerun.result is True assert not rerun.changes -def test_label_absent_already(github, fake_github, testmode): - ret = github.label_absent(name="ghost", test=testmode) +def test_label_absent_already(github_repo, fake_github, testmode): + ret = github_repo.label_absent(name="ghost", test=testmode) assert ret.result is True assert not ret.changes assert not fake_github.mutations() diff --git a/tests/functional/states/github/test_pages.py b/tests/functional/states/github_repo/test_pages.py similarity index 64% rename from tests/functional/states/github/test_pages.py rename to tests/functional/states/github_repo/test_pages.py index 2345d5c6..f6c2859c 100644 --- a/tests/functional/states/github/test_pages.py +++ b/tests/functional/states/github_repo/test_pages.py @@ -1,6 +1,6 @@ -def test_pages_present_enable(github, fake_github, fake_org, testmode): +def test_pages_present_enable(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.pages_present(name="repo-a", build_type="workflow", test=testmode) + ret = github_repo.pages_present(name="repo-a", build_type="workflow", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes["old"] is None @@ -10,13 +10,13 @@ def test_pages_present_enable(github, fake_github, fake_org, testmode): assert pages is None else: assert pages.build_type == "workflow" - rerun = github.pages_present(name="repo-a", build_type="workflow") + rerun = github_repo.pages_present(name="repo-a", build_type="workflow") assert rerun.result is True assert not rerun.changes -def test_pages_present_enable_with_https(github, fake_org, testmode): - ret = github.pages_present( +def test_pages_present_enable_with_https(github_repo, fake_org, testmode): + ret = github_repo.pages_present( name="repo-a", build_type="workflow", https_enforced=True, test=testmode ) assert ret.result is not False @@ -24,8 +24,8 @@ def test_pages_present_enable_with_https(github, fake_org, testmode): assert fake_org.get_repo("repo-a").pages.https_enforced is True -def test_pages_present_idempotent(github, fake_github, testmode): - ret = github.pages_present( +def test_pages_present_idempotent(github_repo, fake_github, testmode): + ret = github_repo.pages_present( name="repo-b", build_type="legacy", source_branch="gh-pages", source_path="/", test=testmode ) assert ret.result is True @@ -33,9 +33,9 @@ def test_pages_present_idempotent(github, fake_github, testmode): assert not fake_github.mutations() -def test_pages_present_build_type_drift(github, fake_github, fake_org, testmode): +def test_pages_present_build_type_drift(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.pages_present(name="repo-b", build_type="workflow", test=testmode) + ret = github_repo.pages_present(name="repo-b", build_type="workflow", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes == { @@ -47,8 +47,8 @@ def test_pages_present_build_type_drift(github, fake_github, fake_org, testmode) assert fake_org.get_repo("repo-b").pages.build_type == expected -def test_pages_present_source_drift(github, fake_org, testmode): - ret = github.pages_present(name="repo-b", source_branch="docs", test=testmode) +def test_pages_present_source_drift(github_repo, fake_org, testmode): + ret = github_repo.pages_present(name="repo-b", source_branch="docs", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes["new"] == {"source_branch": "docs"} @@ -56,30 +56,30 @@ def test_pages_present_source_drift(github, fake_org, testmode): assert fake_org.get_repo("repo-b").pages.source["branch"] == expected -def test_pages_present_missing_repo(github): +def test_pages_present_missing_repo(github_repo): # A missing repo is indistinguishable from a missing Pages site (404), # so the failure surfaces during the creation attempt - ret = github.pages_present(name="ghost") + ret = github_repo.pages_present(name="ghost") assert ret.result is False assert "Failed to enable Pages" in ret.comment -def test_pages_absent(github, fake_github, fake_org, testmode): +def test_pages_absent(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.pages_absent(name="repo-b", test=testmode) + ret = github_repo.pages_absent(name="repo-b", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert ret.changes assert (len(fake_github.mutations()) == before) is testmode assert (fake_org.get_repo("repo-b").pages is not None) is testmode if not testmode: - rerun = github.pages_absent(name="repo-b") + rerun = github_repo.pages_absent(name="repo-b") assert rerun.result is True assert not rerun.changes -def test_pages_absent_already(github, fake_github, testmode): - ret = github.pages_absent(name="repo-a", test=testmode) +def test_pages_absent_already(github_repo, fake_github, testmode): + ret = github_repo.pages_absent(name="repo-a", test=testmode) assert ret.result is True assert not ret.changes assert not fake_github.mutations() diff --git a/tests/functional/states/github_repo/test_repo.py b/tests/functional/states/github_repo/test_repo.py new file mode 100644 index 00000000..4579281f --- /dev/null +++ b/tests/functional/states/github_repo/test_repo.py @@ -0,0 +1,155 @@ +import pytest + +from tests.support.github import NotFound + + +@pytest.fixture +def _instant_sleep(monkeypatch): + # present waits for repo creation to settle; pointless against the fake + monkeypatch.setattr("time.sleep", lambda _seconds: None) + + +def test_present_create(github_repo, fake_github, fake_org, _instant_sleep, testmode): + before = len(fake_github.mutations()) + ret = github_repo.present(name="new-repo", description="Fancy", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "repo" in ret.changes + assert (len(fake_github.mutations()) == before) is testmode + if testmode: + with pytest.raises(NotFound): + fake_org.get_repo("new-repo") + else: + assert fake_org.get_repo("new-repo").description == "Fancy" + + +def test_present_idempotent(github_repo, fake_github, testmode): + ret = github_repo.present(name="repo-a", description="Test repo repo-a", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_present_converges_drift(github_repo, fake_org, testmode): + ret = github_repo.present(name="repo-a", description="Managed description", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "repo" in ret.changes + expected = "Test repo repo-a" if testmode else "Managed description" + assert fake_org.get_repo("repo-a").description == expected + if not testmode: + rerun = github_repo.present(name="repo-a", description="Managed description") + assert rerun.result is True + assert not rerun.changes + + +def test_present_default_branch(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.present(name="repo-a", default_branch="develop", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "repo" in ret.changes + assert (len(fake_github.mutations()) == before) is testmode + expected = "main" if testmode else "develop" + assert fake_org.get_repo("repo-a").default_branch == expected + + +def test_present_default_branch_idempotent(github_repo, fake_github, testmode): + ret = github_repo.present(name="repo-a", default_branch="main", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_present_create_ignores_default_branch(github_repo, fake_org, _instant_sleep, testmode): + # The default branch can only be switched to an existing branch, + # so it is not applied on repository creation + ret = github_repo.present(name="new-repo", default_branch="develop", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "repo" in ret.changes + if not testmode: + assert fake_org.get_repo("new-repo").default_branch == "main" + + +def test_present_create_with_teams(github_repo, fake_github, fake_org, _instant_sleep, testmode): + before = len(fake_github.mutations()) + ret = github_repo.present(name="new-repo", teams={"Team A": "push"}, test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "repo" in ret.changes + assert "Team A" in ret.changes + assert (len(fake_github.mutations()) == before) is testmode + if testmode: + assert "new-repo" not in fake_org.repos + else: + assert fake_org.get_team("team-a").repos["new-repo"] == "push" + + +def test_present_adds_repo_to_team(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.present(name="repo-b", teams={"Team A": "push"}, test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "Team A" in ret.changes + assert (len(fake_github.mutations()) == before) is testmode + team = fake_org.get_team("team-a") + if testmode: + assert "repo-b" not in team.repos + else: + assert team.repos["repo-b"] == "push" + + +def test_present_team_permission_change(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.present(name="repo-a", teams={"Team A": "push"}, test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "Team A" in ret.changes + assert (len(fake_github.mutations()) == before) is testmode + expected = "pull" if testmode else "push" + assert fake_org.get_team("team-a").repos["repo-a"] == expected + + +def test_present_team_permission_unchanged(github_repo, fake_github, fake_org, testmode): + ret = github_repo.present(name="repo-a", teams={"Team A": "pull"}, test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + assert fake_org.get_team("team-a").repos["repo-a"] == "pull" + + +def test_present_team_removal(github_repo, fake_github, fake_org, testmode): + # An empty teams dict removes the repo from unlisted teams + before = len(fake_github.mutations()) + ret = github_repo.present(name="repo-a", teams={}, test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert "Team A" in ret.changes + assert (len(fake_github.mutations()) == before) is testmode + assert ("repo-a" in fake_org.get_team("team-a").repos) is testmode + + +def test_present_missing_team(github_repo): + ret = github_repo.present(name="repo-b", teams={"Ghost": "pull"}) + assert ret.result is False + assert "Failed" in ret.comment + + +def test_absent(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.absent(name="repo-a", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert ret.changes == {"old": "Repo repo-a exists", "new": "Repo repo-a deleted"} + if testmode: + assert "would have been deleted" in ret.comment + fake_org.get_repo("repo-a") + else: + with pytest.raises(NotFound): + fake_org.get_repo("repo-a") + + rerun = github_repo.absent(name="repo-a") + assert rerun.result is True + assert not rerun.changes diff --git a/tests/functional/states/github_repo/test_ruleset.py b/tests/functional/states/github_repo/test_ruleset.py new file mode 100644 index 00000000..bb8c1b01 --- /dev/null +++ b/tests/functional/states/github_repo/test_ruleset.py @@ -0,0 +1,102 @@ +def test_ruleset_present_idempotent(github_repo, fake_github, testmode): + ret = github_repo.ruleset_present( + name="repo-rules", + ruleset_params={"target": "branch", "enforcement": "disabled"}, + test=testmode, + ) + assert ret.result is True + assert not ret.changes + assert "already configured" in ret.comment + assert not fake_github.mutations() + + +def test_ruleset_present_ignores_server_populated_fields(github_repo, testmode): + # id/created_at/source etc. in the server response must not register + # as drift when only managed params are compared + ret = github_repo.ruleset_present( + name="repo-rules", ruleset_params={"enforcement": "disabled"}, test=testmode + ) + assert ret.result is True + assert not ret.changes + + +def test_ruleset_present_converges_drift(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.ruleset_present( + name="repo-rules", + ruleset_params={"target": "branch", "enforcement": "active"}, + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["old"] == {"enforcement": "disabled"} + assert ret.changes["new"] == {"enforcement": "active"} + assert (len(fake_github.mutations()) == before) is testmode + expected = "disabled" if testmode else "active" + assert fake_org.get_repo("repo-a").rulesets["repo-rules"].enforcement == expected + if not testmode: + rerun = github_repo.ruleset_present( + name="repo-rules", ruleset_params={"target": "branch", "enforcement": "active"} + ) + assert rerun.result is True + assert not rerun.changes + + +def test_ruleset_present_create(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.ruleset_present( + name="new-rules", + repo_name="repo-b", + ruleset_params={"target": "branch", "enforcement": "active"}, + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["old"] is None + assert (len(fake_github.mutations()) == before) is testmode + assert ("new-rules" in fake_org.get_repo("repo-b").rulesets) is not testmode + + +def test_ruleset_present_list_error(github_repo): + ret = github_repo.ruleset_present( + name="x", repo_name="ghost", ruleset_params={"enforcement": "active"} + ) + assert ret.result is False + assert "Failed to list rulesets" in ret.comment + + +def test_ruleset_present_update_failure(github_repo): + # Parameters unknown to the API are rejected with a validation error + ret = github_repo.ruleset_present( + name="repo-rules", ruleset_params={"enforcement": "active", "bogus_param": True} + ) + assert ret.result is False + assert "Failed to update ruleset" in ret.comment + assert not ret.changes + + +def test_ruleset_absent(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.ruleset_absent(name="repo-rules", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert (len(fake_github.mutations()) == before) is testmode + assert bool(list(fake_org.get_repo("repo-a").rulesets)) is testmode + assert ret.changes == {"old": "Ruleset repo-rules exists", "new": "Ruleset repo-rules deleted"} + if not testmode: + rerun = github_repo.ruleset_absent(name="repo-rules") + assert rerun.result is True + assert not rerun.changes + + +def test_ruleset_absent_missing(github_repo, fake_github, testmode): + ret = github_repo.ruleset_absent(name="ghost-rules", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + +def test_ruleset_absent_list_error(github_repo): + ret = github_repo.ruleset_absent(name="x", repo_name="ghost") + assert ret.result is False + assert "Failed to list rulesets" in ret.comment diff --git a/tests/functional/states/github/test_workflow_permissions.py b/tests/functional/states/github_repo/test_workflow_permissions.py similarity index 69% rename from tests/functional/states/github/test_workflow_permissions.py rename to tests/functional/states/github_repo/test_workflow_permissions.py index 0b7f0754..6f624c8e 100644 --- a/tests/functional/states/github/test_workflow_permissions.py +++ b/tests/functional/states/github_repo/test_workflow_permissions.py @@ -1,6 +1,6 @@ -def test_workflow_permissions_managed(github, fake_github, fake_org, testmode): +def test_workflow_permissions_managed(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github.workflow_permissions_managed( + ret = github_repo.workflow_permissions_managed( name="repo-a", default_workflow_permissions="read", can_approve_pull_request_reviews=True, @@ -20,7 +20,7 @@ def test_workflow_permissions_managed(github, fake_github, fake_org, testmode): else: assert repo.data.default_workflow_permissions == "read" assert repo.data.can_approve_pull_request_reviews is True - rerun = github.workflow_permissions_managed( + rerun = github_repo.workflow_permissions_managed( name="repo-a", default_workflow_permissions="read", can_approve_pull_request_reviews=True, @@ -29,8 +29,10 @@ def test_workflow_permissions_managed(github, fake_github, fake_org, testmode): assert not rerun.changes -def test_workflow_permissions_managed_unspecified_setting_untouched(github, fake_org, testmode): - ret = github.workflow_permissions_managed( +def test_workflow_permissions_managed_unspecified_setting_untouched( + github_repo, fake_org, testmode +): + ret = github_repo.workflow_permissions_managed( name="repo-a", default_workflow_permissions="read", test=testmode ) assert ret.result is not False @@ -39,8 +41,8 @@ def test_workflow_permissions_managed_unspecified_setting_untouched(github, fake assert repo.data.can_approve_pull_request_reviews is False -def test_workflow_permissions_managed_idempotent(github, fake_github, testmode): - ret = github.workflow_permissions_managed( +def test_workflow_permissions_managed_idempotent(github_repo, fake_github, testmode): + ret = github_repo.workflow_permissions_managed( name="repo-a", can_approve_pull_request_reviews=False, test=testmode ) assert ret.result is True @@ -48,8 +50,8 @@ def test_workflow_permissions_managed_idempotent(github, fake_github, testmode): assert not fake_github.mutations() -def test_workflow_permissions_managed_repo_name_override(github, fake_org, testmode): - ret = github.workflow_permissions_managed( +def test_workflow_permissions_managed_repo_name_override(github_repo, fake_org, testmode): + ret = github_repo.workflow_permissions_managed( name="restrict Actions", repo_name="repo-b", default_workflow_permissions="read", @@ -60,13 +62,15 @@ def test_workflow_permissions_managed_repo_name_override(github, fake_org, testm assert fake_org.get_repo("repo-b").data.default_workflow_permissions == expected -def test_workflow_permissions_managed_no_settings(github): - ret = github.workflow_permissions_managed(name="repo-a") +def test_workflow_permissions_managed_no_settings(github_repo): + ret = github_repo.workflow_permissions_managed(name="repo-a") assert ret.result is False assert "At least one setting" in ret.comment -def test_workflow_permissions_managed_missing_repo(github): - ret = github.workflow_permissions_managed(name="ghost", default_workflow_permissions="read") +def test_workflow_permissions_managed_missing_repo(github_repo): + ret = github_repo.workflow_permissions_managed( + name="ghost", default_workflow_permissions="read" + ) assert ret.result is False assert "Failed to get workflow permissions" in ret.comment From aeb6a2555f139a3c4a26d5658a3eb8ac4250ab6b Mon Sep 17 00:00:00 2001 From: jeanluc Date: Mon, 12 Oct 2026 20:56:33 +0200 Subject: [PATCH 3/6] Modernize interface Use the migration to new module names to get rid of more legacy cruft. --- changelog/+get_user_pubkeys.removed.md | 1 + changelog/+legacy_modules.deprecated.md | 2 +- changelog/+scoped_modules.added.md | 2 +- changelog/+structured_changes.added.md | 1 + changelog/+user_owned_repos.added.md | 1 + docs/ref/utils/index.rst | 2 +- .../utils/saltext.github.utils.gh.members.rst | 5 + .../utils/saltext.github.utils.gh.users.rst | 5 - pyproject.toml | 2 + src/saltext/github/modules/github.py | 121 +- src/saltext/github/modules/github_org.py | 585 ++++--- src/saltext/github/modules/github_repo.py | 1143 ++++++------- src/saltext/github/states/github_org.py | 663 +++----- src/saltext/github/states/github_repo.py | 1442 +++++++---------- src/saltext/github/utils/__init__.py | 32 + src/saltext/github/utils/gh/__init__.py | 61 - src/saltext/github/utils/gh/actions.py | 44 +- src/saltext/github/utils/gh/client.py | 12 +- src/saltext/github/utils/gh/deploy_keys.py | 24 +- src/saltext/github/utils/gh/environments.py | 74 +- src/saltext/github/utils/gh/exceptions.py | 4 +- src/saltext/github/utils/gh/issues.py | 57 +- src/saltext/github/utils/gh/labels.py | 40 +- .../github/utils/gh/{users.py => members.py} | 31 +- src/saltext/github/utils/gh/milestones.py | 27 +- src/saltext/github/utils/gh/pages.py | 28 +- src/saltext/github/utils/gh/profile.py | 25 +- src/saltext/github/utils/gh/pulls.py | 32 +- src/saltext/github/utils/gh/repos.py | 112 +- src/saltext/github/utils/gh/rulesets.py | 35 +- src/saltext/github/utils/gh/teams.py | 107 +- tests/functional/modules/github/test_teams.py | 6 - .../modules/github_org/test_members.py | 8 - .../modules/github_org/test_repos.py | 29 +- .../modules/github_org/test_teams.py | 55 +- .../github_repo/test_actions_secrets.py | 4 +- .../modules/github_repo/test_environments.py | 14 +- .../modules/github_repo/test_issues.py | 39 +- .../modules/github_repo/test_labels.py | 7 +- .../modules/github_repo/test_milestones.py | 6 - .../modules/github_repo/test_pages.py | 10 + .../modules/github_repo/test_pulls.py | 8 +- .../modules/github_repo/test_repo.py | 3 +- .../states/github_org/test_member.py | 4 +- .../states/github_org/test_ruleset.py | 11 +- .../functional/states/github_org/test_team.py | 171 +- .../states/github_repo/test_actions_secret.py | 8 +- .../states/github_repo/test_deploy_key.py | 6 +- .../states/github_repo/test_environment.py | 20 +- .../states/github_repo/test_label.py | 6 +- .../states/github_repo/test_pages.py | 17 +- .../states/github_repo/test_repo.py | 40 +- .../states/github_repo/test_ruleset.py | 10 +- .../github_repo/test_workflow_permissions.py | 2 +- tests/support/github/entities.py | 1 + tests/support/github/state.py | 226 +-- 56 files changed, 2420 insertions(+), 3011 deletions(-) create mode 100644 changelog/+get_user_pubkeys.removed.md create mode 100644 changelog/+structured_changes.added.md create mode 100644 changelog/+user_owned_repos.added.md create mode 100644 docs/ref/utils/saltext.github.utils.gh.members.rst delete mode 100644 docs/ref/utils/saltext.github.utils.gh.users.rst rename src/saltext/github/utils/gh/{users.py => members.py} (76%) diff --git a/changelog/+get_user_pubkeys.removed.md b/changelog/+get_user_pubkeys.removed.md new file mode 100644 index 00000000..ad723624 --- /dev/null +++ b/changelog/+get_user_pubkeys.removed.md @@ -0,0 +1 @@ +Removed the unauthenticated ``get_user_pubkeys`` helper from ``saltext.github.utils.gh``. It ignored the configured ``api_url``, relied on ``salt.utils.http`` and was not exposed in any module diff --git a/changelog/+legacy_modules.deprecated.md b/changelog/+legacy_modules.deprecated.md index 4b6445e3..e7d2915a 100644 --- a/changelog/+legacy_modules.deprecated.md +++ b/changelog/+legacy_modules.deprecated.md @@ -1 +1 @@ -Deprecated managing organization- and repository-scoped resources via the ``github`` execution and state modules. Use the corresponding functions of the new ``github_org`` and ``github_repo`` modules instead +Deprecated all current functions of the ``github`` execution module as well as the ``github`` state module. Use the corresponding functions of the new ``github_org`` and ``github_repo`` modules instead. The ``github`` execution module remains the namespace for functionality that is not tied to a single organization. Notably, ``github.get_user`` looks up organization membership despite its name - its replacement is ``github_org.get_member``. The ``list_members_without_mfa`` function and the per-team MFA enforcement of ``team_present`` are deprecated without replacement - require 2FA organization-wide in the GitHub settings instead diff --git a/changelog/+scoped_modules.added.md b/changelog/+scoped_modules.added.md index 135631d5..df8066f2 100644 --- a/changelog/+scoped_modules.added.md +++ b/changelog/+scoped_modules.added.md @@ -1 +1 @@ -Added scoped ``github_org`` and ``github_repo`` execution and state modules, which replace the organization- and repository-related functionality of the monolithic ``github`` modules. Unlike the legacy functions, they raise errors instead of logging them and returning ``False`` +Added scoped ``github_org`` and ``github_repo`` execution and state modules, which replace the organization- and repository-related functionality of the monolithic ``github`` modules. Unlike the legacy functions, they raise errors instead of logging them and returning ``False``. Collections with a unique natural key (repos, teams, team repos, labels, environments, Actions secrets) are returned as name-keyed mappings, id-addressed ones (issues, PRs, milestones, rulesets, deploy keys) as lists. All collection outputs can be reduced to specific fields via the ``fields`` argument. The new ``github_org.team_present`` state manages members as a simple list and drops the legacy per-team MFA enforcement options - require 2FA org-wide in the GitHub organization settings instead diff --git a/changelog/+structured_changes.added.md b/changelog/+structured_changes.added.md new file mode 100644 index 00000000..c44831e3 --- /dev/null +++ b/changelog/+structured_changes.added.md @@ -0,0 +1 @@ +The new ``github_org`` and ``github_repo`` states report machine-readable ``changes`` dictionaries (``created``/``deleted``/``updated`` entity names, ``old``/``new`` setting mappings and ``*_added``/``*_removed`` name lists) instead of prose diff --git a/changelog/+user_owned_repos.added.md b/changelog/+user_owned_repos.added.md new file mode 100644 index 00000000..462cfbd9 --- /dev/null +++ b/changelog/+user_owned_repos.added.md @@ -0,0 +1 @@ +The ``github_repo`` execution module and its states can manage user-owned repositories by pointing the profile's ``org_name`` at the user login (except repository creation and team management, which are organization-only) diff --git a/docs/ref/utils/index.rst b/docs/ref/utils/index.rst index 3ee36aa8..198d2c55 100644 --- a/docs/ref/utils/index.rst +++ b/docs/ref/utils/index.rst @@ -18,6 +18,7 @@ _________ utils.gh.exceptions utils.gh.issues utils.gh.labels + utils.gh.members utils.gh.milestones utils.gh.pages utils.gh.profile @@ -25,4 +26,3 @@ _________ utils.gh.repos utils.gh.rulesets utils.gh.teams - utils.gh.users diff --git a/docs/ref/utils/saltext.github.utils.gh.members.rst b/docs/ref/utils/saltext.github.utils.gh.members.rst new file mode 100644 index 00000000..6b49f503 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.members.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.members +=============================== + +.. automodule:: saltext.github.utils.gh.members + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.users.rst b/docs/ref/utils/saltext.github.utils.gh.users.rst deleted file mode 100644 index 6789a159..00000000 --- a/docs/ref/utils/saltext.github.utils.gh.users.rst +++ /dev/null @@ -1,5 +0,0 @@ -saltext.github.utils.gh.users -============================= - -.. automodule:: saltext.github.utils.gh.users - :members: diff --git a/pyproject.toml b/pyproject.toml index 8e366c7a..74243b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ dynamic = ["version"] dependencies = [ "salt>=3006", "PyGithub>=2.2", + # Used directly for Actions secret encryption (also a PyGithub dependency) + "pynacl>=1.4", ] [project.readme] diff --git a/src/saltext/github/modules/github.py b/src/saltext/github/modules/github.py index 6560eb7d..a8b27807 100644 --- a/src/saltext/github/modules/github.py +++ b/src/saltext/github/modules/github.py @@ -3,14 +3,15 @@ :depends: PyGithub python module -.. deprecated:: 1.1.0 +.. note:: - Managing organization- and repository-scoped resources via this module - is deprecated. Use the :py:mod:`github_org + All organization- and repository-related functions in this module are + deprecated in favor of the :py:mod:`github_org ` and :py:mod:`github_repo - ` modules instead, which raise - errors instead of logging and returning ``False``. This module retains - functionality that is not tied to a single organization. + ` modules, which raise errors + instead of logging and returning ``False``. This module remains the + namespace for functionality that is not tied to a single organization, + e.g. looking up arbitrary users. Configuration ------------- @@ -71,13 +72,13 @@ try: from saltext.github.utils.gh import issues as gh_issues + from saltext.github.utils.gh import members as gh_members from saltext.github.utils.gh import milestones as gh_milestones from saltext.github.utils.gh import profile as gh_profile from saltext.github.utils.gh import pulls as gh_pulls from saltext.github.utils.gh import repos as gh_repos from saltext.github.utils.gh import rulesets as gh_rulesets from saltext.github.utils.gh import teams as gh_teams - from saltext.github.utils.gh import users as gh_users from saltext.github.utils.gh.exceptions import GitHubApiError from saltext.github.utils.gh.exceptions import GitHubNotFoundError @@ -99,20 +100,6 @@ def __virtual__(): ) -def _get_config_value(profile, config_name): - """ - Helper function that returns a profile's configuration value based on - the supplied configuration name. - - profile - The profile name that contains configuration information. - - config_name - The configuration item's name to use to return configuration values. - """ - return gh_profile.get_config_value(profile, config_name, salt=__salt__, context=__context__) - - def _get_conn(profile): """ Return the GitHub connection, cached into __context__ for performance @@ -120,33 +107,16 @@ def _get_conn(profile): return gh_profile.get_conn(profile, salt=__salt__, context=__context__) -def list_users(profile="github", ignore_cache=False): - """ - List all users within the organization. - - .. deprecated:: 1.1.0 - Use :py:func:`github_org.list_members ` instead. - - profile - The name of the profile configuration to use. Defaults to ``github``. - - ignore_cache - Bypasses the use of cached users. - - CLI Example: - - .. code-block:: bash - - salt myminion github.list_users - salt myminion github.list_users profile='my-github-profile' - """ - return gh_users.list_members(_get_conn(profile), ignore_cache=ignore_cache) - - def get_user(name, profile="github", user_details=False): """ Get a GitHub user by name. + .. deprecated:: 1.1.0 + Despite its name, this function looks up the user's membership in + the profile's organization, not a generic user account. Use + :py:func:`github_org.get_member ` + instead. + name The user for which to obtain information. @@ -172,12 +142,53 @@ def get_user(name, profile="github", user_details=False): return True try: - return gh_users.get_user_info(_get_conn(profile), name) + return gh_members.get_info(_get_conn(profile), name) except GitHubNotFoundError: log.error("Resource not found", exc_info_on_loglevel=logging.DEBUG) return False +# Anything below here is deprecated. Replacement functions are in github_org/github_repo. +# This module is intended for non-org/non-repo functionality now. + + +def _get_config_value(profile, config_name): + """ + Helper function that returns a profile's configuration value based on + the supplied configuration name. + + profile + The profile name that contains configuration information. + + config_name + The configuration item's name to use to return configuration values. + """ + return gh_profile.get_config_value(profile, config_name, salt=__salt__, context=__context__) + + +def list_users(profile="github", ignore_cache=False): + """ + List all users within the organization. + + .. deprecated:: 1.1.0 + Use :py:func:`github_org.list_members ` instead. + + profile + The name of the profile configuration to use. Defaults to ``github``. + + ignore_cache + Bypasses the use of cached users. + + CLI Example: + + .. code-block:: bash + + salt myminion github.list_users + salt myminion github.list_users profile='my-github-profile' + """ + return gh_members.list_all(_get_conn(profile), ignore_cache=ignore_cache) + + def add_user(name, profile="github"): """ Add a GitHub user. @@ -199,7 +210,7 @@ def add_user(name, profile="github"): """ try: - return gh_users.invite_member(_get_conn(profile), name) + return gh_members.invite(_get_conn(profile), name) except GitHubNotFoundError: log.error("Resource not found", exc_info_on_loglevel=logging.DEBUG) return False @@ -226,7 +237,7 @@ def remove_user(name, profile="github"): """ try: - return gh_users.remove_member(_get_conn(profile), name) + return gh_members.remove(_get_conn(profile), name) except GitHubNotFoundError: log.error("Resource not found", exc_info_on_loglevel=logging.DEBUG) return False @@ -608,7 +619,15 @@ def get_repo_info(repo_name, profile="github", ignore_cache=False): salt myminion github.get_repo_info salt salt myminion github.get_repo_info salt profile='my-github-profile' """ - return gh_repos.get_info(_get_conn(profile), repo_name, ignore_cache=ignore_cache) + info = gh_repos.get_info(_get_conn(profile), repo_name, ignore_cache=ignore_cache) + # The legacy output contained GitHub's deprecated alias fields + return { + **info, + "watchers_count": info["stargazers_count"], + "watchers": info["stargazers_count"], + "forks": info["forks_count"], + "open_issues": info["open_issues_count"], + } def get_repo_teams(repo_name, profile="github"): @@ -1195,7 +1214,9 @@ def list_members_without_mfa(profile="github", ignore_cache=False): List all members (in lower case) without MFA turned on. .. deprecated:: 1.1.0 - Use :py:func:`github_org.list_members_without_mfa ` instead. + Deprecated without replacement, along with the per-team MFA + enforcement of the ``github.team_present`` state. Require 2FA + organization-wide in the GitHub settings instead. profile The name of the profile configuration to use. Defaults to ``github``. @@ -1209,7 +1230,7 @@ def list_members_without_mfa(profile="github", ignore_cache=False): salt myminion github.list_members_without_mfa """ - return gh_users.list_members_without_mfa(_get_conn(profile), ignore_cache=ignore_cache) + return gh_members.list_without_mfa(_get_conn(profile), ignore_cache=ignore_cache) def is_team_member(name, team_name, profile="github"): diff --git a/src/saltext/github/modules/github_org.py b/src/saltext/github/modules/github_org.py index 3eea7fb0..da230a79 100644 --- a/src/saltext/github/modules/github_org.py +++ b/src/saltext/github/modules/github_org.py @@ -8,6 +8,9 @@ the functions in this module raise errors (usually subclasses of ``CommandExecutionError``) instead of logging them and returning ``False``. +Collections with a unique natural key (repositories, teams) are returned +as mappings keyed by it, id-addressed collections (rulesets) as lists. + :depends: PyGithub python module Configuration @@ -22,13 +25,11 @@ token: abc1234 org_name: my_organization - # optional: it can be dangerous to change the privacy of a repository - # in an automated way. set this to True to allow privacy modifications + # Optional: Allow automated repository privacy modifications. allow_repo_privacy_changes: False - # optional: the base URL of the GitHub API. Set this when targeting - # a GitHub Enterprise Server instance, e.g. - # https://github.example.com/api/v3 + # Optional: Base URL of the GitHub API. Set this when targeting + # a GitHub Enterprise Server instance, e.g. https://github.example.com/api/v3 api_url: https://api.github.com """ @@ -36,32 +37,19 @@ from salt.exceptions import CommandExecutionError -try: - from saltext.github.utils.gh import profile as gh_profile - from saltext.github.utils.gh import repos as gh_repos - from saltext.github.utils.gh import rulesets as gh_rulesets - from saltext.github.utils.gh import teams as gh_teams - from saltext.github.utils.gh import users as gh_users - from saltext.github.utils.gh.exceptions import GitHubNotFoundError - - HAS_LIBS = True -except ImportError: # pragma: no cover - HAS_LIBS = False +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import members as gh_members +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh import repos as gh_repos +from saltext.github.utils.gh import rulesets as gh_rulesets +from saltext.github.utils.gh import teams as gh_teams +from saltext.github.utils.gh.exceptions import GitHubNotFoundError log = logging.getLogger(__name__) __virtualname__ = "github_org" -def __virtual__(): - if HAS_LIBS: - return __virtualname__ - return ( # pragma: no cover - False, - "The github_org execution module cannot be loaded: PyGithub library is not installed.", - ) - - def _get_conn(profile): return gh_profile.get_conn(profile, salt=__salt__, context=__context__) @@ -69,50 +57,36 @@ def _get_conn(profile): def _get_team(conn, name): """ Return a team's cached details, raising when it does not exist. + Teams are matched by exact display name first, then by slug and + case-insensitive display name since GitHub derives team identity + (the slug) case-insensitively. """ team = gh_teams.get(conn, name) if team is None: + for candidate in gh_teams.list_all(conn).values(): + if name == candidate["slug"] or name.lower() == candidate["name"].lower(): + return candidate raise GitHubNotFoundError(f"Team {name} does not exist in organization {conn.org_name}.") return team -def list_members(profile="github", ignore_cache=False): +def list_members(ignore_cache=False, profile="github"): """ List the logins of all members of the organization. - profile - The name of the profile configuration to use. Defaults to ``github``. - - ignore_cache - Bypasses the use of cached members. - CLI Example: .. code-block:: bash - salt myminion github_org.list_members - """ - return gh_users.list_members(_get_conn(profile), ignore_cache=ignore_cache) - - -def list_members_without_mfa(profile="github", ignore_cache=False): - """ - List the logins (lowercase) of all organization members without - MFA turned on. - - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_org.list_members ignore_cache - Bypasses the use of cached members. + Bypass the use of cached members. - CLI Example: - - .. code-block:: bash - - salt myminion github_org.list_members_without_mfa + profile + Profile configuration name to use. Defaults to ``github``. """ - return gh_users.list_members_without_mfa(_get_conn(profile), ignore_cache=ignore_cache) + return gh_members.list_all(_get_conn(profile), ignore_cache=ignore_cache) def get_member(name, profile="github"): @@ -120,19 +94,19 @@ def get_member(name, profile="github"): Return information about a user, including their organization membership state (``in_org`` and ``membership_state``). - name - The login of the user. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_org.get_member github-handle + salt-call github_org.get_member github-handle + + name + Login of the user. + + profile + Profile configuration name to use. Defaults to ``github``. """ - return gh_users.get_user_info(_get_conn(profile), name) + return gh_members.get_info(_get_conn(profile), name) def add_member(name, profile="github"): @@ -141,66 +115,75 @@ def add_member(name, profile="github"): membership is ``pending`` (i.e. an invitation that needs to be accepted first). - name - The login of the user to invite. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_org.add_member github-handle + salt-call github_org.add_member github-handle + + name + Login of the user to invite. + + profile + Profile configuration name to use. Defaults to ``github``. """ - return gh_users.invite_member(_get_conn(profile), name) + return gh_members.invite(_get_conn(profile), name) def remove_member(name, profile="github"): """ Remove a user from the organization. - name - The login of the user to remove. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_org.remove_member github-handle + salt-call github_org.remove_member github-handle + + name + Login of the user to remove. + + profile + Profile configuration name to use. Defaults to ``github``. """ - if not gh_users.remove_member(_get_conn(profile), name): - raise CommandExecutionError(f"Failed to remove member {name} from the organization.") + gh_members.remove(_get_conn(profile), name) return True -def list_repos(private=None, profile="github"): +def list_repos(private=None, fields=None, ignore_cache=False, profile="github"): """ - List the names of all repositories within the organization the - profile token can see. + List all repositories within the organization the profile token + can see, as a mapping of repository name to details. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.list_repos + salt-call github_org.list_repos private=true private Only list private (``true``) or public (``false``) repositories. By default, all repositories are listed. - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. - .. code-block:: bash + ignore_cache + Bypass the use of cached repositories. - salt myminion github_org.list_repos - salt myminion github_org.list_repos private=true - """ - return [ - repo.name - for repo in gh_repos.list_all(_get_conn(profile)) - if private is None or repo.private is private - ] + profile + Profile configuration name to use. Defaults to ``github``. + """ + return filter_fields( + { + repo.name: gh_repos.to_dict(repo) + for repo in gh_repos.list_all(_get_conn(profile), ignore_cache=ignore_cache) + if private is None or repo.private is private + }, + fields, + ) def add_repo( @@ -220,44 +203,44 @@ def add_repo( Create a new repository in the organization. Returns information about the created repository. + CLI Example: + + .. code-block:: bash + + salt-call github_org.add_repo 'repo_name' + name - The name of the repository to create. + Name of the repository to create. description - The description of the repository. + Repository description. homepage - The URL with more information about the repository. + URL with more information about the repository, e.g. rendered docs. private - The visiblity of the repository. + Repository visibility. has_issues - Whether to enable issues for this repository. + Enable issues for this repository. has_wiki - Whether to enable the wiki for this repository. + Enable the wiki for this repository. has_downloads - Whether to enable downloads for this repository. + Enable downloads for this repository. auto_init - Whether to create an initial commit with an empty README. + Create an initial commit with an empty README. gitignore_template - The desired language or platform for a .gitignore, e.g "Haskell". + Desired language or platform for a .gitignore, e.g. "Haskell". license_template - The desired LICENSE template to apply, e.g "mit" or "mozilla". + Desired LICENSE template to apply, e.g. "mit" or "mozilla". profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_org.add_repo 'repo_name' + Profile configuration name to use. Defaults to ``github``. """ return gh_repos.create( _get_conn(profile), @@ -274,43 +257,56 @@ def add_repo( ) -def list_teams(profile="github", ignore_cache=False): +def _strip_team_caches(team_info): """ - List all teams of the organization as a mapping of team name - to team details. + Render a team cache entry for output, without the attached + repository/member caches. + """ + return {key: val for key, val in team_info.items() if key not in ("repos", "members")} - profile - The name of the profile configuration to use. Defaults to ``github``. - ignore_cache - Bypasses the use of cached teams. +def list_teams(fields=None, ignore_cache=False, profile="github"): + """ + List all teams of the organization, as a mapping of team name + to team details. CLI Example: .. code-block:: bash - salt myminion github_org.list_teams + salt-call github_org.list_teams + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + ignore_cache + Bypass the use of cached teams. + + profile + Profile configuration name to use. Defaults to ``github``. """ - return gh_teams.list_all(_get_conn(profile), ignore_cache=ignore_cache) + teams = gh_teams.list_all(_get_conn(profile), ignore_cache=ignore_cache) + return filter_fields({name: _strip_team_caches(team) for name, team in teams.items()}, fields) def get_team(name, profile="github"): """ Return the details of a single team. - name - The name of the team. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_org.get_team 'team_name' + salt-call github_org.get_team 'team_name' + + name + Name of the team. + + profile + Profile configuration name to use. Defaults to ``github``. """ - return _get_team(_get_conn(profile), name) + return _strip_team_caches(_get_team(_get_conn(profile), name)) def add_team( @@ -323,68 +319,71 @@ def add_team( ): """ Create a new team within the organization. + Returns the details of the created team. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.add_team 'team_name' name - The name of the team to be created. + Name of the team to create. description - The description of the team. + Team description. repo_names - The names of repositories to add the team to. + Names of repositories to add the team to. privacy - The level of privacy for the team, can be ``secret`` or ``closed``. + Privacy level for the team, either ``secret`` or ``closed``. permission - The default permission for new repositories added to the team, can be + Default permission for new repositories added to the team, either ``pull``, ``push`` or ``admin``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_org.add_team 'team_name' + Profile configuration name to use. Defaults to ``github``. """ + conn = _get_conn(profile) gh_teams.create( - _get_conn(profile), + conn, name, description=description, repo_names=repo_names, privacy=privacy, permission=permission, ) - return True + return _strip_team_caches(_get_team(conn, name)) def edit_team(name, description=None, privacy=None, permission=None, profile="github"): """ Update an existing team. + Returns the refreshed team details. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.edit_team 'team_name' description='Team description' name - The name of the team to be edited. + Name of the team to edit. description - The description of the team. + Team description. privacy - The level of privacy for the team, can be ``secret`` or ``closed``. + Privacy level for the team, either ``secret`` or ``closed``. permission - The default permission for new repositories added to the team, can be + Default permission for new repositories added to the team, either ``pull``, ``push`` or ``admin``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_org.edit_team 'team_name' description='Team description' + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) gh_teams.update( @@ -395,52 +394,57 @@ def edit_team(name, description=None, privacy=None, permission=None, profile="gi privacy=privacy, permission=permission, ) - return True + return _strip_team_caches(_get_team(conn, name)) def remove_team(name, profile="github"): """ Remove a team from the organization. - name - The name of the team to be removed. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_org.remove_team 'team_name' + salt-call github_org.remove_team 'team_name' + + name + Name of the team to remove. + + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) gh_teams.delete(conn, _get_team(conn, name)) return True -def list_team_repos(team_name, profile="github", ignore_cache=False): +def list_team_repos(team_name, fields=None, ignore_cache=False, profile="github"): """ - Return the repositories of a team as a mapping of (lowercase) - repository name to repository details. + Return the repositories of a team, as a mapping of repository + name to team-specific details (``permission``). - team_name - The name of the team from which to list repos. + CLI Example: - profile - The name of the profile configuration to use. Defaults to ``github``. + .. code-block:: bash - ignore_cache - Bypasses the use of cached team repos. + salt-call github_org.list_team_repos 'team_name' - CLI Example: + team_name + Name of the team from which to list repos. - .. code-block:: bash + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. - salt myminion github_org.list_team_repos 'team_name' + ignore_cache + Bypass the use of cached team repos. + + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - return gh_teams.list_repos(conn, _get_team(conn, team_name), ignore_cache=ignore_cache) + repos = gh_teams.list_repos(conn, _get_team(conn, team_name), ignore_cache=ignore_cache) + return filter_fields({repo["name"]: repo for repo in repos.values()}, fields) def add_team_repo(repo_name, team_name, permission=None, profile="github"): @@ -448,25 +452,24 @@ def add_team_repo(repo_name, team_name, permission=None, profile="github"): Add a repository to a team, or update the permission the team has on it. + CLI Example: + + .. code-block:: bash + + salt-call github_org.add_team_repo 'my_repo' 'team_name' + repo_name - The name of the repository to add. + Name of the repository to add. team_name - The name of the team to add the repository to. + Name of the team to add the repository to. permission - The permission for team members within the repository, can be ``pull``, - ``push`` or ``admin``. If not specified, the default permission specified - on the team will be used. + Permission for team members within the repository, either ``pull``, + ``push`` or ``admin``. If not specified, the team's default permission is used. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_org.add_team_repo 'my_repo' 'team_name' + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) if not gh_teams.add_repo(conn, _get_team(conn, team_name), repo_name, permission=permission): @@ -480,45 +483,44 @@ def remove_team_repo(repo_name, team_name, profile="github"): """ Remove a repository from a team. - repo_name - The name of the repository to remove. + CLI Example: - team_name - The name of the team to remove the repository from. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_org.remove_team_repo 'my_repo' 'team_name' - CLI Example: + repo_name + Name of the repository to remove. - .. code-block:: bash + team_name + Name of the team to remove the repository from. - salt myminion github_org.remove_team_repo 'my_repo' 'team_name' + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - if not gh_teams.remove_repo(conn, _get_team(conn, team_name), repo_name): - raise CommandExecutionError(f"Failed to remove repo {repo_name} from team {team_name}.") + gh_teams.remove_repo(conn, _get_team(conn, team_name), repo_name) return True -def list_team_members(team_name, profile="github", ignore_cache=False): +def list_team_members(team_name, ignore_cache=False, profile="github"): """ Return the logins of a team's members, in lower case. - team_name - The name of the team from which to list members. + CLI Example: - profile - The name of the profile configuration to use. Defaults to ``github``. + .. code-block:: bash - ignore_cache - Bypasses the use of cached team members. + salt-call github_org.list_team_members 'team_name' - CLI Example: + team_name + Name of the team from which to list members. - .. code-block:: bash + ignore_cache + Bypass the use of cached team members. - salt myminion github_org.list_team_members 'team_name' + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) return gh_teams.list_members(conn, _get_team(conn, team_name), ignore_cache=ignore_cache) @@ -528,20 +530,20 @@ def is_team_member(name, team_name, profile="github"): """ Return whether a user is a member of a team. - name - The login of the user whose membership to check. + CLI Example: - team_name - The name of the team to check membership in. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_org.is_team_member 'user_name' 'team_name' - CLI Example: + name + Login of the user whose membership to check. - .. code-block:: bash + team_name + Name of the team to check membership in. - salt myminion github_org.is_team_member 'user_name' 'team_name' + profile + Profile configuration name to use. Defaults to ``github``. """ return name.lower() in list_team_members(team_name, profile=profile) @@ -550,20 +552,20 @@ def add_team_member(name, team_name, profile="github"): """ Add an organization member to a team. - name - The login of the user to add. + CLI Example: - team_name - The name of the team to add the user to. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_org.add_team_member 'user_name' 'team_name' - CLI Example: + name + Login of the user to add. - .. code-block:: bash + team_name + Name of the team to add the user to. - salt myminion github_org.add_team_member 'user_name' 'team_name' + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) gh_teams.add_member(conn, _get_team(conn, team_name), name) @@ -574,148 +576,133 @@ def remove_team_member(name, team_name, profile="github"): """ Remove a member from a team. - name - The login of the user to remove. + CLI Example: - team_name - The name of the team to remove the user from. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_org.remove_team_member 'user_name' 'team_name' - CLI Example: + name + Login of the user to remove. - .. code-block:: bash + team_name + Name of the team to remove the user from. - salt myminion github_org.remove_team_member 'user_name' 'team_name' + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - if not gh_teams.remove_member(conn, _get_team(conn, team_name), name): - raise CommandExecutionError(f"Failed to remove member {name} from team {team_name}.") + gh_teams.remove_member(conn, _get_team(conn, team_name), name) return True -def list_rulesets(per_page=None, headers=None, profile="github"): +def list_rulesets(per_page=None, fields=None, profile="github"): """ List all rulesets of the organization. - per_page - Pagination size, maximum 100. + CLI Example: - headers - Extra headers to pass to the GitHub API. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_org.list_rulesets - CLI Example: + per_page + Pagination size, maximum 100. - .. code-block:: bash + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. - salt myminion github_org.list_rulesets + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - return gh_rulesets.list_all( - conn, "org", org_name=conn.org_name, headers=headers, per_page=per_page + return filter_fields( + gh_rulesets.list_all(conn, "org", org_name=conn.org_name, per_page=per_page), fields ) -def get_ruleset(ruleset_id, headers=None, profile="github"): +def get_ruleset(ruleset_id, profile="github"): """ Return the data of a single organization ruleset. - ruleset_id - The ID of the ruleset. - - headers - Extra headers to pass to the GitHub API. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_org.get_ruleset 1234 + salt-call github_org.get_ruleset 1234 + + ruleset_id + ID of the ruleset. + + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - return gh_rulesets.get(conn, ruleset_id, "org", org_name=conn.org_name, headers=headers) + return gh_rulesets.get(conn, ruleset_id, "org", org_name=conn.org_name) -def add_ruleset(ruleset_params, headers=None, profile="github"): +def add_ruleset(ruleset_params, profile="github"): """ Create a new organization ruleset. Returns the data of the created ruleset. - ruleset_params - The ruleset definition. Must contain at least ``name`` and - ``enforcement``. - - headers - Extra headers to pass to the GitHub API. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_org.add_ruleset '{name: test, enforcement: active}' + salt-call github_org.add_ruleset '{name: test, enforcement: active}' + + ruleset_params + Ruleset definition. Must contain at least ``name`` and ``enforcement``. + + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - return gh_rulesets.create(conn, ruleset_params, "org", org_name=conn.org_name, headers=headers) + return gh_rulesets.create(conn, ruleset_params, "org", org_name=conn.org_name) -def update_ruleset(ruleset_id, ruleset_params, headers=None, profile="github"): +def update_ruleset(ruleset_id, ruleset_params, profile="github"): """ Update an existing organization ruleset. Returns the data of the updated ruleset. - ruleset_id - The ID of the ruleset. - - ruleset_params - The updated ruleset definition. + CLI Example: - headers - Extra headers to pass to the GitHub API. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_org.update_ruleset 1234 '{enforcement: disabled}' - CLI Example: + ruleset_id + ID of the ruleset. - .. code-block:: bash + ruleset_params + Updated ruleset definition. - salt myminion github_org.update_ruleset 1234 '{enforcement: disabled}' + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - return gh_rulesets.update( - conn, ruleset_id, ruleset_params, "org", org_name=conn.org_name, headers=headers - ) + return gh_rulesets.update(conn, ruleset_id, ruleset_params, "org", org_name=conn.org_name) -def delete_ruleset(ruleset_id, headers=None, profile="github"): +def delete_ruleset(ruleset_id, profile="github"): """ Delete an organization ruleset. - ruleset_id - The ID of the ruleset. - - headers - Extra headers to pass to the GitHub API. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_org.delete_ruleset 1234 + salt-call github_org.delete_ruleset 1234 + + ruleset_id + ID of the ruleset. + + profile + Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - gh_rulesets.delete(conn, ruleset_id, "org", org_name=conn.org_name, headers=headers) + gh_rulesets.delete(conn, ruleset_id, "org", org_name=conn.org_name) return True diff --git a/src/saltext/github/modules/github_repo.py b/src/saltext/github/modules/github_repo.py index c0f36bc4..84709269 100644 --- a/src/saltext/github/modules/github_repo.py +++ b/src/saltext/github/modules/github_repo.py @@ -9,6 +9,19 @@ the functions in this module raise errors (usually subclasses of ``CommandExecutionError``) instead of logging them and returning ``False``. +Collections with a unique natural key (teams, Actions secrets, +environments, labels) are returned as mappings keyed by it, id-addressed +collections (issues, pull requests, milestones, rulesets, deploy keys, +deployment branch policies) as lists. + +.. note:: + + This module also works on user-owned repositories since the + corresponding API endpoints are shared: set the profile's + ``org_name`` to the user login. Repository creation + (``github_org.add_repo``, the ``github_repo.present`` state) + and team management remain organization-only. + :depends: PyGithub python module Configuration @@ -16,8 +29,8 @@ This module uses the same configuration profiles as the :py:mod:`github ` module. All functions -operate on the repository named in ``repo_name``, which can also be -set in the profile: +operate on the repository named in ``repo_name``, which is required +unless a default is set in the profile: .. code-block:: yaml @@ -29,13 +42,11 @@ # repo_name is passed. repo_name: my_repo - # optional: it can be dangerous to change the privacy of a repository - # in an automated way. set this to True to allow privacy modifications + # Optional: Allow automated repository privacy modifications. allow_repo_privacy_changes: False - # optional: the base URL of the GitHub API. Set this when targeting - # a GitHub Enterprise Server instance, e.g. - # https://github.example.com/api/v3 + # Optional: Base URL of the GitHub API. Set this when targeting + # a GitHub Enterprise Server instance, e.g. https://github.example.com/api/v3 api_url: https://api.github.com """ @@ -43,38 +54,25 @@ from salt.exceptions import CommandExecutionError -try: - from saltext.github.utils.gh import actions as gh_actions - from saltext.github.utils.gh import deploy_keys as gh_deploy_keys - from saltext.github.utils.gh import environments as gh_environments - from saltext.github.utils.gh import issues as gh_issues - from saltext.github.utils.gh import labels as gh_labels - from saltext.github.utils.gh import milestones as gh_milestones - from saltext.github.utils.gh import pages as gh_pages - from saltext.github.utils.gh import profile as gh_profile - from saltext.github.utils.gh import pulls as gh_pulls - from saltext.github.utils.gh import repos as gh_repos - from saltext.github.utils.gh import rulesets as gh_rulesets - from saltext.github.utils.gh.exceptions import GitHubNotFoundError - - HAS_LIBS = True -except ImportError: # pragma: no cover - HAS_LIBS = False +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import actions as gh_actions +from saltext.github.utils.gh import deploy_keys as gh_deploy_keys +from saltext.github.utils.gh import environments as gh_environments +from saltext.github.utils.gh import issues as gh_issues +from saltext.github.utils.gh import labels as gh_labels +from saltext.github.utils.gh import milestones as gh_milestones +from saltext.github.utils.gh import pages as gh_pages +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh import pulls as gh_pulls +from saltext.github.utils.gh import repos as gh_repos +from saltext.github.utils.gh import rulesets as gh_rulesets +from saltext.github.utils.gh.exceptions import GitHubNotFoundError log = logging.getLogger(__name__) __virtualname__ = "github_repo" -def __virtual__(): - if HAS_LIBS: - return __virtualname__ - return ( # pragma: no cover - False, - "The github_repo execution module cannot be loaded: PyGithub library is not installed.", - ) - - def _get_conn(profile): return gh_profile.get_conn(profile, salt=__salt__, context=__context__) @@ -85,47 +83,51 @@ def _get_config_value(profile, config_name): def get_info(repo_name=None, ignore_cache=False, profile="github"): """ - Return information about the repository. + Return information about a repository. - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + CLI Example: - ignore_cache - Bypasses the use of cached repository information. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.get_info my-repo - CLI Example: + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - .. code-block:: bash + ignore_cache + Bypass the use of cached repository information. - salt myminion github_repo.get_info my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_repos.get_info(_get_conn(profile), repo_name, ignore_cache=ignore_cache) -def get_teams(repo_name=None, profile="github"): +def get_teams(repo_name=None, fields=None, profile="github"): """ - Return the teams the repository belongs to. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. + Return the teams a repository belongs to, as a mapping of team + name to details. CLI Example: .. code-block:: bash - salt myminion github_repo.get_teams my-repo + salt-call github_repo.get_teams my-repo + + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_repos.get_teams(_get_conn(profile), repo_name) + teams = gh_repos.get_teams(_get_conn(profile), repo_name) + return filter_fields({team["name"]: team for team in teams}, fields) def update( @@ -140,43 +142,42 @@ def update( profile="github", ): """ - Update the repository settings. + Update repository settings. Returns the refreshed repository information. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.update my-repo description='Managed by Salt' + repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. description - The description of the repository. + Repository description. homepage - The URL with more information about the repository. + URL with more information about the repository, e.g. rendered docs. private - The visiblity of the repository. Changing this requires - ``allow_repo_privacy_changes`` to be enabled in the profile. + Repository visibility. Changing this requires ``allow_repo_privacy_changes`` + to be enabled in the profile. has_issues - Whether to enable issues for this repository. + Enable issues for this repository. has_wiki - Whether to enable the wiki for this repository. + Enable the wiki for this repository. has_downloads - Whether to enable downloads for this repository. + Enable downloads for this repository. default_branch - The default branch of the repository. The branch must already exist. + Default branch of the repository. Must already exist: Ignored during repo creation. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.update my-repo description='Managed by Salt' + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) try: @@ -199,20 +200,19 @@ def update( def delete(repo_name=None, profile="github"): """ - Delete the repository. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. + Delete a repository. CLI Example: .. code-block:: bash - salt myminion github_repo.delete my-repo + salt-call github_repo.delete my-repo + + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_repos.delete(_get_conn(profile), repo_name) @@ -230,65 +230,64 @@ def list_issues( sort="created", direction="desc", since=None, - output="min", per_page=None, + fields=None, profile="github", ): """ Return the issues in the repository, based on the search options. + Pull requests are excluded. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_issues my-repo repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. milestone - The number of a GitHub milestone, or a string of either ``*`` or - ``none``. + Number of a GitHub milestone, or a string of either ``*`` or ``none``. state - Indicates the state of the issues to return. Can be either ``open``, + Filter returned issues by their state. Can be either ``open``, ``closed``, or ``all``. Default is ``open``. assignee - Can be the name of a user. Pass in ``none`` (as a string) for issues + Filter returned issues by assigned user handle. Pass ``none`` (as a string) for issues with no assigned user or ``*`` for issues assigned to any user. creator - The user that created the issue. + Filter by the username that created the issue. mentioned - A user that's mentioned in the issue. + Filter issues for a user that's mentioned in the issue. labels - A string of comma separated label names. For example, ``bug,ui,@high``. + Filter issues by associated labels. String of comma separated label names. + For example, ``bug,ui,@high``. sort - What to sort results by. Can be either ``created``, ``updated``, or + Field to sort results by. Either ``created``, ``updated``, or ``comments``. Default is ``created``. direction - The direction of the sort. Can be either ``asc`` or ``desc``. Default - is ``desc``. + Sort direction. Either ``asc`` or ``desc``. Default is ``desc``. since - Only issues updated at or after this time are returned. This is a + Only list issues updated at or after this time. This is a timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. - output - The amount of data returned for each issue. Defaults to ``min``. - Change to ``full`` to see all issue output. - per_page Pagination size, maximum 100. - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. - salt myminion github_repo.list_issues my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) issues = gh_issues.list_all( @@ -305,83 +304,57 @@ def list_issues( since=since, per_page=per_page, ) - if output == "full": - return issues - return [gh_issues.format_issue(issue) for issue in issues or []] + return filter_fields(issues, fields) -def get_issue(number, repo_name=None, output="min", profile="github"): +def get_issue(number, repo_name=None, profile="github"): """ Return information about a single issue in the repository. - number - The number of the issue to retrieve. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + CLI Example: - output - The amount of data returned for the issue. Defaults to ``min``. - Change to ``full`` to see all issue output. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.get_issue 514 - CLI Example: + number + Issue number to retrieve. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.get_issue 514 + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - issue = gh_issues.get(_get_conn(profile), repo_name, number) - if output == "full": - return issue - return gh_issues.format_issue(issue) + return gh_issues.get(_get_conn(profile), repo_name, number) -def get_issue_comments(number, repo_name=None, since=None, output="min", profile="github"): +def get_issue_comments(number, repo_name=None, since=None, profile="github"): """ Return the comments of an issue in the repository. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_issue_comments 514 + number - The number of the issue for which to retrieve comments. + Issue number for which to retrieve comments. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. since - Only comments updated at or after this time are returned. This is a + Only return comments updated at or after this time. This is a timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. - output - The amount of data returned for each comment. Defaults to ``min``. - Change to ``full`` to see all comment output. - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.get_issue_comments 514 + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - comments = gh_issues.get_comments(_get_conn(profile), repo_name, number, since=since) - if output == "full": - return comments - return [ - { - "id": comment.get("id"), - "created_at": comment.get("created_at"), - "updated_at": comment.get("updated_at"), - "user_login": comment.get("user").get("login"), - } - for comment in comments or [] - ] + return gh_issues.get_comments(_get_conn(profile), repo_name, number, since=since) def list_milestones( @@ -389,43 +362,42 @@ def list_milestones( state="open", sort="due_on", direction="asc", - output="min", per_page=None, + fields=None, profile="github", ): """ - Return the milestones of the repository. + Return the existing repository milestones. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_milestones my-repo repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. state - The state of the milestones. Either ``open``, ``closed``, or ``all``. + Filter milestones by their state. Either ``open``, ``closed``, or ``all``. Default is ``open``. sort - What to sort results by. Either ``due_on`` or ``completeness``. Default + Field to sort results by. Either ``due_on`` or ``completeness``. Default is ``due_on``. direction - The direction of the sort. Either ``asc`` or ``desc``. Default is ``asc``. - - output - The amount of data returned for each milestone. Defaults to ``min``. - Change to ``full`` to see all milestone output. + Sort direction. Either ``asc`` or ``desc``. Default is ``asc``. per_page Pagination size, maximum 100. - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. - salt myminion github_repo.list_milestones my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) milestones = gh_milestones.list_all( @@ -436,56 +408,43 @@ def list_milestones( direction=direction, per_page=per_page, ) - if output == "full": - return milestones - return [gh_milestones.format_milestone(milestone) for milestone in milestones or []] + return filter_fields(milestones, fields) -def get_milestone(number=None, name=None, repo_name=None, output="min", profile="github"): +def get_milestone(number=None, name=None, repo_name=None, profile="github"): """ Return information about a single milestone in the repository. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_milestone 72 + salt-call github_repo.get_milestone name=my_milestone + number - The number of the milestone to retrieve. If provided, this option - will be favored over ``name``. + Milestone number to retrieve. If provided, overrides ``name``. name - The title of the milestone to retrieve. + Title of the milestone to retrieve. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - output - The amount of data returned for the milestone. Defaults to ``min``. - Change to ``full`` to see all milestone output. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.get_milestone 72 - salt myminion github_repo.get_milestone name=my_milestone + Profile configuration name to use. Defaults to ``github``. """ if not (number or name): raise CommandExecutionError("Either a milestone 'name' or 'number' must be provided.") repo_name = _resolve_repo_name(repo_name, profile) if number: - milestone = gh_milestones.get(_get_conn(profile), repo_name, number) - if output == "full": - return milestone - return gh_milestones.format_milestone(milestone) + return gh_milestones.get(_get_conn(profile), repo_name, number) milestones = gh_milestones.list_all(_get_conn(profile), repo_name, state="all") for milestone in milestones or []: if milestone.get("title") == name: - if output == "full": - return milestone - return gh_milestones.format_milestone(milestone) + return milestone raise GitHubNotFoundError(f"Milestone {name} was not found in repository {repo_name}.") @@ -496,53 +455,50 @@ def list_pulls( base=None, sort="created", direction="desc", - output="min", per_page=None, + fields=None, profile="github", ): """ - Return the pull requests in the repository, based on the search - options provided. + Return the pull requests in the repository, based on the search options. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_pulls my-repo repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. state - Indicates the state of the pull requests to return. Can be either + Filter returned pull requests by their state. Can be either ``open``, ``closed``, or ``all``. Default is ``open``. head - Filter pull requests by head user and branch name in the format of + Filter pull requests by head user and branch name in the format ``user:ref-name``. base - Filter pulls by base branch name. Example: ``gh-pages``. + Filter pulls by base branch name, e.g. ``gh-pages``. sort - What to sort results by. Can be either ``created``, ``updated``, + Field to sort results by. Either ``created``, ``updated``, ``popularity`` (comment count), or ``long-running`` (age, filtering by pull requests updated within the last month). Default is ``created``. direction - The direction of the sort. Can be either ``asc`` or ``desc``. Default - is ``desc``. - - output - The amount of data returned for each pull request. Defaults to ``min``. - Change to ``full`` to see all pull request output. + Sort direction. Either ``asc`` or ``desc``. Default is ``desc``. per_page Pagination size, maximum 100. - profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. - .. code-block:: bash - - salt myminion github_repo.list_pulls my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) pulls = gh_pulls.list_all( @@ -555,134 +511,117 @@ def list_pulls( direction=direction, per_page=per_page, ) - if output == "full": - return pulls - return [gh_pulls.format_pr(pr_) for pr_ in pulls or []] + return filter_fields(pulls, fields) -def list_rulesets(repo_name=None, per_page=None, headers=None, profile="github"): +def list_rulesets(repo_name=None, per_page=None, fields=None, profile="github"): """ List all rulesets of the repository. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_rulesets my-repo + repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. per_page Pagination size, maximum 100. - headers - Extra headers to pass to the GitHub API. + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.list_rulesets my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) conn = _get_conn(profile) - return gh_rulesets.list_all( + rulesets = gh_rulesets.list_all( conn, "repo", owner=conn.org_name, repo_name=repo_name, - headers=headers, per_page=per_page, ) + return filter_fields(rulesets, fields) -def get_ruleset(ruleset_id, repo_name=None, headers=None, profile="github"): +def get_ruleset(ruleset_id, repo_name=None, profile="github"): """ Return the data of a single repository ruleset. - ruleset_id - The ID of the ruleset. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + CLI Example: - headers - Extra headers to pass to the GitHub API. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.get_ruleset 1234 repo_name=my-repo - CLI Example: + ruleset_id + ID of the ruleset. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.get_ruleset 1234 repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) conn = _get_conn(profile) - return gh_rulesets.get( - conn, ruleset_id, "repo", owner=conn.org_name, repo_name=repo_name, headers=headers - ) + return gh_rulesets.get(conn, ruleset_id, "repo", owner=conn.org_name, repo_name=repo_name) -def add_ruleset(ruleset_params, repo_name=None, headers=None, profile="github"): +def add_ruleset(ruleset_params, repo_name=None, profile="github"): """ Create a new repository ruleset. Returns the data of the created ruleset. - ruleset_params - The ruleset definition. Must contain at least ``name`` and - ``enforcement``. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + CLI Example: - headers - Extra headers to pass to the GitHub API. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.add_ruleset '{name: test, enforcement: active}' repo_name=my-repo - CLI Example: + ruleset_params + Ruleset definition. Must contain at least ``name`` and ``enforcement``. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.add_ruleset '{name: test, enforcement: active}' repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) conn = _get_conn(profile) return gh_rulesets.create( - conn, ruleset_params, "repo", owner=conn.org_name, repo_name=repo_name, headers=headers + conn, ruleset_params, "repo", owner=conn.org_name, repo_name=repo_name ) -def update_ruleset(ruleset_id, ruleset_params, repo_name=None, headers=None, profile="github"): +def update_ruleset(ruleset_id, ruleset_params, repo_name=None, profile="github"): """ Update an existing repository ruleset. Returns the data of the updated ruleset. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.update_ruleset 1234 '{enforcement: disabled}' repo_name=my-repo + ruleset_id - The ID of the ruleset. + ID of the ruleset. ruleset_params - The updated ruleset definition. + Updated ruleset definition. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - headers - Extra headers to pass to the GitHub API. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.update_ruleset 1234 '{enforcement: disabled}' repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) conn = _get_conn(profile) @@ -693,38 +632,31 @@ def update_ruleset(ruleset_id, ruleset_params, repo_name=None, headers=None, pro "repo", owner=conn.org_name, repo_name=repo_name, - headers=headers, ) -def delete_ruleset(ruleset_id, repo_name=None, headers=None, profile="github"): +def delete_ruleset(ruleset_id, repo_name=None, profile="github"): """ Delete a repository ruleset. - ruleset_id - The ID of the ruleset. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + CLI Example: - headers - Extra headers to pass to the GitHub API. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.delete_ruleset 1234 repo_name=my-repo - CLI Example: + ruleset_id + ID of the ruleset. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.delete_ruleset 1234 repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) conn = _get_conn(profile) - gh_rulesets.delete( - conn, ruleset_id, "repo", owner=conn.org_name, repo_name=repo_name, headers=headers - ) + gh_rulesets.delete(conn, ruleset_id, "repo", owner=conn.org_name, repo_name=repo_name) return True @@ -737,46 +669,48 @@ def _resolve_repo_name(repo_name, profile): return repo_name -def list_deploy_keys(repo_name=None, profile="github"): +def list_deploy_keys(repo_name=None, fields=None, profile="github"): """ List the deploy keys of a repository. - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_repo.list_deploy_keys my-repo + salt-call github_repo.list_deploy_keys my-repo + + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_deploy_keys.list_all(_get_conn(profile), repo_name) + return filter_fields(gh_deploy_keys.list_all(_get_conn(profile), repo_name), fields) def get_deploy_key(key_id, repo_name=None, profile="github"): """ Return the data of a single deploy key of a repository. - key_id - The ID of the deploy key. + CLI Example: - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.get_deploy_key 12345 repo_name=my-repo - CLI Example: + key_id + ID of the deploy key. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.get_deploy_key 12345 repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_deploy_keys.get(_get_conn(profile), repo_name, key_id) @@ -786,30 +720,29 @@ def add_deploy_key(title, key, read_only=False, repo_name=None, profile="github" """ Add a deploy key to a repository. Returns the data of the created key. - Note that deploy keys are immutable. To modify one, remove it and - add a replacement. + .. note:: + Deploy keys are immutable. To modify one, remove it and add a replacement. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.add_deploy_key autorelease 'ssh-ed25519 AAAA...' read_only=false repo_name=my-repo title - The display title of the deploy key. + Display title of the deploy key. key - The SSH public key. + SSH public key. read_only Deny write access to the repository for this key. Defaults to false. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.add_deploy_key autorelease 'ssh-ed25519 AAAA...' read_only=false repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_deploy_keys.create( @@ -821,47 +754,51 @@ def remove_deploy_key(key_id, repo_name=None, profile="github"): """ Remove a deploy key from a repository. - key_id - The ID of the deploy key. + CLI Example: - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.remove_deploy_key 12345 repo_name=my-repo - CLI Example: + key_id + ID of the deploy key. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.remove_deploy_key 12345 repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_deploy_keys.delete(_get_conn(profile), repo_name, key_id) return True -def list_actions_secrets(repo_name=None, profile="github"): +def list_actions_secrets(repo_name=None, fields=None, profile="github"): """ - List the Actions secrets of a repository. Secret values are write-only, - so this only returns metadata (``name``, ``created_at``, ``updated_at``). - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. + List the Actions secrets of a repository, as a mapping of secret + name to metadata. Secret values are write-only, so this only returns + metadata (``created_at``, ``updated_at``). CLI Example: .. code-block:: bash - salt myminion github_repo.list_actions_secrets my-repo + salt-call github_repo.list_actions_secrets my-repo + + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_actions.list_secrets(_get_conn(profile), repo_name) + secrets = gh_actions.list_secrets(_get_conn(profile), repo_name) + return filter_fields({secret["name"]: secret for secret in secrets}, fields) def get_actions_secret(name, repo_name=None, profile="github"): @@ -869,21 +806,20 @@ def get_actions_secret(name, repo_name=None, profile="github"): Return the metadata of a single Actions secret of a repository. The value itself is write-only and cannot be retrieved. - name - The name of the secret. + CLI Example: - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.get_actions_secret MY_SECRET repo_name=my-repo - CLI Example: + name + Name of the secret. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.get_actions_secret MY_SECRET repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_actions.get_secret(_get_conn(profile), repo_name, name) @@ -894,24 +830,23 @@ def set_actions_secret(name, value, repo_name=None, profile="github"): Create or update an Actions secret of a repository. The value is encrypted to the repository public key before leaving the minion. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.set_actions_secret MY_SECRET hunter2 repo_name=my-repo + name - The name of the secret. + Name of the secret. value - The plaintext secret value. + Plaintext secret value. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.set_actions_secret MY_SECRET hunter2 repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_actions.set_secret(_get_conn(profile), repo_name, name, value) @@ -922,21 +857,20 @@ def remove_actions_secret(name, repo_name=None, profile="github"): """ Remove an Actions secret from a repository. - name - The name of the secret. + CLI Example: - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.remove_actions_secret MY_SECRET repo_name=my-repo - CLI Example: + name + Name of the secret. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.remove_actions_secret MY_SECRET repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_actions.delete_secret(_get_conn(profile), repo_name, name) @@ -949,18 +883,17 @@ def get_workflow_permissions(repo_name=None, profile="github"): in a repository, a mapping with ``default_workflow_permissions`` (``read``/``write``) and ``can_approve_pull_request_reviews``. - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_repo.get_workflow_permissions my-repo + salt-call github_repo.get_workflow_permissions my-repo + + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_actions.get_workflow_permissions(_get_conn(profile), repo_name) @@ -976,25 +909,24 @@ def set_workflow_permissions( Update the default workflow permissions granted to the ``GITHUB_TOKEN`` in a repository. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.set_workflow_permissions read can_approve_pull_request_reviews=true repo_name=my-repo + default_workflow_permissions - The default permissions for the ``GITHUB_TOKEN``, either ``read`` + Default permissions for the ``GITHUB_TOKEN``, either ``read`` or ``write``. can_approve_pull_request_reviews - Whether GitHub Actions can approve pull requests. + Allow GitHub Actions to approve pull requests. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.set_workflow_permissions read can_approve_pull_request_reviews=true repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_actions.set_workflow_permissions( @@ -1006,46 +938,50 @@ def set_workflow_permissions( return True -def list_environments(repo_name=None, profile="github"): +def list_environments(repo_name=None, fields=None, profile="github"): """ - List the environments of a repository. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. + List the environments of a repository, as a mapping of environment + name to details. CLI Example: .. code-block:: bash - salt myminion github_repo.list_environments my-repo + salt-call github_repo.list_environments my-repo + + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_environments.list_all(_get_conn(profile), repo_name) + environments = gh_environments.list_all(_get_conn(profile), repo_name) + return filter_fields({env["name"]: env for env in environments}, fields) def get_environment(name, repo_name=None, profile="github"): """ Return the data of a single environment of a repository. - name - The name of the environment. + CLI Example: - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.get_environment release repo_name=my-repo - CLI Example: + name + Name of the environment. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.get_environment release repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_environments.get(_get_conn(profile), repo_name, name) @@ -1056,67 +992,54 @@ def set_environment( wait_timer=None, prevent_self_review=None, reviewers=None, - deployment_branch_policy=..., + deployment_branch_policy=None, repo_name=None, profile="github", ): """ - Create or update an environment of a repository. + Create or update an environment of a repository, replacing its whole + configuration. Unspecified settings are reset to their defaults. Returns the data of the environment. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.set_environment release repo_name=my-repo \\ + deployment_branch_policy='{protected_branches: false, custom_branch_policies: true}' + name - The name of the environment. + Name of the environment. wait_timer - The amount of time to delay a job after the job is initially - triggered, in minutes. + Amount of time to delay a job after it is initially triggered, + in minutes. prevent_self_review - Whether or not a user who created the job is prevented from - approving their own job. + Prevent a user who created a job from approving it. reviewers - The people or teams that may review jobs that reference the + People or teams that may review jobs that reference the environment, as a list of mappings with ``type`` (``User`` or ``Team``) and ``id``. deployment_branch_policy - The type of deployment branch policy for this environment, either - ``none`` to allow all branches or a mapping with + Type of deployment branch policy for this environment, either + ``null`` (default) to allow all branches or a mapping with ``protected_branches`` and ``custom_branch_policies`` booleans. Branch name patterns for custom policies are managed separately, see :py:func:`github_repo.add_deployment_branch_policy `. - If not passed at all, the current policy is left unchanged. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.set_environment release repo_name=my-repo \\ - deployment_branch_policy='{protected_branches: false, custom_branch_policies: true}' + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - conn = _get_conn(profile) - if deployment_branch_policy is ...: - # PUT replaces the whole environment config, so carry over the - # current policy when the caller does not specify one. - try: - current = gh_environments.get(conn, repo_name, name) - deployment_branch_policy = gh_environments.normalize(current)[ - "deployment_branch_policy" - ] - except GitHubNotFoundError: - deployment_branch_policy = None return gh_environments.set_( - conn, + _get_conn(profile), repo_name, name, wait_timer=wait_timer, @@ -1130,49 +1053,53 @@ def remove_environment(name, repo_name=None, profile="github"): """ Remove an environment from a repository. - name - The name of the environment. + CLI Example: - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.remove_environment release repo_name=my-repo - CLI Example: + name + Name of the environment. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.remove_environment release repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_environments.delete(_get_conn(profile), repo_name, name) return True -def list_deployment_branch_policies(environment, repo_name=None, profile="github"): +def list_deployment_branch_policies(environment, repo_name=None, fields=None, profile="github"): """ List the deployment branch policies of an environment. - environment - The name of the environment. + CLI Example: - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.list_deployment_branch_policies release repo_name=my-repo - CLI Example: + environment + Name of the environment. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. - salt myminion github_repo.list_deployment_branch_policies release repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_environments.list_branch_policies(_get_conn(profile), repo_name, environment) + return filter_fields( + gh_environments.list_branch_policies(_get_conn(profile), repo_name, environment), fields + ) def add_deployment_branch_policy( @@ -1182,32 +1109,30 @@ def add_deployment_branch_policy( Add a deployment branch (or tag) policy to an environment. Returns the data of the created policy. - The environment's ``deployment_branch_policy`` must have - ``custom_branch_policies`` enabled. + The environment's ``deployment_branch_policy`` must have ``custom_branch_policies`` enabled. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.add_deployment_branch_policy release main repo_name=my-repo environment - The name of the environment. + Name of the environment. name - The name pattern that branches (or tags) must match in order - to deploy to the environment. + Name pattern that branches (or tags) must match in order to + deploy to the environment. type\_ - Whether this rule targets a ``branch`` or ``tag``. + Target of this rule, either ``branch`` or ``tag``. Defaults to ``branch``. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.add_deployment_branch_policy release main repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_environments.create_branch_policy( @@ -1219,70 +1144,73 @@ def remove_deployment_branch_policy(environment, policy_id, repo_name=None, prof """ Remove a deployment branch policy from an environment. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.remove_deployment_branch_policy release 12345 repo_name=my-repo + environment - The name of the environment. + Name of the environment. policy_id - The ID of the branch policy. + ID of the branch policy. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.remove_deployment_branch_policy release 12345 repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_environments.delete_branch_policy(_get_conn(profile), repo_name, environment, policy_id) return True -def list_labels(repo_name=None, profile="github"): +def list_labels(repo_name=None, fields=None, profile="github"): """ - List the labels of a repository. - - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. + List the labels of a repository, as a mapping of label name + to details. CLI Example: .. code-block:: bash - salt myminion github_repo.list_labels my-repo + salt-call github_repo.list_labels my-repo + + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_labels.list_all(_get_conn(profile), repo_name) + labels = gh_labels.list_all(_get_conn(profile), repo_name) + return filter_fields({label["name"]: label for label in labels}, fields) def get_label(name, repo_name=None, profile="github"): """ Return the data of a single label of a repository. - name - The name of the label. + CLI Example: - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.get_label bug repo_name=my-repo - CLI Example: + name + Name of the label. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.get_label bug repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_labels.get(_get_conn(profile), repo_name, name) @@ -1292,28 +1220,27 @@ def add_label(name, color=None, description=None, repo_name=None, profile="githu """ Create a label in a repository. Returns the data of the created label. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.add_label release color=00ff00 repo_name=my-repo + name - The name of the label. + Name of the label. color - The hexadecimal color code for the label, without the leading ``#``. + Hexadecimal color code for the label, without the leading ``#``. If unset, GitHub assigns a random one. description - A short description of the label. + Short description of the label. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.add_label release color=00ff00 repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_labels.create( @@ -1326,30 +1253,29 @@ def edit_label(name, new_name=None, color=None, description=None, repo_name=None Update an existing label of a repository. Returns the data of the updated label. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.edit_label release color=00ff00 repo_name=my-repo + name - The (current) name of the label. + Current name of the label. new_name - A new name for the label. + New name for the label. color - The hexadecimal color code for the label, without the leading ``#``. + Hexadecimal color code for the label, without the leading ``#``. description - A short description of the label. + Short description of the label. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.edit_label release color=00ff00 repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_labels.update( @@ -1366,21 +1292,20 @@ def remove_label(name, repo_name=None, profile="github"): """ Delete a label from a repository. - name - The name of the label. + CLI Example: - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + .. code-block:: bash - profile - The name of the profile configuration to use. Defaults to ``github``. + salt-call github_repo.remove_label release repo_name=my-repo - CLI Example: + name + Name of the label. - .. code-block:: bash + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. - salt myminion github_repo.remove_label release repo_name=my-repo + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_labels.delete(_get_conn(profile), repo_name, name) @@ -1391,18 +1316,17 @@ def get_pages(repo_name=None, profile="github"): """ Return information about the GitHub Pages site of a repository. - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_repo.get_pages my-repo + salt-call github_repo.get_pages my-repo + + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_pages.get(_get_conn(profile), repo_name) @@ -1415,8 +1339,14 @@ def setup_pages( Configure a GitHub Pages site for a repository. Returns the data of the created site. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.setup_pages build_type=workflow repo_name=my-repo + build_type - The process by which the site is built, either ``legacy`` + Process by which the site is built, either ``legacy`` (deploy from a branch) or ``workflow`` (GitHub Actions). source_branch @@ -1427,17 +1357,10 @@ def setup_pages( either ``/`` or ``/docs``. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.setup_pages build_type=workflow repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_pages.create( @@ -1452,6 +1375,7 @@ def update_pages( build_type=None, source_branch=None, source_path=None, + cname=..., https_enforced=None, repo_name=None, profile="github", @@ -1459,8 +1383,14 @@ def update_pages( """ Update the GitHub Pages site configuration of a repository. + CLI Example: + + .. code-block:: bash + + salt-call github_repo.update_pages build_type=workflow repo_name=my-repo + build_type - The process by which the site is built, either ``legacy`` + Process by which the site is built, either ``legacy`` (deploy from a branch) or ``workflow`` (GitHub Actions). source_branch @@ -1470,21 +1400,18 @@ def update_pages( For ``legacy`` builds, the repository directory to publish from, either ``/`` or ``/docs``. + cname + Custom domain for the site. Pass ``null`` explicitly to remove it, + omit to leave it unchanged. + https_enforced - Whether HTTPS is enforced for the site. + Enforce HTTPS for the site. repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. - - CLI Example: - - .. code-block:: bash - - salt myminion github_repo.update_pages build_type=workflow repo_name=my-repo + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_pages.update( @@ -1492,6 +1419,7 @@ def update_pages( repo_name, build_type=build_type, source=gh_pages.build_source(source_branch, source_path), + cname=cname, https_enforced=https_enforced, ) return True @@ -1501,18 +1429,17 @@ def remove_pages(repo_name=None, profile="github"): """ Delete the GitHub Pages site of a repository. - repo_name - The name of the repository. This argument is required, either passed - via the CLI, or defined in the configured profile. - - profile - The name of the profile configuration to use. Defaults to ``github``. - CLI Example: .. code-block:: bash - salt myminion github_repo.remove_pages my-repo + salt-call github_repo.remove_pages my-repo + + repo_name + Name of the repository. Defaults to the profile's ``repo_name``. + + profile + Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) gh_pages.delete(_get_conn(profile), repo_name) diff --git a/src/saltext/github/states/github_org.py b/src/saltext/github/states/github_org.py index 1f6cf45c..72f02190 100644 --- a/src/saltext/github/states/github_org.py +++ b/src/saltext/github/states/github_org.py @@ -4,11 +4,11 @@ .. versionadded:: 1.1.0 """ -import datetime import logging from salt.exceptions import CommandExecutionError +from saltext.github.utils import filter_unset from saltext.github.utils.gh.exceptions import GitHubNotFoundError log = logging.getLogger(__name__) @@ -16,15 +16,6 @@ __virtualname__ = "github_org" -def __virtual__(): - """ - Only load if the github_org module is available in __salt__ - """ - if "github_org.list_members" in __salt__: - return __virtualname__ - return (False, "github_org module could not be loaded") # pragma: no cover - - def member_present(name, profile="github"): """ Ensure a user is a member of the organization. Since GitHub @@ -32,58 +23,42 @@ def member_present(name, profile="github"): single run: Once the user has been invited, this state reports success while the membership state is ``pending``. - Example: - - .. code-block:: yaml - - gitexample is a member of the org: - github_org.member_present: - - name: gitexample - name - The GitHub login of the user. + GitHub login of the user. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - info = __salt__["github_org.get_member"](name, profile=profile) - except GitHubNotFoundError: - ret["result"] = False - ret["comment"] = f"User {name} does not exist" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get member {name}: {err}" - return ret - - if info["in_org"]: - ret["comment"] = f"User {name} is already a member of the organization" - return ret - if info["membership_state"] == "pending": - ret["comment"] = f"User {name} has already been invited" - return ret - - ret["changes"]["old"] = None - ret["changes"]["new"] = f"User {name} is a member of the organization" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"User {name} would have been invited" - return ret - try: - pending = __salt__["github_org.add_member"](name, profile=profile) + try: + info = __salt__["github_org.get_member"](name, profile=profile) + except GitHubNotFoundError as err: + raise CommandExecutionError(f"User {name} does not exist") from err + + if info["in_org"]: + ret["comment"] = f"User {name} is already a member of the organization" + return ret + if info["membership_state"] == "pending": + ret["comment"] = f"User {name} has already been invited" + return ret + + ret["changes"]["invited"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"User {name} would have been invited" + return ret + + if __salt__["github_org.add_member"](name, profile=profile): + ret["comment"] = f"User {name} has been invited" + else: + ret["changes"] = {"added": name} + ret["comment"] = f"User {name} has been added" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to invite user {name}: {err}" - return ret - if pending: - ret["changes"]["new"] = f"User {name} has been invited to the organization" - ret["comment"] = f"User {name} has been invited" - else: - ret["comment"] = f"User {name} has been added" return ret @@ -91,403 +66,210 @@ def member_absent(name, profile="github"): """ Ensure a user is not a member of the organization. - Example: - - .. code-block:: yaml - - gitexample is not a member of the org: - github_org.member_absent: - - name: gitexample - name - The GitHub login of the user. + GitHub login of the user. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - info = __salt__["github_org.get_member"](name, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"User {name} does not exist" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get member {name}: {err}" - return ret - - if not info["in_org"]: - ret["comment"] = f"User {name} is not a member of the organization" - return ret - - ret["changes"]["old"] = f"User {name} is a member of the organization" - ret["changes"]["new"] = f"User {name} is not a member of the organization" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"User {name} would have been removed from the organization" - return ret - try: + try: + info = __salt__["github_org.get_member"](name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"User {name} does not exist" + return ret + + if not info["in_org"]: + ret["comment"] = f"User {name} is not a member of the organization" + return ret + + ret["changes"]["removed"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"User {name} would have been removed from the organization" + return ret + __salt__["github_org.remove_member"](name, profile=profile) + ret["comment"] = f"User {name} has been removed from the organization" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to remove user {name} from the organization: {err}" - return ret - ret["comment"] = f"User {name} has been removed from the organization" return ret -def _member_violates_mfa(member, member_info, mfa_deadline, members_without_mfa): - if member_info.get("mfa_exempt", False): - return False - enforce_mfa_from = datetime.datetime.strptime( - member_info.get("enforce_mfa_from", "1970/01/01"), "%Y/%m/%d" - ).replace(tzinfo=datetime.timezone.utc) - return member.lower() in members_without_mfa and (mfa_deadline > enforce_mfa_from) - - -def _team_converge_repos(ret, name, repo_names, test_comments, profile): +def _case_insensitive_diff(current, desired): """ - Converge an existing team's repositories, recording changes in ``ret``. - Returns false when the state should abort. + Return (added, removed) between two collections of names, + compared case-insensitively since GitHub treats repository + names and user logins as such. ``added`` preserves the case + of the desired names, ``removed`` the current one. """ - current_repos = set(__salt__["github_org.list_team_repos"](name, profile=profile)) - repo_names = set(repo_names or []) - - repos_to_add = repo_names - current_repos - repos_to_remove = current_repos - repo_names if repo_names else [] - - if repos_to_add: - if __opts__["test"]: - test_comments.append( - f"Team {name} will have the following repos added: {list(repos_to_add)}." - ) - ret["result"] = None - for repo_name in repos_to_add: - repo_change = { - "old": f"Repo {repo_name} is not in team {name}", - "new": f"Repo {repo_name} is in team {name}", - } - if __opts__["test"]: - ret["changes"][repo_name] = repo_change - else: - try: - __salt__["github_org.add_team_repo"](repo_name, name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to add repo {repo_name} to team {name}: {err}" - return False - ret["changes"][repo_name] = repo_change - - if repos_to_remove: - if __opts__["test"]: - test_comments.append( - f"Team {name} will have the following repos removed: {list(repos_to_remove)}." - ) - ret["result"] = None - for repo_name in repos_to_remove: - repo_change = { - "old": f"Repo {repo_name} is in team {name}", - "new": f"Repo {repo_name} is not in team {name}", - } - if __opts__["test"]: - ret["changes"][repo_name] = repo_change - else: - try: - __salt__["github_org.remove_team_repo"](repo_name, name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to remove repo {repo_name} from team {name}: {err}" - return False - ret["changes"][repo_name] = repo_change - return True - - -def _team_converge_members( # pylint: disable=too-many-arguments - ret, name, members, enforce_mfa, no_mfa_grace_seconds, test_comments, profile -): - """ - Converge a team's members, recording changes in ``ret``. - Returns false when the state should abort. - """ - manage_members = members is not None - - mfa_deadline = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta( - seconds=no_mfa_grace_seconds - ) - members_no_mfa = __salt__["github_org.list_members_without_mfa"](profile=profile) - - members_lower = {} - for member_name, info in (members or {}).items(): - members_lower[member_name.lower()] = info - - member_change = False - current_members = __salt__["github_org.list_team_members"](name, profile=profile) - - for member, member_info in (members or {}).items(): - log.info("Checking member %s in team %s", member, name) - - if member.lower() not in current_members: - if enforce_mfa and _member_violates_mfa( - member, member_info, mfa_deadline, members_no_mfa - ): - if __opts__["test"]: - test_comments.append( - f"User {member} will not be added to the team because they do not have MFA." - ) - else: # Add to team - member_change = True - member_add = { - "old": f"User {member} is not in team {name}", - "new": f"User {member} is in team {name}", - } - if __opts__["test"]: - test_comments.append(f"User {member} set to be added to the team.") - ret["result"] = None - ret["changes"][member] = member_add - else: - try: - __salt__["github_org.add_team_member"](member, name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to add user {member} to team {name}: {err}" - return False - ret["changes"][member] = member_add - - for member in current_members: - mfa_violation = False - if member in members_lower: - mfa_violation = _member_violates_mfa( - member, members_lower[member], mfa_deadline, members_no_mfa - ) - if manage_members and member not in members_lower or (enforce_mfa and mfa_violation): - # Remove from team - member_change = True - extra_changes = " due to MFA violation" if mfa_violation else "" - member_removal = { - "old": f"User {member} is in team {name}", - "new": f"User {member} is not in team {name}{extra_changes}", - } - if __opts__["test"]: - if mfa_violation: - test_comments.append( - f"User {member} set to be removed from the team " - "because they do not have MFA." - ) - else: - test_comments.append(f"User {member} set to be removed from the team.") - ret["result"] = None - ret["changes"][member] = member_removal - else: - try: - __salt__["github_org.remove_team_member"](member, name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to remove user {member} from team {name}: {err}" - return False - ret["changes"][member] = member_removal - - if member_change: # Refresh team cache - __salt__["github_org.list_team_members"](name, profile=profile, ignore_cache=True) - return True + current = {name.lower(): name for name in current} + desired = {name.lower(): name for name in desired} + added = sorted(desired[name] for name in set(desired) - set(current)) + removed = sorted(current[name] for name in set(current) - set(desired)) + return added, removed def team_present( name, description=None, repo_names=None, - privacy="secret", - permission="pull", + privacy=None, + permission=None, members=None, - enforce_mfa=False, - no_mfa_grace_seconds=0, profile="github", ): """ - Ensure a team is present. + Ensure a named team exists as specified. + Unspecified settings are left unmanaged. + + .. note:: + + Unlike the deprecated ``github.team_present`` state, this state + does not implement per-team MFA enforcement. Require 2FA for + the whole organization in its GitHub settings instead. name - The name of the team in the organization. + Name of the team in the organization. description - The description of the team. + Team description. repo_names - The names of repositories to add the team to. + List of repository names the team should have access to. + Unlisted repositories are removed from the team. privacy - The level of privacy for the team, can be ``secret`` or ``closed``. Defaults - to secret. + Privacy level for the team, either ``secret`` or ``closed``. permission - The default permission for new repositories added to the team, can be - ``pull``, ``push`` or ``admin``. Defaults to pull. + Default permission for new repositories added to the team, either + ``pull``, ``push`` or ``admin``. members - The members belonging to the team, specified as a dict of member name to - optional configuration. Options include ``enforce_mfa_from`` and ``mfa_exempt``. - - enforce_mfa - Whether to enforce MFA requirements on members of the team. If True then - all members without ``mfa_exempt: True`` configured will be removed from - the team. Note that ``no_mfa_grace_seconds`` may be set to allow members - a grace period. - - no_mfa_grace_seconds - The number of seconds of grace time that a member will have to enable MFA - before being removed from the team. The grace period will begin from - ``enforce_mfa_from`` on the member configuration, which defaults to - 1970/01/01. + Login handles of team members. Unlisted members are removed from the team. profile - The name of the profile configuration to use. Defaults to ``github``. - - Example: - - .. code-block:: yaml - - Ensure team test is present in github: - github_org.team_present: - - name: test - - members: - user1: {} - user2: {} - - Ensure team test_mfa is present in github: - github_org.team_present: - - name: test_mfa - - members: - user1: - enforce_mfa_from: 2016/06/15 - - enforce_mfa: True + Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} + ret = { + "name": name, + "changes": {}, + "result": True, + "comment": f"Team {name} is present as specified", + } try: - target = __salt__["github_org.get_team"](name, profile=profile) - except GitHubNotFoundError: - target = None - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get team {name}: {err}" - return ret - test_comments = [] - - if target: # Team already exists - parameters = {} - if description is not None and target["description"] != description: - parameters["description"] = description - if permission is not None and target["permission"] != permission: - parameters["permission"] = permission - if privacy is not None and target["privacy"] != privacy: - parameters["privacy"] = privacy - - if parameters: - team_change = { - "old": f"Team properties were {target}", - "new": f"Team properties (that changed) are {parameters}", - } - if __opts__["test"]: - test_comments.append(f"Team properties are set to be edited: {parameters}") - ret["result"] = None - ret["changes"]["team"] = team_change - else: - try: - __salt__["github_org.edit_team"](name, profile=profile, **parameters) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to update team properties: {err}" - return ret - ret["changes"]["team"] = team_change - - if not _team_converge_repos(ret, name, repo_names, test_comments, profile): + try: + target = __salt__["github_org.get_team"](name, profile=profile) + except GitHubNotFoundError: + target = None + + settings = filter_unset( + {"description": description, "privacy": privacy, "permission": permission} + ) + repos_added = repos_removed = members_added = members_removed = [] + + if target is None: + ret["changes"]["created"] = name + repos_added = sorted(repo_names or []) + members_added = sorted(members or []) + else: + diff = {key: val for key, val in settings.items() if target[key] != val} + if diff: + ret["changes"]["old"] = {key: target[key] for key in diff} + ret["changes"]["new"] = diff + if repo_names is not None: + current_repos = __salt__["github_org.list_team_repos"](name, profile=profile) + repos_added, repos_removed = _case_insensitive_diff(current_repos, repo_names) + if members is not None: + current_members = __salt__["github_org.list_team_members"](name, profile=profile) + members_added, members_removed = _case_insensitive_diff(current_members, members) + + for field, values in ( + ("repos_added", repos_added), + ("repos_removed", repos_removed), + ("members_added", members_added), + ("members_removed", members_removed), + ): + if values: + ret["changes"][field] = values + + if not ret["changes"]: return ret - else: # Team does not exist - it will be created. + verb = "created" if target is None else "updated" if __opts__["test"]: - # Note: member/repo changes are not projected for new teams - ret["comment"] = f"Team {name} is set to be created." - ret["changes"]["team"] = {"old": None, "new": f"Team {name} has been created"} ret["result"] = None + ret["comment"] = f"Team {name} would have been {verb}" return ret - try: + if target is None: __salt__["github_org.add_team"]( - name, - description=description, - repo_names=repo_names, - permission=permission, - privacy=privacy, - profile=profile, + name, repo_names=repo_names, profile=profile, **settings ) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to create team {name}: {err}" - return ret - ret["changes"]["team"] = {"old": None, "new": f"Team {name} has been created"} - - if not _team_converge_members( - ret, name, members, enforce_mfa, no_mfa_grace_seconds, test_comments, profile - ): - return ret - - if test_comments: - ret["comment"] = "\n".join(test_comments) + else: + if "new" in ret["changes"]: + __salt__["github_org.edit_team"](name, profile=profile, **ret["changes"]["new"]) + for repo_name in repos_added: + __salt__["github_org.add_team_repo"](repo_name, name, profile=profile) + for repo_name in repos_removed: + __salt__["github_org.remove_team_repo"](repo_name, name, profile=profile) + for member in members_added: + __salt__["github_org.add_team_member"](member, name, profile=profile) + for member in members_removed: + __salt__["github_org.remove_team_member"](member, name, profile=profile) + if members_added or members_removed: # Refresh team cache + __salt__["github_org.list_team_members"](name, profile=profile, ignore_cache=True) + ret["comment"] = f"Team {name} has been {verb}" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} return ret def team_absent(name, profile="github"): """ - Ensure a team is absent. - - Example: - - .. code-block:: yaml - - Ensure team test is absent in github: - github_org.team_absent: - - name: test + Ensure a named team does not exist. name - The name of the team in the organization. + Name of the team in the organization. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - __salt__["github_org.get_team"](name, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Team {name} does not exist" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get team {name}: {err}" - return ret + try: + __salt__["github_org.get_team"](name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Team {name} does not exist" + return ret - ret["changes"]["old"] = f"Team {name} exists" - ret["changes"]["new"] = f"Team {name} deleted" - if __opts__["test"]: - ret["comment"] = f"Team {name} would have been deleted" - ret["result"] = None - return ret + ret["changes"]["deleted"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Team {name} would have been deleted" + return ret - try: __salt__["github_org.remove_team"](name, profile=profile) + ret["comment"] = f"Team {name} has been deleted" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to delete team {name}: {err}" - return ret - ret["comment"] = f"Deleted team {name}" return ret def ruleset_present(name, ruleset_params=None, profile="github"): """ - Ensure an organization ruleset is present and configured as specified. + Ensure an organization ruleset exists as specified. Only the parameters included in ``ruleset_params`` are compared, server-populated fields are ignored. @@ -496,22 +278,22 @@ def ruleset_present(name, ruleset_params=None, profile="github"): .. code-block:: yaml Ensure ruleset test is present in the org: - github_org.ruleset_present: - - name: test - - ruleset_params: - target: branch - enforcement: active + github_org.ruleset_present: + - name: test + - ruleset_params: + target: branch + enforcement: active name - The name of the ruleset. + Ruleset name to manage. ruleset_params - The ruleset definition. Must contain at least ``enforcement`` - when the ruleset needs to be created. The ``name`` is set from + Ruleset definition. Must contain at least ``enforcement`` + when the ruleset needs to be created. ``name`` is pulled from this state's ``name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} @@ -520,106 +302,73 @@ def ruleset_present(name, ruleset_params=None, profile="github"): try: rulesets = __salt__["github_org.list_rulesets"](profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list rulesets: {err}" - return ret + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + + if ruleset is None: + ret["changes"]["created"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been created" + return ret + __salt__["github_org.add_ruleset"](ruleset_params, profile=profile) + ret["comment"] = f"Ruleset {name} has been created" + return ret + + ruleset_info = __salt__["github_org.get_ruleset"](ruleset["id"], profile=profile) - ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + # Only diff the parameters that are being managed. The server + # response carries additional server-populated fields (id, + # timestamps, source, ...) that must not register as drift. + diff = {key: val for key, val in ruleset_params.items() if ruleset_info.get(key) != val} + if not diff: + ret["comment"] = f"Ruleset {name} is already configured as specified" + return ret - if ruleset is None: - ret["changes"]["old"] = None - ret["changes"]["new"] = f"Ruleset {name} created" + ret["changes"]["old"] = {key: ruleset_info.get(key) for key in diff} + ret["changes"]["new"] = diff if __opts__["test"]: ret["result"] = None - ret["comment"] = f"Ruleset {name} would have been created" - return ret - try: - __salt__["github_org.add_ruleset"](ruleset_params, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to create ruleset {name}: {err}" + ret["comment"] = f"Ruleset {name} would have been updated" return ret - ret["comment"] = f"Ruleset {name} has been created" - return ret - try: - ruleset_info = __salt__["github_org.get_ruleset"](ruleset["id"], profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get ruleset {name}: {err}" - return ret - - # Only diff the parameters that are being managed. The server - # response carries additional server-populated fields (id, - # timestamps, source, ...) that must not register as drift. - diff = {key: val for key, val in ruleset_params.items() if ruleset_info.get(key) != val} - if not diff: - ret["comment"] = f"Ruleset {name} is already configured as specified" - return ret - - ret["changes"]["old"] = {key: ruleset_info.get(key) for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Ruleset {name} would have been updated" - return ret - try: __salt__["github_org.update_ruleset"](ruleset["id"], ruleset_params, profile=profile) + ret["comment"] = f"Ruleset {name} has been updated" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to update ruleset {name}: {err}" - return ret - ret["comment"] = f"Ruleset {name} has been updated" return ret def ruleset_absent(name, profile="github"): """ - Ensure an organization ruleset is absent. - - Example: - - .. code-block:: yaml - - Ensure ruleset test is absent in the org: - github_org.ruleset_absent: - - name: test + Ensure a named organization ruleset does not exist. name - The name of the ruleset. + Ruleset name to manage. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: rulesets = __salt__["github_org.list_rulesets"](profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list rulesets: {err}" - return ret - - ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) - if ruleset is None: - ret["comment"] = f"Ruleset {name} does not exist" - return ret - - ret["changes"]["old"] = f"Ruleset {name} exists" - ret["changes"]["new"] = f"Ruleset {name} deleted" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Ruleset {name} would have been deleted" - return ret - try: + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + if ruleset is None: + ret["comment"] = f"Ruleset {name} does not exist" + return ret + + ret["changes"]["deleted"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been deleted" + return ret + __salt__["github_org.delete_ruleset"](ruleset["id"], profile=profile) + ret["comment"] = f"Ruleset {name} has been deleted" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to delete ruleset {name}: {err}" - return ret - ret["comment"] = f"Deleted ruleset {name}" return ret diff --git a/src/saltext/github/states/github_repo.py b/src/saltext/github/states/github_repo.py index 10b9d026..63e9bbe9 100644 --- a/src/saltext/github/states/github_repo.py +++ b/src/saltext/github/states/github_repo.py @@ -14,122 +14,83 @@ """ import logging -import time from salt.exceptions import CommandExecutionError from saltext.github.utils import filter_unset +from saltext.github.utils.gh import environments as gh_environments from saltext.github.utils.gh.exceptions import GitHubNotFoundError -try: - from saltext.github.utils.gh import environments as gh_environments -except ImportError: # pragma: no cover - # PyGithub is missing; __virtual__ fails since the execution module - # is not loaded either. - gh_environments = None - log = logging.getLogger(__name__) __virtualname__ = "github_repo" -def __virtual__(): +def _differs(current, desired): """ - Only load if the github_repo module is available in __salt__ + Report whether a managed parameter needs updating. The identity check + makes boolean comparisons exact: since ``True == 1`` in Python, an + equality check alone would not register e.g. ``1`` -> ``True``. """ - if "github_repo.get_info" in __salt__: - return __virtualname__ - return (False, "github_repo module could not be loaded") # pragma: no cover + return current is not desired and current != desired -def _converge_teams(ret, name, teams, current_teams, profile): +def _repo_settings_diff(ret, target, managed): """ - Converge the teams a repository belongs to, recording changes in - ``ret``. Returns false when the state should abort. + Record the settings of an existing repository that need updating + in ``ret`` and return them. """ - current_team_names = {team["name"] for team in current_teams} - - # First remove any teams that aren't present - for team_name in current_team_names: - if team_name in teams: - continue - team_change = { - "old": f"Repo {name} is in team {team_name}", - "new": f"Repo {name} is not in team {team_name}", - } + update_params = { + param: value + for param, value in managed.items() + if value is not None and _differs(target[param], value) + } + if update_params: + ret["changes"]["old"] = {param: target[param] for param in update_params} + ret["changes"]["new"] = update_params + return update_params - if __opts__["test"]: - ret["changes"][team_name] = team_change - ret["result"] = None - else: - try: - __salt__["github_org.remove_team_repo"](name, team_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to remove repo {name} from team {team_name}: {err}" - return False - ret["changes"][team_name] = team_change - - # Next add or modify any necessary teams - for team_name, permission in teams.items(): - if team_name not in current_team_names: # Need to add repo to team - team_change = { - "old": f"Repo {name} is not in team {team_name}", - "new": f"Repo {name} is in team {team_name}", - } - if __opts__["test"]: - ret["changes"][team_name] = team_change - ret["result"] = None - else: - try: - __salt__["github_org.add_team_repo"]( - name, team_name, permission=permission, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to add repo {name} to team {team_name}: {err}" - return False - ret["changes"][team_name] = team_change - else: - try: - current_permission = ( - __salt__["github_org.list_team_repos"](team_name, profile=profile) - .get(name.lower(), {}) - .get("permission") - ) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = ( - f"Failed to determine current permission for team {team_name} " - f"in repo {name}: {err}" - ) - return False - if current_permission == permission: - continue - team_change = { - "old": f"Repo {name} in team {team_name} has permission {current_permission}", - "new": f"Repo {name} in team {team_name} has permission {permission}", - } - if __opts__["test"]: - ret["changes"][team_name] = team_change - ret["result"] = None - else: - try: - __salt__["github_org.add_team_repo"]( - name, team_name, permission=permission, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = ( - f"Failed to set permission on repo {name} " - f"for team {team_name} to {permission}: {err}" - ) - return False - ret["changes"][team_name] = team_change - return True + +def _repo_teams_diff(ret, name, teams, profile): + """ + Record the team association changes of an existing repository in + ``ret`` and return them. + """ + current_teams = set(__salt__["github_repo.get_teams"](repo_name=name, profile=profile)) + teams_added = sorted(set(teams) - current_teams) + teams_removed = sorted(current_teams - set(teams)) + team_permissions = {} + for team_name in sorted(set(teams) & current_teams): + team_repos = { + repo_name.lower(): repo + for repo_name, repo in __salt__["github_org.list_team_repos"]( + team_name, profile=profile + ).items() + } + current_permission = team_repos.get(name.lower(), {}).get("permission") + if current_permission != teams[team_name]: + team_permissions[team_name] = {"old": current_permission, "new": teams[team_name]} + if teams_added: + ret["changes"]["teams_added"] = teams_added + if teams_removed: + ret["changes"]["teams_removed"] = teams_removed + if team_permissions: + ret["changes"]["team_permissions"] = team_permissions + return teams_added, teams_removed, team_permissions + + +def _create_repo(name, create_params, teams, profile): + """ + Create a repository and associate the requested teams. + """ + __salt__["github_org.add_repo"](name, profile=profile, **create_params) + for team_name, permission in (teams or {}).items(): + __salt__["github_org.add_team_repo"]( + name, team_name, permission=permission, profile=profile + ) -def present( # pylint: disable=too-many-statements,too-many-branches +def present( name, description=None, homepage=None, @@ -146,179 +107,141 @@ def present( # pylint: disable=too-many-statements,too-many-branches ): """ Ensure a repository is present and configured as specified. + Unspecified settings are left unmanaged. name - The name of the repository. + Name of the repository. description - The description of the repository. + Repository description. homepage - The URL with more information about the repository. + URL with more information about the repository, e.g. rendered docs. private - The visiblity of the repository. Changing this on an existing - repository requires ``allow_repo_privacy_changes`` to be enabled - in the profile. + Repository visibility. Changing this on an existing repository + requires ``allow_repo_privacy_changes`` to be enabled in the profile. has_issues - Whether to enable issues for this repository. + Enable issues for this repository. has_wiki - Whether to enable the wiki for this repository. + Enable the wiki for this repository. has_downloads - Whether to enable downloads for this repository. + Enable downloads for this repository. auto_init - Whether to create an initial commit with an empty README - when creating the repository. + Create an initial commit with an empty README when creating + the repository. gitignore_template - The desired language or platform for a .gitignore, e.g "Haskell". + Desired language or platform for a .gitignore, e.g. "Haskell". Only applies when creating the repository. license_template - The desired LICENSE template to apply, e.g "mit" or "mozilla". + Desired LICENSE template to apply, e.g. "mit" or "mozilla". Only applies when creating the repository. default_branch - The default branch of the repository. The branch must already exist, + Default branch of the repository. The branch must already exist, so this is not applied when the repository is newly created. teams - The teams this repo should belong to, specified as a dict of - team name to permission (``pull``, ``push`` or ``admin``). + Teams this repo should belong to, specified as a dict of team + name to permission (``pull``, ``push`` or ``admin``). Unlisted teams are removed from the repository. profile - The name of the profile configuration to use. Defaults to ``github``. - - Example: - - .. code-block:: yaml - - Ensure repo my-repo is present in github: - github_repo.present: - - name: my-repo - - description: My very important repository + Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - # This is an optimization to cache all repos in the organization up front. - # The first use of this state will collect all of the repos and save a bunch - # of API calls for future use. - __salt__["github_org.list_repos"](profile=profile) - - try: - target = __salt__["github_repo.get_info"](repo_name=name, profile=profile) - except GitHubNotFoundError: - target = None - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get repo {name}: {err}" - return ret - - given_params = { - "description": description, - "homepage": homepage, - "private": private, - "has_issues": has_issues, - "has_wiki": has_wiki, - "has_downloads": has_downloads, - "auto_init": auto_init, - "gitignore_template": gitignore_template, - "license_template": license_template, - "default_branch": default_branch, + ret = { + "name": name, + "changes": {}, + "result": True, + "comment": f"Repo {name} is present as specified", } - # Keep track of current_teams if we've fetched them after creating a new repo - current_teams = None - - if target: # Repo already exists - # Some params are only valid on repo creation - ignore_params = ["auto_init", "gitignore_template", "license_template"] - parameters = {} - old_parameters = {} - for param_name, param_value in given_params.items(): - if ( - param_value is not None - and param_name not in ignore_params - and target[param_name] is not param_value - and target[param_name] != param_value - ): - parameters[param_name] = param_value - old_parameters[param_name] = target[param_name] + try: + # This is an optimization to cache all repos in the organization up front. + # The first use of this state will collect all of the repos and save a bunch + # of API calls for future use. + __salt__["github_org.list_repos"](profile=profile) - if parameters: - repo_change = { - "old": f"Repo properties were {old_parameters}", - "new": f"Repo properties (that changed) are {parameters}", - } - if __opts__["test"]: - ret["changes"]["repo"] = repo_change - ret["result"] = None - else: - try: - __salt__["github_repo.update"](repo_name=name, profile=profile, **parameters) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to update repo properties: {err}" - return ret - ret["changes"]["repo"] = repo_change - - else: # Repo does not exist - it will be created. - repo_change = {"old": None, "new": f"Repo {name} has been created"} - if __opts__["test"]: - ret["changes"]["repo"] = repo_change - ret["result"] = None + try: + target = __salt__["github_repo.get_info"](repo_name=name, profile=profile) + except GitHubNotFoundError: + target = None + + update_params = {} + teams_added, teams_removed, team_permissions = [], [], {} + if target is None: + ret["changes"]["created"] = name + if teams: + ret["changes"]["teams_added"] = sorted(teams) else: - add_params = { - param: value - for param, value in given_params.items() - # The default branch can only be switched to an existing branch - if param != "default_branch" - } - try: - __salt__["github_org.add_repo"](name, profile=profile, **add_params) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to create repo {name}: {err}" - return ret - - # Turns out that trying to fetch teams for a new repo can 404 immediately - # after repo creation, so this waits until we can fetch teams successfully - # before continuing. - for attempt in range(3): - time.sleep(1) - try: - current_teams = __salt__["github_repo.get_teams"]( - repo_name=name, profile=profile - ) - break - except CommandExecutionError: - log.info("Attempt %s to fetch new repo %s failed", attempt, name) + update_params = _repo_settings_diff( + ret, + target, + { + "description": description, + "homepage": homepage, + "private": private, + "has_issues": has_issues, + "has_wiki": has_wiki, + "has_downloads": has_downloads, + "default_branch": default_branch, + }, + ) + if teams is not None: + teams_added, teams_removed, team_permissions = _repo_teams_diff( + ret, name, teams, profile + ) - if current_teams is None: - ret["result"] = False - ret["comment"] = f"Failed to verify repo {name} after creation." - return ret + if not ret["changes"]: + return ret - ret["changes"]["repo"] = repo_change - - if teams is not None: - if __opts__["test"] and not target: - # Assume no teams if we're in test mode and the repo doesn't exist - current_teams = [] - elif current_teams is None: - try: - current_teams = __salt__["github_repo.get_teams"](repo_name=name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get teams of repo {name}: {err}" - return ret - if not _converge_teams(ret, name, teams, current_teams, profile): + verb = "created" if target is None else "updated" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Repo {name} would have been {verb}" return ret + + if target is None: + _create_repo( + name, + { + "description": description, + "homepage": homepage, + "private": private, + "has_issues": has_issues, + "has_wiki": has_wiki, + "has_downloads": has_downloads, + "auto_init": auto_init, + "gitignore_template": gitignore_template, + "license_template": license_template, + }, + teams, + profile, + ) + else: + if update_params: + __salt__["github_repo.update"](repo_name=name, profile=profile, **update_params) + for team_name in teams_added: + __salt__["github_org.add_team_repo"]( + name, team_name, permission=teams[team_name], profile=profile + ) + for team_name in teams_removed: + __salt__["github_org.remove_team_repo"](name, team_name, profile=profile) + for team_name in team_permissions: + __salt__["github_org.add_team_repo"]( + name, team_name, permission=teams[team_name], profile=profile + ) + ret["comment"] = f"Repo {name} has been {verb}" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} return ret @@ -326,50 +249,33 @@ def absent(name, profile="github"): """ Ensure a repository is absent. - Example: - - .. code-block:: yaml - - Ensure repo test is absent in github: - github_repo.absent: - - name: test - name - The name of the repository in the organization. + Name of the repository in the organization. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - __salt__["github_repo.get_info"](repo_name=name, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Repo {name} does not exist" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get repo {name}: {err}" - return ret + try: + __salt__["github_repo.get_info"](repo_name=name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Repo {name} does not exist" + return ret - ret["changes"]["old"] = f"Repo {name} exists" - ret["changes"]["new"] = f"Repo {name} deleted" - if __opts__["test"]: - ret["comment"] = f"Repo {name} would have been deleted" - ret["result"] = None - return ret + ret["changes"]["deleted"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Repo {name} would have been deleted" + return ret - try: __salt__["github_repo.delete"](repo_name=name, profile=profile) + ret["comment"] = f"Repo {name} has been deleted" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = ( - f"Failed to delete repo {name}: {err}. " - "Ensure the delete_repo scope is enabled if using OAuth." - ) - return ret - ret["comment"] = f"Deleted repo {name}" return ret @@ -384,27 +290,26 @@ def ruleset_present(name, ruleset_params=None, repo_name=None, profile="github") .. code-block:: yaml Ensure ruleset test is present in my-repo: - github_repo.ruleset_present: - - name: test - - repo_name: my-repo - - ruleset_params: - target: branch - enforcement: active + github_repo.ruleset_present: + - name: test + - repo_name: my-repo + - ruleset_params: + target: branch + enforcement: active name - The name of the ruleset. + Ruleset name to manage. ruleset_params - The ruleset definition. Must contain at least ``enforcement`` - when the ruleset needs to be created. The ``name`` is set from + Ruleset definition. Must contain at least ``enforcement`` + when the ruleset needs to be created. ``name`` is pulled from this state's ``name``. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} @@ -413,65 +318,47 @@ def ruleset_present(name, ruleset_params=None, repo_name=None, profile="github") try: rulesets = __salt__["github_repo.list_rulesets"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list rulesets: {err}" - return ret - - ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) - if ruleset is None: - ret["changes"]["old"] = None - ret["changes"]["new"] = f"Ruleset {name} created" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Ruleset {name} would have been created" - return ret - try: + if ruleset is None: + ret["changes"]["created"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been created" + return ret __salt__["github_repo.add_ruleset"]( ruleset_params, repo_name=repo_name, profile=profile ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to create ruleset {name}: {err}" + ret["comment"] = f"Ruleset {name} has been created" return ret - ret["comment"] = f"Ruleset {name} has been created" - return ret - try: ruleset_info = __salt__["github_repo.get_ruleset"]( ruleset["id"], repo_name=repo_name, profile=profile ) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get ruleset {name}: {err}" - return ret - - # Only diff the parameters that are being managed. The server - # response carries additional server-populated fields (id, - # timestamps, source, ...) that must not register as drift. - diff = {key: val for key, val in ruleset_params.items() if ruleset_info.get(key) != val} - if not diff: - ret["comment"] = f"Ruleset {name} is already configured as specified" - return ret - - ret["changes"]["old"] = {key: ruleset_info.get(key) for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Ruleset {name} would have been updated" - return ret - try: + + # Only diff the parameters that are being managed. The server + # response carries additional server-populated fields (id, + # timestamps, source, ...) that must not register as drift. + diff = {key: val for key, val in ruleset_params.items() if ruleset_info.get(key) != val} + if not diff: + ret["comment"] = f"Ruleset {name} is already configured as specified" + return ret + + ret["changes"]["old"] = {key: ruleset_info.get(key) for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been updated" + return ret + __salt__["github_repo.update_ruleset"]( ruleset["id"], ruleset_params, repo_name=repo_name, profile=profile ) + ret["comment"] = f"Ruleset {name} has been updated" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to update ruleset {name}: {err}" - return ret - ret["comment"] = f"Ruleset {name} has been updated" return ret @@ -479,53 +366,36 @@ def ruleset_absent(name, repo_name=None, profile="github"): """ Ensure a repository ruleset is absent. - Example: - - .. code-block:: yaml - - Ensure ruleset test is absent in my-repo: - github_repo.ruleset_absent: - - name: test - - repo_name: my-repo - name - The name of the ruleset. + Ruleset name to manage. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: rulesets = __salt__["github_repo.list_rulesets"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list rulesets: {err}" - return ret - - ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) - if ruleset is None: - ret["comment"] = f"Ruleset {name} does not exist" - return ret - - ret["changes"]["old"] = f"Ruleset {name} exists" - ret["changes"]["new"] = f"Ruleset {name} deleted" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Ruleset {name} would have been deleted" - return ret - try: + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + if ruleset is None: + ret["comment"] = f"Ruleset {name} does not exist" + return ret + + ret["changes"]["deleted"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been deleted" + return ret + __salt__["github_repo.delete_ruleset"](ruleset["id"], repo_name=repo_name, profile=profile) + ret["comment"] = f"Ruleset {name} has been deleted" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to delete ruleset {name}: {err}" - return ret - ret["comment"] = f"Deleted ruleset {name}" return ret @@ -551,81 +421,61 @@ def deploy_key_present(name, key, read_only=False, repo_name=None, profile="gith .. code-block:: yaml Autorelease deploy key is present: - github_repo.deploy_key_present: - - name: autorelease - - key: ssh-ed25519 AAAA... - - repo_name: my-repo + github_repo.deploy_key_present: + - name: autorelease + - key: ssh-ed25519 AAAA... + - repo_name: my-repo name - The display title of the deploy key. + Display title of the deploy key. key - The SSH public key. + SSH public key. read_only Deny write access to the repository for this key. Defaults to false. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: keys = __salt__["github_repo.list_deploy_keys"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list deploy keys: {err}" - return ret + current = next((cur for cur in keys if cur["title"] == name), None) - current = next((cur for cur in keys if cur["title"] == name), None) + if current is not None: + if _key_body(current["key"]) == _key_body(key) and bool(current["read_only"]) is bool( + read_only + ): + ret["comment"] = f"Deploy key {name} is present as specified" + return ret + ret["changes"]["old"] = {"key": current["key"], "read_only": current["read_only"]} + ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} + else: + ret["changes"]["created"] = name - if current is not None: - if _key_body(current["key"]) == _key_body(key) and bool(current["read_only"]) is bool( - read_only - ): - ret["comment"] = f"Deploy key {name} is already present" - return ret - ret["changes"]["old"] = {"key": current["key"], "read_only": current["read_only"]} - ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} + verb = "added" if current is None else "replaced" if __opts__["test"]: ret["result"] = None - ret["comment"] = f"Deploy key {name} would have been replaced" + ret["comment"] = f"Deploy key {name} would have been {verb}" return ret - try: + + if current is not None: __salt__["github_repo.remove_deploy_key"]( current["id"], repo_name=repo_name, profile=profile ) - __salt__["github_repo.add_deploy_key"]( - name, key, read_only=read_only, repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to replace deploy key {name}: {err}" - return ret - ret["comment"] = f"Deploy key {name} has been replaced" - return ret - - ret["changes"]["old"] = None - ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Deploy key {name} would have been added" - return ret - try: __salt__["github_repo.add_deploy_key"]( name, key, read_only=read_only, repo_name=repo_name, profile=profile ) + ret["comment"] = f"Deploy key {name} has been {verb}" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to add deploy key {name}: {err}" - return ret - ret["comment"] = f"Deploy key {name} has been added" return ret @@ -634,45 +484,37 @@ def deploy_key_absent(name, repo_name=None, profile="github"): Ensure no deploy key with this title is present in a repository. name - The display title of the deploy key. + Display title of the deploy key. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: keys = __salt__["github_repo.list_deploy_keys"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list deploy keys: {err}" - return ret - - current = next((cur for cur in keys if cur["title"] == name), None) - if current is None: - ret["comment"] = f"Deploy key {name} is already absent" - return ret - - ret["changes"]["old"] = f"Deploy key {name} exists" - ret["changes"]["new"] = f"Deploy key {name} removed" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Deploy key {name} would have been removed" - return ret - try: + current = next((cur for cur in keys if cur["title"] == name), None) + if current is None: + ret["comment"] = f"Deploy key {name} is already absent" + return ret + + ret["changes"]["deleted"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been removed" + return ret + __salt__["github_repo.remove_deploy_key"]( current["id"], repo_name=repo_name, profile=profile ) + ret["comment"] = f"Deploy key {name} has been removed" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to remove deploy key {name}: {err}" - return ret - ret["comment"] = f"Deploy key {name} has been removed" return ret @@ -685,11 +527,11 @@ def actions_secret_present(name, value=None, force=False, repo_name=None, profil Set ``force`` to push the value on every run. name - The name of the secret. GitHub treats secret names + Name of the secret. GitHub treats secret names case-insensitively. value - The plaintext secret value. It is encrypted to the repository + Plaintext secret value. It is encrypted to the repository public key before leaving the minion and never appears in the state output. Required when the secret does not exist yet or ``force`` is set. @@ -699,58 +541,47 @@ def actions_secret_present(name, value=None, force=False, repo_name=None, profil Defaults to false. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: secrets = __salt__["github_repo.list_actions_secrets"](repo_name=repo_name, profile=profile) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list Actions secrets: {err}" - return ret + current = next((sec for sec in secrets if sec.lower() == name.lower()), None) + + if current is not None and not force: + ret["comment"] = ( + f"Secret {name} is already present. Note that secret values are " + "write-only and cannot be verified, set force to push the value." + ) + return ret - current = next((sec for sec in secrets if sec["name"].lower() == name.lower()), None) + if value is None: + raise CommandExecutionError(f"A value is required to set secret {name}") - if current is not None and not force: - ret["comment"] = ( - f"Secret {name} is already present. Note that secret values are " - "write-only and cannot be verified, set force to push the value." - ) - return ret + if current is None: + ret["changes"]["created"] = name + verb = "created" + else: + ret["changes"]["updated"] = name + verb = "updated" + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Secret {name} would have been {verb}" + return ret - if value is None: - ret["result"] = False - ret["comment"] = f"A value is required to set secret {name}" - return ret - - if current is None: - ret["changes"]["old"] = None - ret["changes"]["new"] = f"Secret {name} set" - verb = "created" - else: - ret["changes"]["old"] = f"Secret {name} exists" - ret["changes"]["new"] = f"Secret {name} updated" - verb = "updated" - - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Secret {name} would have been {verb}" - return ret - try: __salt__["github_repo.set_actions_secret"]( name, value, repo_name=repo_name, profile=profile ) + ret["comment"] = f"Secret {name} has been {verb}" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to set secret {name}: {err}" - return ret - ret["comment"] = f"Secret {name} has been {verb}" return ret @@ -759,46 +590,36 @@ def actions_secret_absent(name, repo_name=None, profile="github"): Ensure an Actions secret is absent from a repository. name - The name of the secret. GitHub treats secret names + Name of the secret. GitHub treats secret names case-insensitively. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: secrets = __salt__["github_repo.list_actions_secrets"](repo_name=repo_name, profile=profile) + current = next((sec for sec in secrets if sec.lower() == name.lower()), None) + if current is None: + ret["comment"] = f"Secret {name} is already absent" + return ret + + ret["changes"]["deleted"] = current + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Secret {name} would have been removed" + return ret + + __salt__["github_repo.remove_actions_secret"](current, repo_name=repo_name, profile=profile) + ret["comment"] = f"Secret {name} has been removed" except CommandExecutionError as err: ret["result"] = False - ret["comment"] = f"Failed to list Actions secrets: {err}" - return ret - - current = next((sec for sec in secrets if sec["name"].lower() == name.lower()), None) - if current is None: - ret["comment"] = f"Secret {name} is already absent" - return ret - - ret["changes"]["old"] = f"Secret {name} exists" - ret["changes"]["new"] = f"Secret {name} removed" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Secret {name} would have been removed" - return ret - try: - __salt__["github_repo.remove_actions_secret"]( - current["name"], repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to remove secret {name}: {err}" - return ret - ret["comment"] = f"Secret {name} has been removed" return ret @@ -813,76 +634,58 @@ def workflow_permissions_managed( Manage the default workflow permissions granted to the ``GITHUB_TOKEN`` in a repository. - Example: - - .. code-block:: yaml - - Actions tokens are restricted in my-repo: - github_repo.workflow_permissions_managed: - - name: my-repo - - default_workflow_permissions: read - - can_approve_pull_request_reviews: true - name - The name of the repository, unless ``repo_name`` is passed. + Name of the repository, unless ``repo_name`` is passed. default_workflow_permissions - The default permissions for the ``GITHUB_TOKEN``, either ``read`` + Default permissions for the ``GITHUB_TOKEN``, either ``read`` or ``write``. can_approve_pull_request_reviews - Whether GitHub Actions can approve pull requests. + Allow GitHub Actions to approve pull requests. repo_name - The name of the repository, if it differs from ``name``. + Name of the repository, if it differs from ``name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} repo_name = repo_name or name - desired = filter_unset( - { - "default_workflow_permissions": default_workflow_permissions, - "can_approve_pull_request_reviews": can_approve_pull_request_reviews, - } - ) - if not desired: - ret["result"] = False - ret["comment"] = "At least one setting to manage must be specified" - return ret - try: + desired = filter_unset( + { + "default_workflow_permissions": default_workflow_permissions, + "can_approve_pull_request_reviews": can_approve_pull_request_reviews, + } + ) + if not desired: + raise CommandExecutionError("At least one setting to manage must be specified") + current = __salt__["github_repo.get_workflow_permissions"]( repo_name=repo_name, profile=profile ) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get workflow permissions: {err}" - return ret - - diff = {key: val for key, val in desired.items() if current.get(key) != val} - if not diff: - ret["comment"] = "Workflow permissions are already set as specified" - return ret - - ret["changes"]["old"] = {key: current.get(key) for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = "Workflow permissions would have been updated" - return ret - try: + diff = {key: val for key, val in desired.items() if current.get(key) != val} + if not diff: + ret["comment"] = "Workflow permissions are already set as specified" + return ret + + ret["changes"]["old"] = {key: current.get(key) for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = "Workflow permissions would have been updated" + return ret + __salt__["github_repo.set_workflow_permissions"]( repo_name=repo_name, profile=profile, **diff ) + ret["comment"] = "Workflow permissions have been updated" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to set workflow permissions: {err}" - return ret - ret["comment"] = "Workflow permissions have been updated" return ret @@ -903,52 +706,29 @@ def _desired_branch_policy(deployment_branches): ) -def _converge_branch_policies(ret, name, deployment_branches, *, env_exists, repo_name, profile): +def _environment_settings_diff(ret, current_settings, desired): """ - Converge an environment's deployment branch name patterns, recording - changes in ``ret``. Returns false when the state should abort. + Record an existing environment's settings that need updating in + ``ret`` and return whether there are any. """ - current_policies = [] - if env_exists: - try: - current_policies = __salt__["github_repo.list_deployment_branch_policies"]( - name, repo_name=repo_name, profile=profile - ) - except GitHubNotFoundError: - current_policies = [] - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to list deployment branch policies: {err}" - return False - current_patterns = {policy["name"] for policy in current_policies} - desired_patterns = set(deployment_branches) - if current_patterns == desired_patterns: - return True - ret["changes"]["deployment_branches"] = { - "old": sorted(current_patterns), - "new": sorted(desired_patterns), - } - if __opts__["test"]: - ret["result"] = None - return True - try: - for pattern in desired_patterns - current_patterns: - __salt__["github_repo.add_deployment_branch_policy"]( - name, pattern, repo_name=repo_name, profile=profile + diff = {} + for key, val in desired.items(): + current_val = current_settings.get(key) + if key == "reviewers": + matches = sorted((reviewer["type"], reviewer["id"]) for reviewer in val) == sorted( + (reviewer["type"], reviewer["id"]) for reviewer in current_val ) - for policy in current_policies: - if policy["name"] not in desired_patterns: - __salt__["github_repo.remove_deployment_branch_policy"]( - name, policy["id"], repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to converge deployment branch policies: {err}" - return False - return True + else: + matches = current_val == val + if not matches: + diff[key] = val + if diff: + ret["changes"]["old"] = {key: current_settings.get(key) for key in diff} + ret["changes"]["new"] = diff + return bool(diff) -def environment_present( # pylint: disable=too-many-statements,too-many-branches +def environment_present( name, wait_timer=None, prevent_self_review=None, @@ -961,30 +741,18 @@ def environment_present( # pylint: disable=too-many-statements,too-many-branche Ensure an environment is present in a repository and configured as specified. Unspecified settings are left unmanaged. - Example: - - .. code-block:: yaml - - Release environment is present: - github_repo.environment_present: - - name: release - - repo_name: my-repo - - deployment_branches: - - main - name - The name of the environment. + Name of the environment. wait_timer - The amount of time to delay a job after the job is initially - triggered, in minutes. + Amount of time to delay a job after it is initially triggered, + in minutes. prevent_self_review - Whether or not a user who created the job is prevented from - approving their own job. + Prevent a user who created a job from approving it. reviewers - The people or teams that may review jobs that reference the + People or teams that may review jobs that reference the environment, as a list of mappings with ``type`` (``User`` or ``Team``) and ``id``. @@ -994,94 +762,94 @@ def environment_present( # pylint: disable=too-many-statements,too-many-branche of branch name patterns. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} + ret = { + "name": name, + "changes": {}, + "result": True, + "comment": f"Environment {name} is configured as specified", + } try: - current = __salt__["github_repo.get_environment"]( - name, repo_name=repo_name, profile=profile - ) - except GitHubNotFoundError: - current = None - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get environment {name}: {err}" - return ret - - desired = filter_unset( - { - "wait_timer": wait_timer, - "prevent_self_review": prevent_self_review, - "reviewers": reviewers, - } - ) - if deployment_branches is not None: try: + current = __salt__["github_repo.get_environment"]( + name, repo_name=repo_name, profile=profile + ) + except GitHubNotFoundError: + current = None + + desired = filter_unset( + { + "wait_timer": wait_timer, + "prevent_self_review": prevent_self_review, + "reviewers": reviewers, + } + ) + if deployment_branches is not None: desired["deployment_branch_policy"] = _desired_branch_policy(deployment_branches) - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) + + settings_diff = False + branches_added, branches_removed, current_policies = [], [], [] + if current is None: + ret["changes"]["created"] = name + set_kwargs = desired + if isinstance(deployment_branches, list): + branches_added = sorted(set(deployment_branches)) + else: + current_settings = gh_environments.normalize(current) + settings_diff = _environment_settings_diff(ret, current_settings, desired) + # The API call replaces the whole configuration, so settings + # that are not managed here need to be carried over. + set_kwargs = {**current_settings, **desired} + if isinstance(deployment_branches, list): + try: + current_policies = __salt__["github_repo.list_deployment_branch_policies"]( + name, repo_name=repo_name, profile=profile + ) + except GitHubNotFoundError: + # Not found when the current policy type is not "custom" + current_policies = [] + current_patterns = {policy["name"] for policy in current_policies} + branches_added = sorted(set(deployment_branches) - current_patterns) + branches_removed = sorted(current_patterns - set(deployment_branches)) + + if branches_added or branches_removed: + ret["changes"]["deployment_branches"] = { + "added": branches_added, + "removed": branches_removed, + } + + if not ret["changes"]: return ret - env_diff = {} - if current is None: - ret["changes"]["environment"] = {"old": None, "new": f"Environment {name} created"} - set_kwargs = dict(desired) - else: - current_settings = gh_environments.normalize(current) - for key, val in desired.items(): - current_val = current_settings.get(key) - if key == "reviewers": - matches = sorted((reviewer["type"], reviewer["id"]) for reviewer in val) == sorted( - (reviewer["type"], reviewer["id"]) for reviewer in current_val - ) - else: - matches = current_val == val - if not matches: - env_diff[key] = val - ret["changes"][key] = {"old": current_val, "new": val} - # The API call replaces the whole configuration, so settings - # that are not managed here need to be carried over. - set_kwargs = {**current_settings, **desired} - - if env_diff or current is None: - if not __opts__["test"]: - try: - __salt__["github_repo.set_environment"]( - name, - repo_name=repo_name, - profile=profile, - **set_kwargs, - ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to configure environment {name}: {err}" - return ret + verb = "created" if current is None else "updated" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Environment {name} would have been {verb}" + return ret - # Converge the branch name patterns for custom branch policies - if isinstance(deployment_branches, list) and not _converge_branch_policies( - ret, - name, - deployment_branches, - env_exists=current is not None, - repo_name=repo_name, - profile=profile, - ): - return ret - - if not ret["changes"]: - ret["comment"] = f"Environment {name} is already configured as specified" - elif __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Environment {name} would have been configured" - else: - ret["comment"] = f"Environment {name} has been configured" + if current is None or settings_diff: + __salt__["github_repo.set_environment"]( + name, repo_name=repo_name, profile=profile, **set_kwargs + ) + for pattern in branches_added: + __salt__["github_repo.add_deployment_branch_policy"]( + name, pattern, repo_name=repo_name, profile=profile + ) + for policy in current_policies: + if policy["name"] in branches_removed: + __salt__["github_repo.remove_deployment_branch_policy"]( + name, policy["id"], repo_name=repo_name, profile=profile + ) + ret["comment"] = f"Environment {name} has been {verb}" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} return ret @@ -1090,41 +858,35 @@ def environment_absent(name, repo_name=None, profile="github"): Ensure an environment is absent from a repository. name - The name of the environment. + Name of the environment. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - __salt__["github_repo.get_environment"](name, repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Environment {name} is already absent" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get environment {name}: {err}" - return ret - - ret["changes"]["old"] = f"Environment {name} exists" - ret["changes"]["new"] = f"Environment {name} removed" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Environment {name} would have been removed" - return ret - try: + try: + __salt__["github_repo.get_environment"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Environment {name} is already absent" + return ret + + ret["changes"]["deleted"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Environment {name} would have been removed" + return ret + __salt__["github_repo.remove_environment"](name, repo_name=repo_name, profile=profile) + ret["comment"] = f"Environment {name} has been removed" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to remove environment {name}: {err}" - return ret - ret["comment"] = f"Environment {name} has been removed" return ret @@ -1143,76 +905,64 @@ def label_present(name, color=None, description=None, repo_name=None, profile="g specified. Unspecified settings are left unmanaged. name - The name of the label. + Name of the label. color - The hexadecimal color code for the label. A leading ``#`` + Hexadecimal color code for the label. A leading ``#`` is accepted. description - A short description of the label. + Short description of the label. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - current = __salt__["github_repo.get_label"](name, repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - current = None - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get label {name}: {err}" - return ret - - if current is None: - ret["changes"]["old"] = None - ret["changes"]["new"] = f"Label {name} created" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Label {name} would have been created" - return ret try: + current = __salt__["github_repo.get_label"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + current = None + + if current is None: + ret["changes"]["created"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Label {name} would have been created" + return ret __salt__["github_repo.add_label"]( name, color=color, description=description, repo_name=repo_name, profile=profile ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to create label {name}: {err}" + ret["comment"] = f"Label {name} has been created" + return ret + + diff = {} + if color is not None and _normalize_color(current["color"]) != _normalize_color(color): + diff["color"] = color + if description is not None and current.get("description") != description: + diff["description"] = description + + if not diff: + ret["comment"] = f"Label {name} is already configured as specified" + return ret + + ret["changes"]["old"] = {key: current.get(key) for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Label {name} would have been updated" return ret - ret["comment"] = f"Label {name} has been created" - return ret - diff = {} - if color is not None and _normalize_color(current["color"]) != _normalize_color(color): - diff["color"] = color - if description is not None and current.get("description") != description: - diff["description"] = description - - if not diff: - ret["comment"] = f"Label {name} is already configured as specified" - return ret - - ret["changes"]["old"] = {key: current.get(key) for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Label {name} would have been updated" - return ret - try: __salt__["github_repo.edit_label"](name, repo_name=repo_name, profile=profile, **diff) + ret["comment"] = f"Label {name} has been updated" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to update label {name}: {err}" - return ret - ret["comment"] = f"Label {name} has been updated" return ret @@ -1221,41 +971,35 @@ def label_absent(name, repo_name=None, profile="github"): Ensure a label is absent from a repository. name - The name of the label. + Name of the label. repo_name - The name of the repository. This argument is required, either passed - here or defined in the configured profile. + Name of the repository. Defaults to the profile's ``repo_name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - __salt__["github_repo.get_label"](name, repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Label {name} is already absent" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get label {name}: {err}" - return ret - - ret["changes"]["old"] = f"Label {name} exists" - ret["changes"]["new"] = f"Label {name} removed" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Label {name} would have been removed" - return ret - try: + try: + __salt__["github_repo.get_label"](name, repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Label {name} is already absent" + return ret + + ret["changes"]["deleted"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Label {name} would have been removed" + return ret + __salt__["github_repo.remove_label"](name, repo_name=repo_name, profile=profile) + ret["comment"] = f"Label {name} has been removed" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to remove label {name}: {err}" - return ret - ret["comment"] = f"Label {name} has been removed" return ret @@ -1264,6 +1008,7 @@ def pages_present( build_type=None, source_branch=None, source_path=None, + cname=None, https_enforced=None, repo_name=None, profile="github", @@ -1272,20 +1017,11 @@ def pages_present( Ensure GitHub Pages are enabled for a repository and configured as specified. Unspecified settings are left unmanaged. - Example: - - .. code-block:: yaml - - Pages are deployed via Actions in my-repo: - github_repo.pages_present: - - name: my-repo - - build_type: workflow - name - The name of the repository, unless ``repo_name`` is passed. + Name of the repository, unless ``repo_name`` is passed. build_type - The process by which the site is built, either ``legacy`` + Process by which the site is built, either ``legacy`` (deploy from a branch) or ``workflow`` (GitHub Actions). source_branch @@ -1295,35 +1031,33 @@ def pages_present( For ``legacy`` builds, the repository directory to publish from, either ``/`` or ``/docs``. + cname + Custom domain for the site. + https_enforced - Whether HTTPS is enforced for the site. + Enforce HTTPS for the site. repo_name - The name of the repository, if it differs from ``name``. + Name of the repository, if it differs from ``name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} repo_name = repo_name or name try: - current = __salt__["github_repo.get_pages"](repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - current = None - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get Pages configuration: {err}" - return ret - - if current is None: - ret["changes"]["old"] = None - ret["changes"]["new"] = "Pages site created" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Pages would have been enabled for {repo_name}" - return ret try: + current = __salt__["github_repo.get_pages"](repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + current = None + + if current is None: + ret["changes"]["created"] = repo_name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Pages would have been enabled for {repo_name}" + return ret __salt__["github_repo.setup_pages"]( build_type=build_type, source_branch=source_branch, @@ -1331,59 +1065,56 @@ def pages_present( repo_name=repo_name, profile=profile, ) - if https_enforced is not None: - __salt__["github_repo.update_pages"]( - https_enforced=https_enforced, repo_name=repo_name, profile=profile - ) - except CommandExecutionError as err: - ret["result"] = False - ret["changes"] = {} - ret["comment"] = f"Failed to enable Pages for {repo_name}: {err}" + if cname is not None or https_enforced is not None: + extra = {"https_enforced": https_enforced} + if cname is not None: + extra["cname"] = cname + __salt__["github_repo.update_pages"](repo_name=repo_name, profile=profile, **extra) + ret["comment"] = f"Pages have been enabled for {repo_name}" return ret - ret["comment"] = f"Pages have been enabled for {repo_name}" - return ret - - current_source = current.get("source") or {} - desired = filter_unset( - { - "build_type": build_type, - "source_branch": source_branch, - "source_path": source_path, - "https_enforced": https_enforced, + + current_source = current.get("source") or {} + desired = filter_unset( + { + "build_type": build_type, + "source_branch": source_branch, + "source_path": source_path, + "cname": cname, + "https_enforced": https_enforced, + } + ) + current_flat = { + "build_type": current.get("build_type"), + "source_branch": current_source.get("branch"), + "source_path": current_source.get("path"), + "cname": current.get("cname"), + "https_enforced": current.get("https_enforced"), } - ) - current_flat = { - "build_type": current.get("build_type"), - "source_branch": current_source.get("branch"), - "source_path": current_source.get("path"), - "https_enforced": current.get("https_enforced"), - } - diff = {key: val for key, val in desired.items() if current_flat[key] != val} - - if not diff: - ret["comment"] = f"Pages for {repo_name} are already configured as specified" - return ret - - ret["changes"]["old"] = {key: current_flat[key] for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Pages configuration for {repo_name} would have been updated" - return ret - update_kwargs = dict(diff) - if "source_branch" in diff or "source_path" in diff: - # The source is submitted as a single object, carry over the - # unchanged half - update_kwargs.setdefault("source_branch", current_flat["source_branch"]) - update_kwargs.setdefault("source_path", current_flat["source_path"]) - try: + diff = {key: val for key, val in desired.items() if current_flat[key] != val} + + if not diff: + ret["comment"] = f"Pages for {repo_name} are already configured as specified" + return ret + + ret["changes"]["old"] = {key: current_flat[key] for key in diff} + ret["changes"]["new"] = diff + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Pages configuration for {repo_name} would have been updated" + return ret + + update_kwargs = dict(diff) + if "source_branch" in diff or "source_path" in diff: + # The source is submitted as a single object, carry over the + # unchanged half + update_kwargs.setdefault("source_branch", current_flat["source_branch"]) + update_kwargs.setdefault("source_path", current_flat["source_path"]) __salt__["github_repo.update_pages"](repo_name=repo_name, profile=profile, **update_kwargs) + ret["comment"] = f"Pages configuration for {repo_name} has been updated" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to update Pages configuration for {repo_name}: {err}" - return ret - ret["comment"] = f"Pages configuration for {repo_name} has been updated" return ret @@ -1392,39 +1123,34 @@ def pages_absent(name, repo_name=None, profile="github"): Ensure GitHub Pages are disabled for a repository. name - The name of the repository, unless ``repo_name`` is passed. + Name of the repository, unless ``repo_name`` is passed. repo_name - The name of the repository, if it differs from ``name``. + Name of the repository, if it differs from ``name``. profile - The name of the profile configuration to use. Defaults to ``github``. + Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} repo_name = repo_name or name try: - __salt__["github_repo.get_pages"](repo_name=repo_name, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Pages are already disabled for {repo_name}" - return ret - except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = f"Failed to get Pages configuration: {err}" - return ret - - ret["changes"]["old"] = f"Pages enabled for {repo_name}" - ret["changes"]["new"] = f"Pages disabled for {repo_name}" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Pages would have been disabled for {repo_name}" - return ret - try: + try: + __salt__["github_repo.get_pages"](repo_name=repo_name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Pages are already disabled for {repo_name}" + return ret + + ret["changes"]["deleted"] = repo_name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Pages would have been disabled for {repo_name}" + return ret + __salt__["github_repo.remove_pages"](repo_name=repo_name, profile=profile) + ret["comment"] = f"Pages have been disabled for {repo_name}" except CommandExecutionError as err: ret["result"] = False + ret["comment"] = str(err) ret["changes"] = {} - ret["comment"] = f"Failed to disable Pages for {repo_name}: {err}" - return ret - ret["comment"] = f"Pages have been disabled for {repo_name}" return ret diff --git a/src/saltext/github/utils/__init__.py b/src/saltext/github/utils/__init__.py index abf8be4a..d302eb81 100644 --- a/src/saltext/github/utils/__init__.py +++ b/src/saltext/github/utils/__init__.py @@ -2,6 +2,8 @@ from collections.abc import Mapping from types import EllipsisType +from salt.exceptions import CommandExecutionError + K = typing.TypeVar("K") V = typing.TypeVar("V") @@ -38,3 +40,33 @@ def filter_unset( a string literal (not recommended though). """ return {k: v for k, v in data.items() if v is not unset} + + +def filter_fields(data, fields): + """ + Reduce the entities in a collection to a subset of their fields. + + data + List of mappings or mapping of key to mapping. + + fields + Single field name or list of field names. A list reduces each + entity to a mapping of the listed fields, a single name to the + field's value. ``None`` (the default) returns ``data`` unchanged. + """ + if fields is None: + return data + + def _reduce(entity): + try: + if isinstance(fields, str): + return entity[fields] + return {field: entity[field] for field in fields} + except KeyError as err: + raise CommandExecutionError( + f"Unknown field {err}. Available fields: {', '.join(entity)}" + ) from None + + if isinstance(data, Mapping): + return {key: _reduce(val) for key, val in data.items()} + return [_reduce(entity) for entity in data] diff --git a/src/saltext/github/utils/gh/__init__.py b/src/saltext/github/utils/gh/__init__.py index 1b92ade0..f14ba513 100644 --- a/src/saltext/github/utils/gh/__init__.py +++ b/src/saltext/github/utils/gh/__init__.py @@ -10,66 +10,5 @@ on API errors. """ -import logging - -import salt.utils.http -import salt.utils.json - from saltext.github.utils.gh.exceptions import GitHubApiError # noqa: F401 from saltext.github.utils.gh.exceptions import GitHubNotFoundError # noqa: F401 - -log = logging.getLogger(__name__) - - -def get_user_pubkeys(users): - """ - Retrieve a set of public keys from GitHub for the specified list of users. - Expects input in list format. Optionally, a value in the list may be a dict - whose value is a list of key IDs to be returned. If this is not done, then - all keys will be returned. - - Some example data structures that coupld be passed in would look like: - - .. code-block:: yaml - - ['user1', 'user2', 'user3'] - - [ - 'user1': [ - '12345', - '67890', - ], - 'user2', - 'user3', - ] - """ - if not isinstance(users, list): - return {"Error": "A list of users is expected"} - - ret = {} - for user in users: - key_ids = [] - if isinstance(user, dict): - tmp_user = next(iter(user.keys())) - key_ids = user[tmp_user] - user = tmp_user - - url = f"https://api.github.com/users/{user}/keys" - result = salt.utils.http.query( - url, - "GET", - decode=False, - text=True, - ) - - keys = salt.utils.json.loads(result["text"]) - - ret[user] = {} - for key in keys: - if key_ids: - if str(key["id"]) in key_ids: - ret[user][key["id"]] = key["key"] - else: - ret[user][key["id"]] = key["key"] - - return ret diff --git a/src/saltext/github/utils/gh/actions.py b/src/saltext/github/utils/gh/actions.py index 47908c85..b62b5578 100644 --- a/src/saltext/github/utils/gh/actions.py +++ b/src/saltext/github/utils/gh/actions.py @@ -24,10 +24,10 @@ def list_secrets(conn, repo_name): so this only carries metadata (``name``, ``created_at``, ``updated_at``). conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. """ return client.query(conn, _secrets_path(conn, repo_name), per_page=100)["secrets"] @@ -37,13 +37,13 @@ def get_secret(conn, repo_name, name): Return the metadata of a single Actions secret. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The name of the secret. + Secret name. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the secret does not exist. @@ -57,10 +57,10 @@ def get_public_key(conn, repo_name): a mapping with ``key_id`` and (base64-encoded) ``key``. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. """ return client.query(conn, _secrets_path(conn, repo_name, "public-key")) @@ -71,11 +71,11 @@ def encrypt_secret(public_key, value): (libsodium sealed box). Returns the base64-encoded ciphertext. public_key - The base64-encoded public key of the repository, as returned + Base64-encoded public key of the repository, as returned by :func:`get_public_key` in ``key``. value - The secret value to encrypt. + Secret value to encrypt. """ sealed_box = public.SealedBox(public.PublicKey(public_key.encode(), encoding.Base64Encoder)) return base64.b64encode(sealed_box.encrypt(value.encode())).decode() @@ -87,16 +87,16 @@ def set_secret(conn, repo_name, name, value): repository public key before leaving this host. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The name of the secret. + Secret name. value - The plaintext secret value. + Plaintext secret value. """ pubkey = get_public_key(conn, repo_name) client.query( @@ -115,13 +115,13 @@ def delete_secret(conn, repo_name, name): Remove an Actions secret from a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The name of the secret. + Secret name. """ client.query(conn, _secrets_path(conn, repo_name, name), method="DELETE") @@ -133,10 +133,10 @@ def get_workflow_permissions(conn, repo_name): (``read``/``write``) and ``can_approve_pull_request_reviews``. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. """ return client.query( conn, ["repos", conn.org_name, repo_name, "actions", "permissions", "workflow"] @@ -151,17 +151,17 @@ def set_workflow_permissions( in a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. default_workflow_permissions - The default permissions for the ``GITHUB_TOKEN``, either ``read`` + Default permissions for the ``GITHUB_TOKEN``, either ``read`` or ``write``. can_approve_pull_request_reviews - Whether GitHub Actions can approve pull requests. + Allow GitHub Actions to approve pull requests. """ client.query( conn, diff --git a/src/saltext/github/utils/gh/client.py b/src/saltext/github/utils/gh/client.py index 3a72fd37..0f083740 100644 --- a/src/saltext/github/utils/gh/client.py +++ b/src/saltext/github/utils/gh/client.py @@ -34,10 +34,10 @@ class GitHubConnection: Personal access token to authenticate with. org_name - The name of the organization this connection is scoped to. + Organization name this connection is scoped to. api_url - The base URL of the GitHub API. Set this when targeting a GitHub + Base URL of the GitHub API. Set this when targeting a GitHub Enterprise Server instance. Defaults to the public GitHub API. """ @@ -52,7 +52,7 @@ def __init__(self, token, org_name, *, api_url=API_URL_DEFAULT): @property def org(self): """ - The PyGithub ``Organization`` object this connection is scoped to (cached). + PyGithub ``Organization`` object this connection is scoped to (cached). """ if "org" not in self.cache: self.cache["org"] = self.client.get_organization(self.org_name) @@ -92,11 +92,11 @@ def query(conn, path, *, params=None, method="GET", headers=None, data=None, per Make a direct call to the GitHub API, transparently following pagination. conn - The :class:`GitHubConnection` to run the request through (honoring + :class:`GitHubConnection` to run the request through (honoring its ``api_url``, authentication and retry handling). path - The URL path to query, either as a string or a list of segments. + URL path to query, either as a string or a list of segments. params Mapping of query parameters. @@ -150,7 +150,7 @@ def query(conn, path, *, params=None, method="GET", headers=None, data=None, per log.debug("GitHub Response Status Code: %s", status) decoded = json.loads(text) if text else None - if status not in (200, 201, 204): + if not 200 <= status < 300: exc_type = GitHubNotFoundError if status == 404 else GitHubApiError raise exc_type(_error_message(status, decoded, text), status=status, data=decoded) diff --git a/src/saltext/github/utils/gh/deploy_keys.py b/src/saltext/github/utils/gh/deploy_keys.py index e39b8143..ba0382cd 100644 --- a/src/saltext/github/utils/gh/deploy_keys.py +++ b/src/saltext/github/utils/gh/deploy_keys.py @@ -18,10 +18,10 @@ def list_all(conn, repo_name): Return all deploy keys of a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. """ return client.query(conn, _path(conn, repo_name)) @@ -31,13 +31,13 @@ def get(conn, repo_name, key_id): Return the data of a single deploy key. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. key_id - The ID of the deploy key. + Deploy key ID. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the deploy key does not exist. @@ -52,16 +52,16 @@ def create(conn, repo_name, title, key, *, read_only=False): Note that deploy keys are immutable, they can only be replaced. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. title - The display title of the deploy key. + Display title of the deploy key. key - The SSH public key. + SSH public key. read_only Deny write access to the repository for this key. Defaults to false. @@ -79,12 +79,12 @@ def delete(conn, repo_name, key_id): Remove a deploy key from a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. key_id - The ID of the deploy key. + Deploy key ID. """ client.query(conn, _path(conn, repo_name, key_id), method="DELETE") diff --git a/src/saltext/github/utils/gh/environments.py b/src/saltext/github/utils/gh/environments.py index 7f1e0e95..2303aecd 100644 --- a/src/saltext/github/utils/gh/environments.py +++ b/src/saltext/github/utils/gh/environments.py @@ -10,8 +10,6 @@ log = logging.getLogger(__name__) -UNSET = ... - def _path(conn, repo_name, *suffix): # Environment names can contain characters that are invalid in URL paths @@ -28,7 +26,7 @@ def normalize(env): ``protection_rules`` entries. env - The environment payload to normalize. + Environment payload to normalize. """ ret = { "wait_timer": 0, @@ -53,10 +51,10 @@ def list_all(conn, repo_name): Return all environments of a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. """ return client.query(conn, _path(conn, repo_name), per_page=100)["environments"] @@ -66,13 +64,13 @@ def get(conn, repo_name, name): Return the data of a single environment. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The name of the environment. + Environment name. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the environment does not exist. @@ -88,36 +86,37 @@ def set_( wait_timer=None, prevent_self_review=None, reviewers=None, - deployment_branch_policy=UNSET, + deployment_branch_policy=None, ): """ - Create or update an environment. Returns the data of the environment. + Create or update an environment, replacing its whole configuration. + Unspecified settings are reset to their defaults. + Returns the data of the environment. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The name of the environment. + Environment name. wait_timer - The amount of time to delay a job after the job is initially - triggered, in minutes. + Amount of time to delay a job after it is initially triggered, + in minutes. prevent_self_review - Whether or not a user who created the job is prevented from - approving their own job. + Prevent a user who created a job from approving it. reviewers - The people or teams that may review jobs that reference the + People or teams that may review jobs that reference the environment, as a list of mappings with ``type`` (``User`` or ``Team``) and ``id``. deployment_branch_policy - The type of deployment branch policy for this environment, either - ``None`` to allow all branches or a mapping with + Type of deployment branch policy for this environment, either + ``None`` (default) to allow all branches or a mapping with ``protected_branches`` and ``custom_branch_policies`` booleans. Branch name patterns for custom policies are managed separately, see :func:`create_branch_policy`. @@ -129,8 +128,7 @@ def set_( "reviewers": reviewers, } ) - if deployment_branch_policy is not UNSET: - data["deployment_branch_policy"] = deployment_branch_policy + data["deployment_branch_policy"] = deployment_branch_policy return client.query(conn, _path(conn, repo_name, name), method="PUT", data=data) @@ -139,13 +137,13 @@ def delete(conn, repo_name, name): Delete an environment from a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The name of the environment. + Environment name. """ client.query(conn, _path(conn, repo_name, name), method="DELETE") @@ -155,13 +153,13 @@ def list_branch_policies(conn, repo_name, environment): Return all deployment branch policies of an environment. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. environment - The name of the environment. + Environment name. """ return client.query( conn, @@ -179,20 +177,20 @@ def create_branch_policy(conn, repo_name, environment, name, *, type_="branch"): ``custom_branch_policies`` enabled. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. environment - The name of the environment. + Environment name. name - The name pattern that branches (or tags) must match in order - to deploy to the environment. + Name pattern that branches (or tags) must match in order to + deploy to the environment. type\_ - Whether this rule targets a ``branch`` or ``tag``. + Target of this rule, either ``branch`` or ``tag``. Defaults to ``branch``. """ return client.query( @@ -208,16 +206,16 @@ def delete_branch_policy(conn, repo_name, environment, policy_id): Remove a deployment branch policy from an environment. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. environment - The name of the environment. + Environment name. policy_id - The ID of the branch policy. + Branch policy ID. """ client.query( conn, diff --git a/src/saltext/github/utils/gh/exceptions.py b/src/saltext/github/utils/gh/exceptions.py index 862e114f..1763ccd0 100644 --- a/src/saltext/github/utils/gh/exceptions.py +++ b/src/saltext/github/utils/gh/exceptions.py @@ -10,10 +10,10 @@ class GitHubApiError(CommandExecutionError): The GitHub API returned an error response. status - The HTTP status code of the response, if available. + HTTP status code of the response, if available. data - The decoded response body, if available. + Decoded response body, if available. """ def __init__(self, message, *, status=None, data=None): diff --git a/src/saltext/github/utils/gh/issues.py b/src/saltext/github/utils/gh/issues.py index 1d35e1bf..7657d5cd 100644 --- a/src/saltext/github/utils/gh/issues.py +++ b/src/saltext/github/utils/gh/issues.py @@ -14,13 +14,13 @@ def get(conn, repo_name, number): Return the raw data of a single issue. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository the issue belongs to. + Repository the issue belongs to. number - The number of the issue to retrieve. + Issue number to retrieve. """ return client.query(conn, ["repos", conn.org_name, repo_name, "issues", str(number)]) @@ -30,13 +30,13 @@ def get_comments(conn, repo_name, number, since=None): Return the raw comment data for a single issue. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository the issue belongs to. + Repository the issue belongs to. number - The number of the issue whose comments to retrieve. + Issue number whose comments to retrieve. since Only comments updated at or after this time are returned. This is a @@ -70,13 +70,46 @@ def list_all( options. Pull requests are excluded. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository whose issues to list. + Repository whose issues to list. - Other parameters as described in - :py:func:`github.get_issues `. + milestone + Number of a GitHub milestone, or a string of either ``*`` or ``none``. + + state + Filter returned issues by their state. Can be either ``open``, + ``closed``, or ``all``. Default is ``open``. + + assignee + Filter returned issues by assigned user handle. Pass ``none`` + (as a string) for issues with no assigned user or ``*`` for + issues assigned to any user. + + creator + Filter by the username that created the issue. + + mentioned + Filter issues for a user that's mentioned in the issue. + + labels + Filter issues by associated labels. String of comma separated + label names. For example, ``bug,ui,@high``. + + sort + Field to sort results by. Either ``created``, ``updated``, or + ``comments``. Default is ``created``. + + direction + Sort direction. Either ``asc`` or ``desc``. Default is ``desc``. + + since + Only list issues updated at or after this time. This is a + timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. + + per_page + Pagination size, maximum 100. """ params = {} @@ -116,8 +149,10 @@ def format_issue(issue): Format API return information into a more manageable and useful dictionary for issue information. + Legacy function, only used by deprecated callers. + issue - The issue to format. + Issue to format. """ ret = { "id": issue.get("id"), diff --git a/src/saltext/github/utils/gh/labels.py b/src/saltext/github/utils/gh/labels.py index eb47f3eb..8a044145 100644 --- a/src/saltext/github/utils/gh/labels.py +++ b/src/saltext/github/utils/gh/labels.py @@ -23,7 +23,7 @@ def normalize_color(color): Normalize a label color for comparisons: lowercase, no leading ``#``. color - The hexadecimal color code to normalize. + Hexadecimal color code to normalize. """ if color is None: return None @@ -35,10 +35,10 @@ def list_all(conn, repo_name): Return all labels of a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. """ return client.query(conn, _path(conn, repo_name)) @@ -48,13 +48,13 @@ def get(conn, repo_name, name): Return the data of a single label. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The name of the label. + Label name. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the label does not exist. @@ -67,20 +67,20 @@ def create(conn, repo_name, name, *, color=None, description=None): Create a label in a repository. Returns the data of the created label. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The name of the label. + Label name. color - The hexadecimal color code for the label, without the leading ``#``. + Hexadecimal color code for the label, without the leading ``#``. If unset, GitHub assigns a random one. description - A short description of the label. + Short description of the label. """ data = filter_unset({"name": name, "color": normalize_color(color), "description": description}) return client.query(conn, _path(conn, repo_name), method="POST", data=data) @@ -91,22 +91,22 @@ def update(conn, repo_name, name, *, new_name=None, color=None, description=None Update an existing label. Returns the data of the updated label. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The (current) name of the label. + Current label name. new_name - A new name for the label. + New name for the label. color - The hexadecimal color code for the label, without the leading ``#``. + Hexadecimal color code for the label, without the leading ``#``. description - A short description of the label. + Short description of the label. """ data = filter_unset( {"new_name": new_name, "color": normalize_color(color), "description": description} @@ -119,12 +119,12 @@ def delete(conn, repo_name, name): Delete a label from a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. name - The name of the label. + Label name. """ client.query(conn, _path(conn, repo_name, name), method="DELETE") diff --git a/src/saltext/github/utils/gh/users.py b/src/saltext/github/utils/gh/members.py similarity index 76% rename from src/saltext/github/utils/gh/users.py rename to src/saltext/github/utils/gh/members.py index c1e79ef2..0244bcef 100644 --- a/src/saltext/github/utils/gh/users.py +++ b/src/saltext/github/utils/gh/members.py @@ -12,12 +12,12 @@ @api_request -def list_members(conn, ignore_cache=False): +def list_all(conn, ignore_cache=False): """ Return the logins of all members of the organization (cached). conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. ignore_cache Bypass the cached member list. @@ -28,16 +28,16 @@ def list_members(conn, ignore_cache=False): @api_request -def get_user_info(conn, login): +def get_info(conn, login): """ Return information about a user, including their organization membership state. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. login - The login of the user to look up. + User handle to look up. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the user does not exist. @@ -70,16 +70,16 @@ def get_user_info(conn, login): @api_request -def invite_member(conn, login): +def invite(conn, login): """ Invite a user into the organization. Returns whether the resulting membership state is ``pending``. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. login - The login of the user to invite. + User handle to invite. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the user does not exist. @@ -95,16 +95,15 @@ def invite_member(conn, login): @api_request -def remove_member(conn, login): +def remove(conn, login): """ - Remove a user from the organization. Returns whether the user is no - longer a member afterwards. + Remove a user from the organization (if they are a member). conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. login - The login of the user to remove. + User handle to remove. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the user does not exist. @@ -114,17 +113,17 @@ def remove_member(conn, login): if conn.org.has_in_members(user): conn.org.remove_from_members(user) - return not conn.org.has_in_members(user) + return True @api_request -def list_members_without_mfa(conn, ignore_cache=False): +def list_without_mfa(conn, ignore_cache=False): """ Return the logins (lowercase) of all organization members without MFA turned on (cached). conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. ignore_cache Bypass the cached member list. diff --git a/src/saltext/github/utils/gh/milestones.py b/src/saltext/github/utils/gh/milestones.py index eeebafdd..a3e1a09d 100644 --- a/src/saltext/github/utils/gh/milestones.py +++ b/src/saltext/github/utils/gh/milestones.py @@ -14,13 +14,13 @@ def get(conn, repo_name, number): Return the raw data of a single milestone. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository the milestone belongs to. + Repository the milestone belongs to. number - The number of the milestone to retrieve. + Milestone number to retrieve. """ return client.query(conn, ["repos", conn.org_name, repo_name, "milestones", str(number)]) @@ -39,13 +39,24 @@ def list_all( search options. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository whose milestones to list. + Repository whose milestones to list. - Other parameters as described in - :py:func:`github.get_milestones `. + state + Filter milestones by their state. Either ``open``, ``closed``, + or ``all``. Default is ``open``. + + sort + Field to sort results by. Either ``due_on`` or ``completeness``. + Default is ``due_on``. + + direction + Sort direction. Either ``asc`` or ``desc``. Default is ``asc``. + + per_page + Pagination size, maximum 100. """ params = {} @@ -68,7 +79,7 @@ def format_milestone(milestone): Trim milestone data to the fields that are useful in (CLI) output. milestone - The milestone to format. + Milestone to format. """ return { key: value diff --git a/src/saltext/github/utils/gh/pages.py b/src/saltext/github/utils/gh/pages.py index ae7933fd..364c4a4d 100644 --- a/src/saltext/github/utils/gh/pages.py +++ b/src/saltext/github/utils/gh/pages.py @@ -20,10 +20,10 @@ def build_source(branch=None, path=None): when both are unset. branch - The repository branch to publish from. + Repository branch to publish from. path - The repository directory to publish from, either ``/`` or ``/docs``. + Repository directory to publish from, either ``/`` or ``/docs``. """ source = filter_unset({"branch": branch, "path": path}) return source or None @@ -34,10 +34,10 @@ def get(conn, repo_name): Return information about the GitHub Pages site of a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the repository has no Pages site. @@ -51,13 +51,13 @@ def create(conn, repo_name, *, build_type=None, source=None): Returns the data of the created site. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. build_type - The process by which the site is built, either ``legacy`` + Process by which the site is built, either ``legacy`` (deploy from a branch) or ``workflow`` (GitHub Actions). source @@ -77,13 +77,13 @@ def update(conn, repo_name, *, build_type=None, source=None, cname=..., https_en Update the GitHub Pages site configuration of a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. build_type - The process by which the site is built, either ``legacy`` + Process by which the site is built, either ``legacy`` (deploy from a branch) or ``workflow`` (GitHub Actions). source @@ -91,11 +91,11 @@ def update(conn, repo_name, *, build_type=None, source=None, cname=..., https_en ``branch`` and ``path``. cname - The custom domain for the site. Pass ``None`` explicitly + Custom domain for the site. Pass ``None`` explicitly to remove it. https_enforced - Whether HTTPS is enforced for the site. + Enforce HTTPS for the site. """ data = filter_unset( {"build_type": build_type, "source": source, "https_enforced": https_enforced} @@ -110,9 +110,9 @@ def delete(conn, repo_name): Delete the GitHub Pages site of a repository. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository. + Repository name. """ client.query(conn, _path(conn, repo_name), method="DELETE") diff --git a/src/saltext/github/utils/gh/profile.py b/src/saltext/github/utils/gh/profile.py index c0842400..bc926ed1 100644 --- a/src/saltext/github/utils/gh/profile.py +++ b/src/saltext/github/utils/gh/profile.py @@ -4,6 +4,7 @@ execution modules. """ +import hashlib import logging from salt.exceptions import CommandExecutionError @@ -21,16 +22,16 @@ def get_config_value(profile, config_name, *, salt, context): (resolving SDB URIs) and cached in ``context``. profile - The profile name that contains configuration information. + Profile name that contains configuration information. config_name - The configuration item's name to use to return configuration values. + Configuration item name to look up. salt - The ``__salt__`` dunder of the calling module. + ``__salt__`` dunder of the calling module. context - The ``__context__`` dunder of the calling module. + ``__context__`` dunder of the calling module. """ if "gh_profile" not in context or profile not in context["gh_profile"]: if not salt["config.get"](profile): @@ -59,13 +60,13 @@ def get_api_url(profile, *, salt, context): Return the configured GitHub API base URL, defaulting to the public one. profile - The profile name that contains configuration information. + Profile name that contains configuration information. salt - The ``__salt__`` dunder of the calling module. + ``__salt__`` dunder of the calling module. context - The ``__context__`` dunder of the calling module. + ``__context__`` dunder of the calling module. """ try: return get_config_value(profile, "api_url", salt=salt, context=context).rstrip("/") @@ -79,18 +80,20 @@ def get_conn(profile, *, salt, context): for a profile, cached in ``context`` for performance. profile - The profile name that contains configuration information. + Profile name that contains configuration information. salt - The ``__salt__`` dunder of the calling module. + ``__salt__`` dunder of the calling module. context - The ``__context__`` dunder of the calling module. + ``__context__`` dunder of the calling module. """ token = get_config_value(profile, "token", salt=salt, context=context) org_name = get_config_value(profile, "org_name", salt=salt, context=context) api_url = get_api_url(profile, salt=salt, context=context) - key = f"github.{token}:{org_name}:{api_url}" + # Hash the token to keep the secret out of context dumps + token_hash = hashlib.sha256(token.encode()).hexdigest()[:16] + key = f"github.{token_hash}:{org_name}:{api_url}" if key not in context: context[key] = GitHubConnection(token, org_name, api_url=api_url) diff --git a/src/saltext/github/utils/gh/pulls.py b/src/saltext/github/utils/gh/pulls.py index 47d87d4c..6d00e614 100644 --- a/src/saltext/github/utils/gh/pulls.py +++ b/src/saltext/github/utils/gh/pulls.py @@ -25,13 +25,33 @@ def list_all( search options. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. repo_name - The name of the repository whose pull requests to list. + Repository whose pull requests to list. - Other parameters as described in - :py:func:`github.get_prs `. + state + Filter returned pull requests by their state. Can be either + ``open``, ``closed``, or ``all``. Default is ``open``. + + head + Filter pull requests by head user and branch name in the format + ``user:ref-name``. + + base + Filter pulls by base branch name, e.g. ``gh-pages``. + + sort + Field to sort results by. Either ``created``, ``updated``, + ``popularity`` (comment count), or ``long-running`` (age, + filtering by pull requests updated within the last month). + Default is ``created``. + + direction + Sort direction. Either ``asc`` or ``desc``. Default is ``desc``. + + per_page + Pagination size, maximum 100. """ params = {} @@ -59,8 +79,10 @@ def format_pr(pr_): Format API return information into a more manageable and useful dictionary for pull request information. + Legacy function, only used by deprecated callers. + pr\_ - The pull request to format. + Pull request to format. """ ret = { "id": pr_.get("id"), diff --git a/src/saltext/github/utils/gh/repos.py b/src/saltext/github/utils/gh/repos.py index 76ec4d27..ca6ccea1 100644 --- a/src/saltext/github/utils/gh/repos.py +++ b/src/saltext/github/utils/gh/repos.py @@ -3,6 +3,7 @@ """ import logging +import time from github.GithubException import UnknownObjectException from salt.exceptions import CommandExecutionError @@ -16,7 +17,9 @@ def to_dict(repo): """ - Render a PyGithub ``Repository`` object into a plain dictionary. + Render a PyGithub ``Repository`` object into a plain dictionary, + excluding GitHub's deprecated alias fields (``watchers_count``, + ``watchers``, ``forks``, ``open_issues``). """ ret = {} ret["id"] = repo.id @@ -30,12 +33,9 @@ def to_dict(repo): ret["homepage"] = repo.homepage ret["size"] = repo.size ret["stargazers_count"] = repo.stargazers_count - ret["watchers_count"] = repo.watchers_count ret["language"] = repo.language + ret["forks_count"] = repo.forks_count ret["open_issues_count"] = repo.open_issues_count - ret["forks"] = repo.forks - ret["open_issues"] = repo.open_issues - ret["watchers"] = repo.watchers ret["default_branch"] = repo.default_branch ret["has_issues"] = repo.has_issues ret["has_wiki"] = repo.has_wiki @@ -50,7 +50,7 @@ def list_all(conn, ignore_cache=False): objects (cached). conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. ignore_cache Bypass the cached repository list. @@ -71,10 +71,10 @@ def get_info(conn, name, ignore_cache=False): Return information about a single repository as a dictionary (cached). conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. name - The name of the repository. + Repository name. ignore_cache Bypass the cached repository information. @@ -106,10 +106,10 @@ def get_teams(conn, name): Return the teams a repository belongs to. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. name - The name of the repository. + Repository name. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the repository does not exist. @@ -143,16 +143,41 @@ def create( license_template=None, ): """ - Create a new repository in the organization. + Create a new repository in the organization and wait for it to + become visible. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. name - The name of the repository to create. + Repository name to create. - Other parameters as described in - :py:func:`github.add_repo `. + description + Repository description. + + homepage + URL with more information about the repository, e.g. rendered docs. + + private + Repository visibility. + + has_issues + Enable issues for this repository. + + has_wiki + Enable the wiki for this repository. + + has_downloads + Enable downloads for this repository. + + auto_init + Create an initial commit with an empty README. + + gitignore_template + Desired language or platform for a .gitignore, e.g. "Haskell". + + license_template + Desired LICENSE template to apply, e.g. "mit" or "mozilla". """ parameters = filter_unset( { @@ -168,6 +193,19 @@ def create( } ) repo = conn.org.create_repo(name, **parameters) + + # Fetching a new repo's associations can 404 for a short while + # after creation, wait until it settles. + for attempt in range(3): + try: + get_teams(conn, name) + break + except GitHubNotFoundError: + log.info("Attempt %s to fetch new repo %s failed", attempt, name) + time.sleep(1) + else: + raise CommandExecutionError(f"Failed to verify repo {name} after creation.") + info = to_dict(repo) conn.cache[("repo_info", repo.name.lower())] = info if "repos" in conn.cache: @@ -193,24 +231,44 @@ def update( Update an existing repository in the organization. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. name - The name of the repository to update. + Repository name to update. - allow_privacy_change - Whether a change of the repository's visibility is allowed. - Defaults to false. + description + Repository description. + + homepage + URL with more information about the repository, e.g. rendered docs. + + private + Repository visibility. Changing this requires ``allow_repo_privacy_changes`` + to be enabled in the profile. + + has_issues + Enable issues for this repository. - Other parameters as described in - :py:func:`github.edit_repo `. + has_wiki + Enable the wiki for this repository. + + has_downloads + Enable downloads for this repository. + + default_branch + Default branch of the repository. Must already exist: Ignored during repo creation. + + allow_privacy_change + Allow changing the repository's visibility. Defaults to false. """ if private is not None and not allow_privacy_change: raise CommandExecutionError( f"The private field is set to be changed for repo {name} " "but allow_repo_privacy_changes disallows this." ) - repo = conn.org.get_repo(name) + # Resolve via the owner-agnostic endpoint, which also works for + # user-owned repositories + repo = conn.client.get_repo(f"{conn.org_name}/{name}") parameters = filter_unset( { "description": description, @@ -232,11 +290,13 @@ def delete(conn, name): Delete a repository from the organization. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. name - The name of the repository to delete. + Repository name to delete. """ - repo = conn.org.get_repo(name) + # Resolve via the owner-agnostic endpoint, which also works for + # user-owned repositories + repo = conn.client.get_repo(f"{conn.org_name}/{name}") repo.delete() list_all(conn, ignore_cache=True) # Refresh cache diff --git a/src/saltext/github/utils/gh/rulesets.py b/src/saltext/github/utils/gh/rulesets.py index 07a27373..8703d5db 100644 --- a/src/saltext/github/utils/gh/rulesets.py +++ b/src/saltext/github/utils/gh/rulesets.py @@ -16,19 +16,19 @@ def build_path(ruleset_type, *, owner=None, repo_name=None, org_name=None, rules Build the API path for ruleset operations. ruleset_type - The type of ruleset. ``repo`` or ``org``. + Ruleset type, either ``repo`` or ``org``. owner - The account owner of the repository. Required for ``repo`` rulesets. + Owner of the repository. Required for ``repo`` rulesets. repo_name - The name of the repository. Required for ``repo`` rulesets. + Repo name. Required for ``repo`` rulesets. org_name - The name of the organization. Required for ``org`` rulesets. + Org name. Required for ``org`` rulesets. ruleset_id - The ID of a specific ruleset to address. + ID of a specific ruleset to address. """ if ruleset_type == "repo": path = ["repos", owner, repo_name, "rulesets"] @@ -47,10 +47,10 @@ def check_params(ruleset_params, required=None): required keys. ruleset_params - The payload to check. + Payload to check. required - An optional set of parameter names that must be present. + Optional set of parameter names that must be present. """ if not isinstance(ruleset_params, dict): raise CommandExecutionError("ruleset_params need to be a dict") @@ -67,7 +67,7 @@ def list_all( Return all rulesets of a repository or organization. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. Other parameters as described in :func:`build_path`. ``headers`` is a mapping of extra headers to send. @@ -81,10 +81,10 @@ def get(conn, ruleset_id, ruleset_type, *, owner=None, repo_name=None, org_name= Return the data of a specific ruleset. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. ruleset_id - The ID of the ruleset to retrieve. + ID of the ruleset to retrieve. Other parameters as described in :func:`build_path`. ``headers`` is a mapping of extra headers to send. @@ -102,11 +102,10 @@ def create( Create a new ruleset. Returns the data of the created ruleset. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. ruleset_params - The ruleset definition. Must contain at least ``name`` and - ``enforcement``. + Ruleset definition. Must contain at least ``name`` and ``enforcement``. Other parameters as described in :func:`build_path`. ``headers`` is a mapping of extra headers to send. @@ -131,13 +130,13 @@ def update( Update an existing ruleset. Returns the data of the updated ruleset. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. ruleset_id - The ID of the ruleset to update. + ID of the ruleset to update. ruleset_params - The updated ruleset definition. + Updated ruleset definition. Other parameters as described in :func:`build_path`. ``headers`` is a mapping of extra headers to send. @@ -156,10 +155,10 @@ def delete( Delete a ruleset. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. ruleset_id - The ID of the ruleset to delete. + ID of the ruleset to delete. Other parameters as described in :func:`build_path`. ``headers`` is a mapping of extra headers to send. diff --git a/src/saltext/github/utils/gh/teams.py b/src/saltext/github/utils/gh/teams.py index 757569d1..439f64d2 100644 --- a/src/saltext/github/utils/gh/teams.py +++ b/src/saltext/github/utils/gh/teams.py @@ -8,8 +8,6 @@ import logging -from github.GithubException import UnknownObjectException - from saltext.github.utils import filter_unset from saltext.github.utils.gh.client import api_request @@ -23,7 +21,7 @@ def list_all(conn, ignore_cache=False): team details (cached). conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. ignore_cache Bypass the cached team list. @@ -36,6 +34,7 @@ def list_all(conn, ignore_cache=False): # here since each one would trigger a separate request per team. teams[team.name] = { "id": team.id, + "name": team.name, "slug": team.slug, "description": team.description, "permission": team.permission, @@ -51,10 +50,10 @@ def get(conn, name): not exist. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. name - The name of the team. + Team name. """ return list_all(conn).get(name) @@ -65,13 +64,23 @@ def create(conn, name, *, description=None, repo_names=None, privacy=None, permi Create a new team in the organization. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. name - The name of the team to create. + Name of team to create. + + description + Team description. + + repo_names + List of repository names to add the team to. - Other parameters as described in - :py:func:`github.add_team `. + privacy + Privacy level for the team, either ``secret`` or ``closed``. + + permission + Default permission for new repositories added to the team, either + ``pull``, ``push`` or ``admin``. """ parameters = filter_unset( { @@ -96,16 +105,23 @@ def update(conn, team_info, name, *, description=None, privacy=None, permission= Update an existing team. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. team_info - The team's entry from :func:`list_all`. + Team's entry from :func:`list_all`. name - The name of the team. + Team name. + + description + Team description. - Other parameters as described in - :py:func:`github.edit_team `. + privacy + Privacy level for the team, either ``secret`` or ``closed``. + + permission + Default permission for new repositories added to the team, either + ``pull``, ``push`` or ``admin``. """ team = conn.org.get_team_by_slug(team_info["slug"]) team.edit( @@ -118,6 +134,7 @@ def update(conn, team_info, name, *, description=None, privacy=None, permission= } ), ) + list_all(conn, ignore_cache=True) # Refresh cache @api_request @@ -126,10 +143,10 @@ def delete(conn, team_info): Delete a team from the organization. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. team_info - The team's entry from :func:`list_all`. + Team's entry from :func:`list_all`. """ team = conn.org.get_team_by_slug(team_info["slug"]) team.delete() @@ -143,10 +160,10 @@ def list_repos(conn, team_info, ignore_cache=False): name to repository details (cached). conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. team_info - The team's entry from :func:`list_all`. + Team's entry from :func:`list_all`. ignore_cache Bypass the cached team repositories. @@ -163,7 +180,7 @@ def list_repos(conn, team_info, ignore_cache=False): elif repo.permissions.push: permission = "push" - repos[repo.name.lower()] = {"permission": permission} + repos[repo.name.lower()] = {"name": repo.name, "permission": permission} team_info["repos"] = repos return repos @@ -175,18 +192,17 @@ def add_repo(conn, team_info, repo_name, permission=None): on it. Returns false if updating the permission failed. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. team_info - The team's entry from :func:`list_all`. + Team's entry from :func:`list_all`. repo_name - The name of the repository to add. + Repository name to add. permission - The permission for team members within the repository, can be ``pull``, - ``push`` or ``admin``. If not specified, the default permission specified - on the team will be used. + Permission for team members within the repository. Either ``pull``, + ``push`` or ``admin``. Defaults to the default permission on the team. """ team = conn.org.get_team_by_slug(team_info["slug"]) repo = conn.org.get_repo(repo_name) @@ -201,22 +217,23 @@ def add_repo(conn, team_info, repo_name, permission=None): @api_request def remove_repo(conn, team_info, repo_name): """ - Remove a repository from a team. Returns whether the repository is no - longer associated with the team afterwards. + Remove a repository from a team. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. team_info - The team's entry from :func:`list_all`. + Team's entry from :func:`list_all`. repo_name - The name of the repository to remove. + Repository name to remove. """ team = conn.org.get_team_by_slug(team_info["slug"]) repo = conn.org.get_repo(repo_name) team.remove_from_repos(repo) - return repo_name not in list_repos(conn, team_info, ignore_cache=True) + if team_info.get("repos"): + team_info["repos"].pop(repo_name.lower(), None) + return True @api_request @@ -225,10 +242,10 @@ def list_members(conn, team_info, ignore_cache=False): Return the (lowercase) logins of a team's members (cached). conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. team_info - The team's entry from :func:`list_all`. + Team's entry from :func:`list_all`. ignore_cache Bypass the cached team members. @@ -247,13 +264,13 @@ def add_member(conn, team_info, login): Add an organization member to a team. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. team_info - The team's entry from :func:`list_all`. + Team's entry from :func:`list_all`. login - The login of the user to add. + User handle to invite. """ team = conn.org.get_team_by_slug(team_info["slug"]) member = conn.client.get_user(login) @@ -263,17 +280,16 @@ def add_member(conn, team_info, login): @api_request def remove_member(conn, team_info, login): """ - Remove a member from a team. Returns whether the user is no longer - a member of the team afterwards. + Remove a member from a team. conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. team_info - The team's entry from :func:`list_all`. + Team's entry from :func:`list_all`. login - The login of the user to remove. + User handle to remove. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the team or user does not exist. @@ -281,9 +297,6 @@ def remove_member(conn, team_info, login): team = conn.org.get_team_by_slug(team_info["slug"]) member = conn.client.get_user(login) team.remove_membership(member) - try: - # There is no non-deprecated endpoint for checking plain membership - team.get_team_membership(member) - except UnknownObjectException: - return True - return False + if team_info.get("members"): + team_info["members"] = [mem for mem in team_info["members"] if mem != login.lower()] + return True diff --git a/tests/functional/modules/github/test_teams.py b/tests/functional/modules/github/test_teams.py index 8f982ce5..28e66d19 100644 --- a/tests/functional/modules/github/test_teams.py +++ b/tests/functional/modules/github/test_teams.py @@ -209,12 +209,6 @@ def test_add_team_member_api_failure(github, monkeypatch): assert github.add_team_member("user2", "Team A") is False -def test_remove_team_member_not_removed(github, monkeypatch): - # The API reports success, but the member is still present afterwards - monkeypatch.setattr(Team, "remove_member", lambda *args, **kwargs: None) - assert github.remove_team_member("user1", "Team A") is False - - def test_remove_team_member_missing_team(github): assert github.remove_team_member("user1", "ghost") is False diff --git a/tests/functional/modules/github_org/test_members.py b/tests/functional/modules/github_org/test_members.py index feef27cb..5fa1713c 100644 --- a/tests/functional/modules/github_org/test_members.py +++ b/tests/functional/modules/github_org/test_members.py @@ -60,11 +60,3 @@ def test_remove_member_not_a_member(github_org): def test_remove_member_unknown_user(github_org): with pytest.raises(CommandExecutionError, match="Not Found"): github_org.remove_member("ghost") - - -def test_list_members_without_mfa(github_org, fake_state): - assert github_org.list_members_without_mfa() == ["user2"] - fake_state.get_user("user1").data.two_factor_authentication = False - # Cached result is returned until the cache is bypassed - assert github_org.list_members_without_mfa() == ["user2"] - assert sorted(github_org.list_members_without_mfa(ignore_cache=True)) == ["user1", "user2"] diff --git a/tests/functional/modules/github_org/test_repos.py b/tests/functional/modules/github_org/test_repos.py index bb548e7a..3c2c3d33 100644 --- a/tests/functional/modules/github_org/test_repos.py +++ b/tests/functional/modules/github_org/test_repos.py @@ -3,12 +3,35 @@ def test_list_repos(github_org): - assert set(github_org.list_repos()) == {"repo-a", "repo-b"} + ret = github_org.list_repos() + assert set(ret) == {"repo-a", "repo-b"} + # Full repository details are included + assert ret["repo-a"]["full_name"] == "testorg/repo-a" + + +def test_list_repos_fields(github_org): + ret = github_org.list_repos(fields=["private"]) + assert ret == {"repo-a": {"private": False}, "repo-b": {"private": True}} + # A single field name implodes the entries to its values + assert github_org.list_repos(fields="private") == {"repo-a": False, "repo-b": True} + + +def test_list_repos_unknown_field(github_org): + with pytest.raises(CommandExecutionError, match="Unknown field"): + github_org.list_repos(fields="bogus") def test_list_repos_private_filter(github_org): - assert github_org.list_repos(private=True) == ["repo-b"] - assert github_org.list_repos(private=False) == ["repo-a"] + assert list(github_org.list_repos(private=True)) == ["repo-b"] + assert list(github_org.list_repos(private=False)) == ["repo-a"] + + +def test_list_repos_uses_cache(github_org, fake_org): + assert set(github_org.list_repos()) == {"repo-a", "repo-b"} + fake_org.add_repo("repo-c") + # Cached result is returned until the cache is bypassed + assert set(github_org.list_repos()) == {"repo-a", "repo-b"} + assert "repo-c" in github_org.list_repos(ignore_cache=True) def test_add_repo(github_org, fake_org): diff --git a/tests/functional/modules/github_org/test_teams.py b/tests/functional/modules/github_org/test_teams.py index 5d1fbd86..f62cd537 100644 --- a/tests/functional/modules/github_org/test_teams.py +++ b/tests/functional/modules/github_org/test_teams.py @@ -1,17 +1,15 @@ import pytest from salt.exceptions import CommandExecutionError -from tests.support.github import Team - def test_list_teams(github_org): ret = github_org.list_teams() - assert "Team A" in ret + assert set(ret) == {"Team A"} assert ret["Team A"]["slug"] == "team-a" def test_list_teams_uses_cache(github_org, fake_org): - assert "Team A" in github_org.list_teams() + assert set(github_org.list_teams()) == {"Team A"} fake_org.add_team("Team B") # Cached result is returned until the cache is bypassed assert "Team B" not in github_org.list_teams() @@ -20,24 +18,44 @@ def test_list_teams_uses_cache(github_org, fake_org): def test_get_team(github_org): ret = github_org.get_team("Team A") + assert ret["name"] == "Team A" assert ret["slug"] == "team-a" assert ret["description"] == "Awesome Team: Team A" +def test_get_team_excludes_internal_caches(github_org): + github_org.list_team_repos("Team A") + github_org.list_team_members("Team A") + ret = github_org.get_team("Team A") + assert "repos" not in ret + assert "members" not in ret + + +def test_get_team_by_slug(github_org): + assert github_org.get_team("team-a")["name"] == "Team A" + + +def test_get_team_name_case_insensitive(github_org): + assert github_org.get_team("team a")["name"] == "Team A" + + def test_get_team_missing(github_org): with pytest.raises(CommandExecutionError, match="does not exist"): github_org.get_team("ghost") def test_add_team(github_org, fake_org): - assert github_org.add_team("Team B", description="Team B", privacy="closed") is True + ret = github_org.add_team("Team B", description="Team B", privacy="closed") + # The created team's details are returned + assert ret["slug"] == "team-b" + assert ret["description"] == "Team B" assert fake_org.get_team("team-b").description == "Team B" # add_team refreshes the team cache itself assert github_org.get_team("Team B") def test_add_team_with_repos(github_org, fake_org): - assert github_org.add_team("Team B", repo_names=["testorg/repo-b"]) is True + assert github_org.add_team("Team B", repo_names=["testorg/repo-b"])["slug"] == "team-b" assert fake_org.get_team("team-b").repos["repo-b"] == "pull" @@ -48,14 +66,16 @@ def test_add_team_duplicate(github_org): def test_edit_team(github_org, fake_org): - assert ( - github_org.edit_team("Team A", description="Renovated", privacy="secret", permission="push") - is True + ret = github_org.edit_team( + "Team A", description="Renovated", privacy="secret", permission="push" ) + # The refreshed team details are returned and the cache is updated + assert ret["description"] == "Renovated" team = fake_org.get_team("team-a") assert team.description == "Renovated" assert team.privacy == "secret" assert team.permission == "push" + assert github_org.get_team("Team A")["description"] == "Renovated" def test_edit_team_missing(github_org): @@ -83,7 +103,7 @@ def test_remove_team_stale_cache(github_org, fake_org): def test_list_team_repos(github_org): ret = github_org.list_team_repos("Team A") - assert set(ret) == {"repo-a"} + assert list(ret) == ["repo-a"] assert ret["repo-a"]["permission"] == "pull" @@ -93,15 +113,19 @@ def test_list_team_repos_missing_team(github_org): def test_list_team_repos_uses_cache(github_org, fake_org): - assert set(github_org.list_team_repos("Team A")) == {"repo-a"} + assert list(github_org.list_team_repos("Team A")) == ["repo-a"] fake_org.get_team("team-a").add_repo("repo-b", "admin") # Cached result is returned until the cache is bypassed - assert set(github_org.list_team_repos("Team A")) == {"repo-a"} + assert list(github_org.list_team_repos("Team A")) == ["repo-a"] refreshed = github_org.list_team_repos("Team A", ignore_cache=True) assert set(refreshed) == {"repo-a", "repo-b"} assert refreshed["repo-b"]["permission"] == "admin" +def test_list_team_repos_fields(github_org): + assert github_org.list_team_repos("Team A", fields="permission") == {"repo-a": "pull"} + + def test_add_team_repo(github_org, fake_org): assert github_org.add_team_repo("repo-b", "Team A", permission="push") is True assert fake_org.get_team("team-a").repos["repo-b"] == "push" @@ -163,10 +187,3 @@ def test_add_team_member_missing_user(github_org): def test_remove_team_member(github_org, fake_org): assert github_org.remove_team_member("user1", "Team A") is True assert "user1" not in fake_org.get_team("team-a").members - - -def test_remove_team_member_not_removed(github_org, monkeypatch): - # The API reports success, but the member is still present afterwards - monkeypatch.setattr(Team, "remove_member", lambda *args, **kwargs: None) - with pytest.raises(CommandExecutionError, match="Failed to remove member"): - github_org.remove_team_member("user1", "Team A") diff --git a/tests/functional/modules/github_repo/test_actions_secrets.py b/tests/functional/modules/github_repo/test_actions_secrets.py index 4e7a42fc..604be2b3 100644 --- a/tests/functional/modules/github_repo/test_actions_secrets.py +++ b/tests/functional/modules/github_repo/test_actions_secrets.py @@ -4,9 +4,9 @@ def test_list_actions_secrets(github_repo): ret = github_repo.list_actions_secrets() - assert [secret["name"] for secret in ret] == ["EXISTING_SECRET"] + assert list(ret) == ["EXISTING_SECRET"] # Secret values are write-only - assert "value" not in ret[0] + assert "value" not in ret["EXISTING_SECRET"] def test_get_actions_secret(github_repo): diff --git a/tests/functional/modules/github_repo/test_environments.py b/tests/functional/modules/github_repo/test_environments.py index af679140..82c5532d 100644 --- a/tests/functional/modules/github_repo/test_environments.py +++ b/tests/functional/modules/github_repo/test_environments.py @@ -6,7 +6,8 @@ def test_list_environments(github_repo): ret = github_repo.list_environments() - assert [env["name"] for env in ret] == ["release"] + assert list(ret) == ["release"] + assert ret["release"]["deployment_branch_policy"] == CUSTOM_POLICY def test_get_environment(github_repo): @@ -28,18 +29,11 @@ def test_set_environment_create(github_repo, fake_org): assert env.deployment_branch_policy is None -def test_set_environment_carries_over_policy(github_repo, fake_org): - # A PUT replaces the whole config, the wrapper is expected to carry - # over the current branch policy when it is not specified. +def test_set_environment_replaces_configuration(github_repo, fake_org): + # A PUT replaces the whole config, unspecified settings are reset github_repo.set_environment("release", wait_timer=10) env = fake_org.get_repo("repo-a").get_environment("release") assert env.wait_timer == 10 - assert env.deployment_branch_policy == CUSTOM_POLICY - - -def test_set_environment_reset_policy(github_repo, fake_org): - github_repo.set_environment("release", deployment_branch_policy=None) - env = fake_org.get_repo("repo-a").get_environment("release") assert env.deployment_branch_policy is None diff --git a/tests/functional/modules/github_repo/test_issues.py b/tests/functional/modules/github_repo/test_issues.py index 4684b5bd..ae35cc97 100644 --- a/tests/functional/modules/github_repo/test_issues.py +++ b/tests/functional/modules/github_repo/test_issues.py @@ -6,10 +6,23 @@ def test_list_issues(github_repo): ret = github_repo.list_issues() # Only open issues by default, pull requests are excluded assert [issue["title"] for issue in ret] == ["Found a bug"] - assert ret[0]["user"] == "user1" - assert ret[0]["assignee"] == "user2" - assert ret[0]["labels"] == ["bug", "prio:high"] - assert ret[0]["milestone"] == "v1.0" + assert ret[0]["number"] == 1 + assert ret[0]["user"]["login"] == "user1" + assert ret[0]["assignee"]["login"] == "user2" + assert [label["name"] for label in ret[0]["labels"]] == ["bug", "prio:high"] + assert ret[0]["milestone"]["title"] == "v1.0" + + +def test_list_issues_fields(github_repo): + assert github_repo.list_issues(fields="title") == ["Found a bug"] + assert github_repo.list_issues(fields=["number", "title"]) == [ + {"number": 1, "title": "Found a bug"} + ] + + +def test_list_issues_unknown_field(github_repo): + with pytest.raises(CommandExecutionError, match="Unknown field"): + github_repo.list_issues(fields="bogus") def test_list_issues_all(github_repo): @@ -17,12 +30,6 @@ def test_list_issues_all(github_repo): assert {issue["title"] for issue in ret} == {"Found a bug", "Stale issue"} -def test_list_issues_full_output(github_repo): - ret = github_repo.list_issues(output="full") - assert ret[0]["number"] == 1 - assert ret[0]["body"] == "" - - def test_list_issues_missing_repo(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): github_repo.list_issues(repo_name="ghost") @@ -31,11 +38,6 @@ def test_list_issues_missing_repo(github_repo): def test_get_issue(github_repo): ret = github_repo.get_issue(1) assert ret["title"] == "Found a bug" - assert ret["issue_number"] == 1 - - -def test_get_issue_full_output(github_repo): - ret = github_repo.get_issue(1, output="full") assert ret["number"] == 1 @@ -47,10 +49,5 @@ def test_get_issue_missing(github_repo): def test_get_issue_comments(github_repo): ret = github_repo.get_issue_comments(1) assert len(ret) == 2 - assert {comment["user_login"] for comment in ret} == {"user1", "user2"} - assert "body" not in ret[0] - - -def test_get_issue_comments_full_output(github_repo): - ret = github_repo.get_issue_comments(1, output="full") + assert {comment["user"]["login"] for comment in ret} == {"user1", "user2"} assert {comment["body"] for comment in ret} == {"Working on it", "Thanks!"} diff --git a/tests/functional/modules/github_repo/test_labels.py b/tests/functional/modules/github_repo/test_labels.py index 9066da5f..851cca6c 100644 --- a/tests/functional/modules/github_repo/test_labels.py +++ b/tests/functional/modules/github_repo/test_labels.py @@ -4,7 +4,12 @@ def test_list_labels(github_repo): ret = github_repo.list_labels() - assert [label["name"] for label in ret] == ["bug"] + assert list(ret) == ["bug"] + assert ret["bug"]["color"] == "d73a4a" + + +def test_list_labels_fields(github_repo): + assert github_repo.list_labels(fields="color") == {"bug": "d73a4a"} def test_get_label(github_repo): diff --git a/tests/functional/modules/github_repo/test_milestones.py b/tests/functional/modules/github_repo/test_milestones.py index 83054e3c..c0273c2d 100644 --- a/tests/functional/modules/github_repo/test_milestones.py +++ b/tests/functional/modules/github_repo/test_milestones.py @@ -5,12 +5,6 @@ def test_list_milestones(github_repo): ret = github_repo.list_milestones() assert [milestone["title"] for milestone in ret] == ["v1.0", "v2.0"] - # min output drops presentation fields - assert "creator" not in ret[0] - - -def test_list_milestones_full_output(github_repo): - ret = github_repo.list_milestones(output="full") assert ret[0]["creator"]["login"] == "user1" diff --git a/tests/functional/modules/github_repo/test_pages.py b/tests/functional/modules/github_repo/test_pages.py index 24d3e162..bbda08c8 100644 --- a/tests/functional/modules/github_repo/test_pages.py +++ b/tests/functional/modules/github_repo/test_pages.py @@ -38,6 +38,16 @@ def test_update_pages_https(github_repo, fake_org): assert fake_org.get_repo("repo-b").pages.https_enforced is True +def test_update_pages_cname(github_repo, fake_org): + assert github_repo.update_pages(cname="docs.example.com", repo_name="repo-b") is True + assert fake_org.get_repo("repo-b").pages.cname == "docs.example.com" + # Passing None removes the custom domain, omitting it leaves it alone + assert github_repo.update_pages(https_enforced=True, repo_name="repo-b") is True + assert fake_org.get_repo("repo-b").pages.cname == "docs.example.com" + assert github_repo.update_pages(cname=None, repo_name="repo-b") is True + assert fake_org.get_repo("repo-b").pages.cname is None + + def test_update_pages_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): github_repo.update_pages(build_type="workflow") diff --git a/tests/functional/modules/github_repo/test_pulls.py b/tests/functional/modules/github_repo/test_pulls.py index 37383046..c32c3de4 100644 --- a/tests/functional/modules/github_repo/test_pulls.py +++ b/tests/functional/modules/github_repo/test_pulls.py @@ -1,10 +1,6 @@ def test_list_pulls(github_repo): ret = github_repo.list_pulls() assert [pr_["title"] for pr_ in ret] == ["Fix the bug"] - assert ret[0]["user"] == "user2" - assert ret[0]["base_branch"] == "main" - - -def test_list_pulls_full_output(github_repo): - ret = github_repo.list_pulls(output="full") + assert ret[0]["user"]["login"] == "user2" + assert ret[0]["base"]["ref"] == "main" assert ret[0]["head"]["ref"] == "bugfix" diff --git a/tests/functional/modules/github_repo/test_repo.py b/tests/functional/modules/github_repo/test_repo.py index 42f0fe9c..7859a57a 100644 --- a/tests/functional/modules/github_repo/test_repo.py +++ b/tests/functional/modules/github_repo/test_repo.py @@ -27,7 +27,8 @@ def test_get_info_uses_cache(github_repo, fake_org): def test_get_teams(github_repo): ret = github_repo.get_teams() - assert [team["name"] for team in ret] == ["Team A"] + assert list(ret) == ["Team A"] + assert ret["Team A"]["permission"] == "pull" def test_get_teams_missing_repo(github_repo): diff --git a/tests/functional/states/github_org/test_member.py b/tests/functional/states/github_org/test_member.py index cd371b43..b75758a7 100644 --- a/tests/functional/states/github_org/test_member.py +++ b/tests/functional/states/github_org/test_member.py @@ -9,7 +9,7 @@ def test_member_present_invites_user(github_org, fake_github, fake_org, testmode assert ret.result is not False assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode - assert ret.changes["old"] is None + assert ret.changes == {"invited": "newuser"} if testmode: assert "would have been invited" in ret.comment with pytest.raises(NotFound): @@ -43,7 +43,7 @@ def test_member_absent_removes_member(github_org, fake_github, fake_org, testmod assert ret.result is not False assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode - assert ret.changes + assert ret.changes == {"removed": "user2"} if testmode: assert "would have been removed" in ret.comment fake_org.get_member("user2") diff --git a/tests/functional/states/github_org/test_ruleset.py b/tests/functional/states/github_org/test_ruleset.py index 2c2e40fa..44b8840f 100644 --- a/tests/functional/states/github_org/test_ruleset.py +++ b/tests/functional/states/github_org/test_ruleset.py @@ -29,8 +29,7 @@ def test_ruleset_present_converges_drift(github_org, fake_github, fake_org, test ) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes["old"] == {"enforcement": "active"} - assert ret.changes["new"] == {"enforcement": "evaluate"} + assert ret.changes == {"old": {"enforcement": "active"}, "new": {"enforcement": "evaluate"}} assert (len(fake_github.mutations()) == before) is testmode expected = "active" if testmode else "evaluate" assert fake_org.rulesets["org-rules"].enforcement == expected @@ -51,7 +50,7 @@ def test_ruleset_present_create(github_org, fake_github, fake_org, testmode): ) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes["old"] is None + assert ret.changes == {"created": "new-rules"} assert (len(fake_github.mutations()) == before) is testmode assert ("new-rules" in fake_org.rulesets) is not testmode @@ -61,7 +60,7 @@ def test_ruleset_present_create_failure(github_org): name="fresh-rules", ruleset_params={"enforcement": "active", "bogus_param": True} ) assert ret.result is False - assert "Failed to create ruleset" in ret.comment + assert "422" in ret.comment assert not ret.changes @@ -71,7 +70,7 @@ def test_ruleset_present_update_failure(github_org): name="org-rules", ruleset_params={"enforcement": "evaluate", "bogus_param": True} ) assert ret.result is False - assert "Failed to update ruleset" in ret.comment + assert "422" in ret.comment assert not ret.changes @@ -82,7 +81,7 @@ def test_ruleset_absent(github_org, fake_github, fake_org, testmode): assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode assert bool(list(fake_org.rulesets)) is testmode - assert ret.changes == {"old": "Ruleset org-rules exists", "new": "Ruleset org-rules deleted"} + assert ret.changes == {"deleted": "org-rules"} if testmode: assert "would have been deleted" in ret.comment else: diff --git a/tests/functional/states/github_org/test_team.py b/tests/functional/states/github_org/test_team.py index a5568fd7..c89776bc 100644 --- a/tests/functional/states/github_org/test_team.py +++ b/tests/functional/states/github_org/test_team.py @@ -6,18 +6,18 @@ def test_team_present_create(github_org, fake_github, fake_org, testmode): before = len(fake_github.mutations()) ret = github_org.team_present( - name="team-b", description="Team B", privacy="closed", members={"user1": {}}, test=testmode + name="team-b", description="Team B", privacy="closed", members=["user1"], test=testmode ) assert ret.result is not False assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode - assert "team" in ret.changes + assert ret.changes["created"] == "team-b" + # member additions are projected even for new teams + assert ret.changes["members_added"] == ["user1"] if testmode: - # member changes are not projected for new teams - assert "set to be created" in ret.comment + assert "would have been created" in ret.comment assert "team-b" not in fake_org.teams else: - assert "user1" in ret.changes created = fake_org.get_team("team-b") assert set(created.data.members) == {"user1"} @@ -28,7 +28,7 @@ def test_team_present_idempotent(github_org, fake_github, testmode): description="Awesome Team: Team A", privacy="closed", permission="pull", - members={"user1": {}}, + members=["user1"], test=testmode, ) assert ret.result is True @@ -36,6 +36,15 @@ def test_team_present_idempotent(github_org, fake_github, testmode): assert not fake_github.mutations() +def test_team_present_unspecified_settings_unmanaged(github_org, fake_github, testmode): + # Without explicit privacy/permission/members/repo_names, only + # existence is ensured + ret = github_org.team_present(name="Team A", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + def test_team_present_converges_drift(github_org, fake_github, fake_org, testmode): before = len(fake_github.mutations()) ret = github_org.team_present( @@ -43,40 +52,44 @@ def test_team_present_converges_drift(github_org, fake_github, fake_org, testmod description="Updated A", privacy="closed", permission="pull", - members={"user2": {}}, + members=["user2"], test=testmode, ) assert ret.result is not False assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode - assert "team" in ret.changes - assert "user1" in ret.changes # removed (managed members) - assert "user2" in ret.changes # added + assert ret.changes["old"] == {"description": "Awesome Team: Team A"} + assert ret.changes["new"] == {"description": "Updated A"} + assert ret.changes["members_added"] == ["user2"] + assert ret.changes["members_removed"] == ["user1"] team = fake_org.get_team("team-a") if testmode: - assert "set to be edited" in ret.comment - assert "User user2 set to be added" in ret.comment - assert "User user1 set to be removed" in ret.comment + assert "would have been updated" in ret.comment assert team.description == "Awesome Team: Team A" assert set(team.data.members) == {"user1"} else: assert team.description == "Updated A" assert set(team.data.members) == {"user2"} + rerun = github_org.team_present( + name="Team A", + description="Updated A", + privacy="closed", + permission="pull", + members=["user2"], + ) + assert rerun.result is True + assert not rerun.changes + def test_team_present_permission_privacy_drift(github_org, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github_org.team_present( - name="Team A", - description="Awesome Team: Team A", - privacy="secret", - permission="push", - test=testmode, - ) + ret = github_org.team_present(name="Team A", privacy="secret", permission="push", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode - assert "team" in ret.changes + assert ret.changes["old"] == {"privacy": "closed", "permission": "pull"} + assert ret.changes["new"] == {"privacy": "secret", "permission": "push"} team = fake_org.get_team("team-a") if testmode: assert team.permission == "pull" @@ -86,32 +99,26 @@ def test_team_present_permission_privacy_drift(github_org, fake_github, fake_org assert team.privacy == "secret" -def test_team_present_create_failure(github_org): - # "team a" is not in the team cache (keyed by name), but its slug - # collides with the existing "Team A" on creation - ret = github_org.team_present(name="team a") - assert ret.result is False - assert "Failed to create team" in ret.comment +def test_team_present_name_case_insensitive(github_org, fake_github, testmode): + # "team a" resolves to the existing "Team A" since team identity + # (the slug) is case-insensitive + ret = github_org.team_present(name="team a", test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() def test_team_present_manages_repos(github_org, fake_github, fake_org, testmode): before = len(fake_github.mutations()) - ret = github_org.team_present( - name="Team A", - description="Awesome Team: Team A", - privacy="closed", - repo_names=["repo-b"], - test=testmode, - ) + ret = github_org.team_present(name="Team A", repo_names=["repo-b"], test=testmode) assert ret.result is not False assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode - assert "repo-b" in ret.changes # added - assert "repo-a" in ret.changes # removed (not listed) + assert ret.changes["repos_added"] == ["repo-b"] + assert ret.changes["repos_removed"] == ["repo-a"] team = fake_org.get_team("team-a") if testmode: - assert "repos added" in ret.comment - assert "repos removed" in ret.comment + assert "would have been updated" in ret.comment assert "repo-a" in team.repos assert "repo-b" not in team.repos else: @@ -119,84 +126,26 @@ def test_team_present_manages_repos(github_org, fake_github, fake_org, testmode) assert "repo-a" not in team.repos +def test_team_present_repos_case_insensitive(github_org, fake_github, testmode): + # Repository names are compared case-insensitively + ret = github_org.team_present(name="Team A", repo_names=["Repo-A"], test=testmode) + assert ret.result is True + assert not ret.changes + assert not fake_github.mutations() + + def test_team_present_add_repo_failure(github_org): - ret = github_org.team_present( - name="Team A", - description="Awesome Team: Team A", - privacy="closed", - repo_names=["ghost"], - ) + ret = github_org.team_present(name="Team A", repo_names=["ghost"]) assert ret.result is False - assert "Failed to add repo ghost" in ret.comment + assert not ret.changes + assert "Not Found" in ret.comment def test_team_present_add_member_failure(github_org): - ret = github_org.team_present( - name="Team A", - description="Awesome Team: Team A", - privacy="closed", - members={"user1": {}, "ghost": {}}, - ) + ret = github_org.team_present(name="Team A", members=["user1", "ghost"]) assert ret.result is False - assert "Failed to add user ghost" in ret.comment - - -def test_team_present_enforce_mfa_blocks_addition(github_org, fake_github, fake_org, testmode): - # user2 has no MFA and is not in the team: they are not added, which - # test mode explains in a comment - before = len(fake_github.mutations()) - ret = github_org.team_present( - name="Team A", - description="Awesome Team: Team A", - privacy="closed", - permission="pull", - members={"user1": {}, "user2": {}}, - enforce_mfa=True, - test=testmode, - ) - assert ret.result is True # nothing else changes in either mode - assert len(fake_github.mutations()) == before - assert set(fake_org.get_team("team-a").data.members) == {"user1"} - assert ("will not be added to the team because they do not have MFA" in ret.comment) is testmode - - -def test_team_present_enforce_mfa_removal(github_org, fake_github, fake_org, testmode): - # user2 has no MFA and is already in the team - fake_org.get_team("team-a").add_member("user2") - before = len(fake_github.mutations()) - ret = github_org.team_present( - name="Team A", - description="Awesome Team: Team A", - privacy="closed", - permission="pull", - members={"user1": {}, "user2": {}}, - enforce_mfa=True, - test=testmode, - ) - assert ret.result is not False - assert (ret.result is None) is testmode - assert (len(fake_github.mutations()) == before) is testmode - assert "due to MFA violation" in ret.changes["user2"]["new"] - team = fake_org.get_team("team-a") - if testmode: - assert "set to be removed from the team because they do not have MFA" in ret.comment - assert "user2" in team.members - else: - assert "user2" not in team.members - - -def test_team_present_mfa_exempt_member(github_org, fake_org): - # mfa_exempt members are added even when MFA is enforced - ret = github_org.team_present( - name="Team A", - description="Awesome Team: Team A", - privacy="closed", - members={"user1": {}, "user2": {"mfa_exempt": True}}, - enforce_mfa=True, - ) - assert ret.result is True - assert "user2" in ret.changes - assert "user2" in fake_org.get_team("team-a").members + assert not ret.changes + assert "Not Found" in ret.comment def test_team_absent(github_org, fake_github, fake_org, testmode): @@ -205,7 +154,7 @@ def test_team_absent(github_org, fake_github, fake_org, testmode): assert ret.result is not False assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode - assert ret.changes == {"old": "Team Team A exists", "new": "Team Team A deleted"} + assert ret.changes == {"deleted": "Team A"} if testmode: assert "would have been deleted" in ret.comment fake_org.get_team("team-a") diff --git a/tests/functional/states/github_repo/test_actions_secret.py b/tests/functional/states/github_repo/test_actions_secret.py index 5f27271c..162429a4 100644 --- a/tests/functional/states/github_repo/test_actions_secret.py +++ b/tests/functional/states/github_repo/test_actions_secret.py @@ -3,7 +3,7 @@ def test_actions_secret_present_create(github_repo, fake_github, fake_org, testm ret = github_repo.actions_secret_present(name="NEW_SECRET", value="s3cr3t", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes + assert ret.changes == {"created": "NEW_SECRET"} # The value must never leak into the state output assert "s3cr3t" not in str(ret.changes) + ret.comment assert (len(fake_github.mutations()) == before) is testmode @@ -34,7 +34,7 @@ def test_actions_secret_present_force(github_repo, fake_github, fake_org, testmo ) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes + assert ret.changes == {"updated": "EXISTING_SECRET"} assert "rotated" not in str(ret.changes) + ret.comment assert (len(fake_github.mutations()) == before) is testmode expected = "hunter2" if testmode else "rotated" @@ -51,7 +51,7 @@ def test_actions_secret_present_requires_value(github_repo, fake_github): def test_actions_secret_present_missing_repo(github_repo): ret = github_repo.actions_secret_present(name="NEW_SECRET", value="s3cr3t", repo_name="ghost") assert ret.result is False - assert "Failed to list Actions secrets" in ret.comment + assert "Not Found" in ret.comment def test_actions_secret_absent(github_repo, fake_github, fake_org, testmode): @@ -59,7 +59,7 @@ def test_actions_secret_absent(github_repo, fake_github, fake_org, testmode): ret = github_repo.actions_secret_absent(name="existing_secret", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes + assert ret.changes == {"deleted": "EXISTING_SECRET"} assert (len(fake_github.mutations()) == before) is testmode assert ("EXISTING_SECRET" in fake_org.get_repo("repo-a").secrets) is testmode if not testmode: diff --git a/tests/functional/states/github_repo/test_deploy_key.py b/tests/functional/states/github_repo/test_deploy_key.py index 120577c8..78cf5c5d 100644 --- a/tests/functional/states/github_repo/test_deploy_key.py +++ b/tests/functional/states/github_repo/test_deploy_key.py @@ -7,7 +7,7 @@ def test_deploy_key_present_create(github_repo, fake_github, fake_org, testmode) ret = github_repo.deploy_key_present(name="autorelease", key=DEPLOY_KEY_B, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes["old"] is None + assert ret.changes == {"created": "autorelease"} assert (len(fake_github.mutations()) == before) is testmode repo = fake_org.get_repo("repo-a") added = [key for key in repo.deploy_keys if key.title == "autorelease"] @@ -75,7 +75,7 @@ def test_deploy_key_present_replaces_changed_read_only( def test_deploy_key_present_missing_repo(github_repo): ret = github_repo.deploy_key_present(name="autorelease", key=DEPLOY_KEY_B, repo_name="ghost") assert ret.result is False - assert "Failed to list deploy keys" in ret.comment + assert "Not Found" in ret.comment def test_deploy_key_absent(github_repo, fake_github, fake_org, testmode): @@ -83,7 +83,7 @@ def test_deploy_key_absent(github_repo, fake_github, fake_org, testmode): ret = github_repo.deploy_key_absent(name="existing-key", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes + assert ret.changes == {"deleted": "existing-key"} assert (len(fake_github.mutations()) == before) is testmode assert bool(fake_org.get_repo("repo-a").deploy_keys) is testmode if not testmode: diff --git a/tests/functional/states/github_repo/test_environment.py b/tests/functional/states/github_repo/test_environment.py index 004a1d41..ceedb7c9 100644 --- a/tests/functional/states/github_repo/test_environment.py +++ b/tests/functional/states/github_repo/test_environment.py @@ -7,7 +7,7 @@ def test_environment_present_create(github_repo, fake_github, fake_org, testmode ret = github_repo.environment_present(name="staging", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert "environment" in ret.changes + assert ret.changes == {"created": "staging"} assert (len(fake_github.mutations()) == before) is testmode assert ("staging" in fake_org.get_repo("repo-a").environments) is not testmode @@ -19,8 +19,8 @@ def test_environment_present_create_with_branches(github_repo, fake_github, fake ) assert ret.result is not False assert (ret.result is None) is testmode - assert "environment" in ret.changes - assert ret.changes["deployment_branches"]["new"] == ["main", "staging/*"] + assert ret.changes["created"] == "staging" + assert ret.changes["deployment_branches"] == {"added": ["main", "staging/*"], "removed": []} assert (len(fake_github.mutations()) == before) is testmode if testmode: assert "staging" not in fake_org.get_repo("repo-a").environments @@ -51,7 +51,7 @@ def test_environment_present_branch_pattern_drift(github_repo, fake_github, fake ) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes == {"deployment_branches": {"old": ["main"], "new": ["hotfix/*", "main"]}} + assert ret.changes == {"deployment_branches": {"added": ["hotfix/*"], "removed": []}} assert (len(fake_github.mutations()) == before) is testmode env = fake_org.get_repo("repo-a").get_environment("release") expected = {"main"} if testmode else {"main", "hotfix/*"} @@ -64,7 +64,7 @@ def test_environment_present_removes_stale_patterns(github_repo, fake_org, testm ) assert ret.result is not False assert (ret.result is None) is testmode - assert "deployment_branches" in ret.changes + assert ret.changes == {"deployment_branches": {"added": ["develop"], "removed": ["main"]}} env = fake_org.get_repo("repo-a").get_environment("release") expected = {"main"} if testmode else {"develop"} assert {policy.name for policy in env.branch_policies} == expected @@ -77,7 +77,7 @@ def test_environment_present_wait_timer_drift_keeps_policy( ret = github_repo.environment_present(name="release", wait_timer=30, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes == {"wait_timer": {"old": 0, "new": 30}} + assert ret.changes == {"old": {"wait_timer": 0}, "new": {"wait_timer": 30}} assert (len(fake_github.mutations()) == before) is testmode env = fake_org.get_repo("repo-a").get_environment("release") if not testmode: @@ -93,7 +93,7 @@ def test_environment_present_protected_branches(github_repo, fake_org, testmode) ) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes["deployment_branch_policy"]["new"] == PROTECTED_POLICY + assert ret.changes["new"] == {"deployment_branch_policy": PROTECTED_POLICY} env = fake_org.get_repo("repo-a").get_environment("release") expected = CUSTOM_POLICY if testmode else PROTECTED_POLICY assert env.deployment_branch_policy == expected @@ -103,7 +103,7 @@ def test_environment_present_all_branches(github_repo, fake_org, testmode): ret = github_repo.environment_present(name="release", deployment_branches="all", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes["deployment_branch_policy"]["new"] is None + assert ret.changes["new"] == {"deployment_branch_policy": None} env = fake_org.get_repo("repo-a").get_environment("release") expected = CUSTOM_POLICY if testmode else None assert env.deployment_branch_policy == expected @@ -114,7 +114,7 @@ def test_environment_present_reviewers(github_repo, fake_org, testmode): ret = github_repo.environment_present(name="release", reviewers=reviewers, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes["reviewers"]["new"] == reviewers + assert ret.changes["new"] == {"reviewers": reviewers} if not testmode: assert fake_org.get_repo("repo-a").get_environment("release").reviewers == reviewers rerun = github_repo.environment_present(name="release", reviewers=reviewers) @@ -134,7 +134,7 @@ def test_environment_absent(github_repo, fake_github, fake_org, testmode): ret = github_repo.environment_absent(name="release", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes + assert ret.changes == {"deleted": "release"} assert (len(fake_github.mutations()) == before) is testmode assert ("release" in fake_org.get_repo("repo-a").environments) is testmode if not testmode: diff --git a/tests/functional/states/github_repo/test_label.py b/tests/functional/states/github_repo/test_label.py index 0b1796c4..94b7ccbc 100644 --- a/tests/functional/states/github_repo/test_label.py +++ b/tests/functional/states/github_repo/test_label.py @@ -5,7 +5,7 @@ def test_label_present_create(github_repo, fake_github, fake_org, testmode): ) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes["old"] is None + assert ret.changes == {"created": "release"} assert (len(fake_github.mutations()) == before) is testmode repo = fake_org.get_repo("repo-a") if testmode: @@ -67,7 +67,7 @@ def test_label_present_missing_repo(github_repo): # so the failure surfaces during the creation attempt ret = github_repo.label_present(name="bug", repo_name="ghost") assert ret.result is False - assert "Failed to create label" in ret.comment + assert "Not Found" in ret.comment def test_label_absent(github_repo, fake_github, fake_org, testmode): @@ -75,7 +75,7 @@ def test_label_absent(github_repo, fake_github, fake_org, testmode): ret = github_repo.label_absent(name="bug", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes + assert ret.changes == {"deleted": "bug"} assert (len(fake_github.mutations()) == before) is testmode assert ("bug" in fake_org.get_repo("repo-a").labels) is testmode if not testmode: diff --git a/tests/functional/states/github_repo/test_pages.py b/tests/functional/states/github_repo/test_pages.py index f6c2859c..c3657759 100644 --- a/tests/functional/states/github_repo/test_pages.py +++ b/tests/functional/states/github_repo/test_pages.py @@ -3,7 +3,7 @@ def test_pages_present_enable(github_repo, fake_github, fake_org, testmode): ret = github_repo.pages_present(name="repo-a", build_type="workflow", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes["old"] is None + assert ret.changes == {"created": "repo-a"} assert (len(fake_github.mutations()) == before) is testmode pages = fake_org.get_repo("repo-a").pages if testmode: @@ -56,12 +56,23 @@ def test_pages_present_source_drift(github_repo, fake_org, testmode): assert fake_org.get_repo("repo-b").pages.source["branch"] == expected +def test_pages_present_cname(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.pages_present(name="repo-b", cname="docs.example.com", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"old": {"cname": None}, "new": {"cname": "docs.example.com"}} + assert (len(fake_github.mutations()) == before) is testmode + expected = None if testmode else "docs.example.com" + assert fake_org.get_repo("repo-b").pages.cname == expected + + def test_pages_present_missing_repo(github_repo): # A missing repo is indistinguishable from a missing Pages site (404), # so the failure surfaces during the creation attempt ret = github_repo.pages_present(name="ghost") assert ret.result is False - assert "Failed to enable Pages" in ret.comment + assert "Not Found" in ret.comment def test_pages_absent(github_repo, fake_github, fake_org, testmode): @@ -69,7 +80,7 @@ def test_pages_absent(github_repo, fake_github, fake_org, testmode): ret = github_repo.pages_absent(name="repo-b", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes + assert ret.changes == {"deleted": "repo-b"} assert (len(fake_github.mutations()) == before) is testmode assert (fake_org.get_repo("repo-b").pages is not None) is testmode if not testmode: diff --git a/tests/functional/states/github_repo/test_repo.py b/tests/functional/states/github_repo/test_repo.py index 4579281f..1e5138ee 100644 --- a/tests/functional/states/github_repo/test_repo.py +++ b/tests/functional/states/github_repo/test_repo.py @@ -3,18 +3,12 @@ from tests.support.github import NotFound -@pytest.fixture -def _instant_sleep(monkeypatch): - # present waits for repo creation to settle; pointless against the fake - monkeypatch.setattr("time.sleep", lambda _seconds: None) - - -def test_present_create(github_repo, fake_github, fake_org, _instant_sleep, testmode): +def test_present_create(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) ret = github_repo.present(name="new-repo", description="Fancy", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert "repo" in ret.changes + assert ret.changes == {"created": "new-repo"} assert (len(fake_github.mutations()) == before) is testmode if testmode: with pytest.raises(NotFound): @@ -34,7 +28,10 @@ def test_present_converges_drift(github_repo, fake_org, testmode): ret = github_repo.present(name="repo-a", description="Managed description", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert "repo" in ret.changes + assert ret.changes == { + "old": {"description": "Test repo repo-a"}, + "new": {"description": "Managed description"}, + } expected = "Test repo repo-a" if testmode else "Managed description" assert fake_org.get_repo("repo-a").description == expected if not testmode: @@ -48,7 +45,10 @@ def test_present_default_branch(github_repo, fake_github, fake_org, testmode): ret = github_repo.present(name="repo-a", default_branch="develop", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert "repo" in ret.changes + assert ret.changes == { + "old": {"default_branch": "main"}, + "new": {"default_branch": "develop"}, + } assert (len(fake_github.mutations()) == before) is testmode expected = "main" if testmode else "develop" assert fake_org.get_repo("repo-a").default_branch == expected @@ -61,24 +61,23 @@ def test_present_default_branch_idempotent(github_repo, fake_github, testmode): assert not fake_github.mutations() -def test_present_create_ignores_default_branch(github_repo, fake_org, _instant_sleep, testmode): +def test_present_create_ignores_default_branch(github_repo, fake_org, testmode): # The default branch can only be switched to an existing branch, # so it is not applied on repository creation ret = github_repo.present(name="new-repo", default_branch="develop", test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert "repo" in ret.changes + assert ret.changes == {"created": "new-repo"} if not testmode: assert fake_org.get_repo("new-repo").default_branch == "main" -def test_present_create_with_teams(github_repo, fake_github, fake_org, _instant_sleep, testmode): +def test_present_create_with_teams(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) ret = github_repo.present(name="new-repo", teams={"Team A": "push"}, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert "repo" in ret.changes - assert "Team A" in ret.changes + assert ret.changes == {"created": "new-repo", "teams_added": ["Team A"]} assert (len(fake_github.mutations()) == before) is testmode if testmode: assert "new-repo" not in fake_org.repos @@ -91,7 +90,7 @@ def test_present_adds_repo_to_team(github_repo, fake_github, fake_org, testmode) ret = github_repo.present(name="repo-b", teams={"Team A": "push"}, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert "Team A" in ret.changes + assert ret.changes == {"teams_added": ["Team A"]} assert (len(fake_github.mutations()) == before) is testmode team = fake_org.get_team("team-a") if testmode: @@ -105,7 +104,7 @@ def test_present_team_permission_change(github_repo, fake_github, fake_org, test ret = github_repo.present(name="repo-a", teams={"Team A": "push"}, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert "Team A" in ret.changes + assert ret.changes == {"team_permissions": {"Team A": {"old": "pull", "new": "push"}}} assert (len(fake_github.mutations()) == before) is testmode expected = "pull" if testmode else "push" assert fake_org.get_team("team-a").repos["repo-a"] == expected @@ -125,7 +124,7 @@ def test_present_team_removal(github_repo, fake_github, fake_org, testmode): ret = github_repo.present(name="repo-a", teams={}, test=testmode) assert ret.result is not False assert (ret.result is None) is testmode - assert "Team A" in ret.changes + assert ret.changes == {"teams_removed": ["Team A"]} assert (len(fake_github.mutations()) == before) is testmode assert ("repo-a" in fake_org.get_team("team-a").repos) is testmode @@ -133,7 +132,8 @@ def test_present_team_removal(github_repo, fake_github, fake_org, testmode): def test_present_missing_team(github_repo): ret = github_repo.present(name="repo-b", teams={"Ghost": "pull"}) assert ret.result is False - assert "Failed" in ret.comment + assert not ret.changes + assert "does not exist" in ret.comment def test_absent(github_repo, fake_github, fake_org, testmode): @@ -142,7 +142,7 @@ def test_absent(github_repo, fake_github, fake_org, testmode): assert ret.result is not False assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode - assert ret.changes == {"old": "Repo repo-a exists", "new": "Repo repo-a deleted"} + assert ret.changes == {"deleted": "repo-a"} if testmode: assert "would have been deleted" in ret.comment fake_org.get_repo("repo-a") diff --git a/tests/functional/states/github_repo/test_ruleset.py b/tests/functional/states/github_repo/test_ruleset.py index bb8c1b01..36dc6c68 100644 --- a/tests/functional/states/github_repo/test_ruleset.py +++ b/tests/functional/states/github_repo/test_ruleset.py @@ -52,7 +52,7 @@ def test_ruleset_present_create(github_repo, fake_github, fake_org, testmode): ) assert ret.result is not False assert (ret.result is None) is testmode - assert ret.changes["old"] is None + assert ret.changes == {"created": "new-rules"} assert (len(fake_github.mutations()) == before) is testmode assert ("new-rules" in fake_org.get_repo("repo-b").rulesets) is not testmode @@ -62,7 +62,7 @@ def test_ruleset_present_list_error(github_repo): name="x", repo_name="ghost", ruleset_params={"enforcement": "active"} ) assert ret.result is False - assert "Failed to list rulesets" in ret.comment + assert "Not Found" in ret.comment def test_ruleset_present_update_failure(github_repo): @@ -71,7 +71,7 @@ def test_ruleset_present_update_failure(github_repo): name="repo-rules", ruleset_params={"enforcement": "active", "bogus_param": True} ) assert ret.result is False - assert "Failed to update ruleset" in ret.comment + assert "422" in ret.comment assert not ret.changes @@ -82,7 +82,7 @@ def test_ruleset_absent(github_repo, fake_github, fake_org, testmode): assert (ret.result is None) is testmode assert (len(fake_github.mutations()) == before) is testmode assert bool(list(fake_org.get_repo("repo-a").rulesets)) is testmode - assert ret.changes == {"old": "Ruleset repo-rules exists", "new": "Ruleset repo-rules deleted"} + assert ret.changes == {"deleted": "repo-rules"} if not testmode: rerun = github_repo.ruleset_absent(name="repo-rules") assert rerun.result is True @@ -99,4 +99,4 @@ def test_ruleset_absent_missing(github_repo, fake_github, testmode): def test_ruleset_absent_list_error(github_repo): ret = github_repo.ruleset_absent(name="x", repo_name="ghost") assert ret.result is False - assert "Failed to list rulesets" in ret.comment + assert "Not Found" in ret.comment diff --git a/tests/functional/states/github_repo/test_workflow_permissions.py b/tests/functional/states/github_repo/test_workflow_permissions.py index 6f624c8e..1332fc82 100644 --- a/tests/functional/states/github_repo/test_workflow_permissions.py +++ b/tests/functional/states/github_repo/test_workflow_permissions.py @@ -73,4 +73,4 @@ def test_workflow_permissions_managed_missing_repo(github_repo): name="ghost", default_workflow_permissions="read" ) assert ret.result is False - assert "Failed to get workflow permissions" in ret.comment + assert "Not Found" in ret.comment diff --git a/tests/support/github/entities.py b/tests/support/github/entities.py index 7a506ab1..1a73bdb6 100644 --- a/tests/support/github/entities.py +++ b/tests/support/github/entities.py @@ -731,6 +731,7 @@ def payload(self, /, **ovrr): "watchers_count": 1, "watchers": 1, "forks": 0, + "forks_count": 0, "open_issues": 0, "open_issues_count": 0, } diff --git a/tests/support/github/state.py b/tests/support/github/state.py index 33526b1d..34e1d01a 100644 --- a/tests/support/github/state.py +++ b/tests/support/github/state.py @@ -9,6 +9,7 @@ from copy import deepcopy from dataclasses import dataclass from dataclasses import field +from dataclasses import fields from tests.support.github.entities import DeployKey from tests.support.github.entities import DeployKeyData @@ -82,71 +83,21 @@ class GlobalState: # pylint: disable=too-many-instance-attributes def __post_init__(self): self.snapshot() + def _stores(self): + return [fld.name for fld in fields(self) if fld.name != "base_url"] + def snapshot(self): - self._init_orgs = deepcopy(self.orgs) - self._init_users = deepcopy(self.users) - self._init_repos = deepcopy(self.repos) - self._init_members = deepcopy(self.members) - self._init_teams = deepcopy(self.teams) - self._init_rulesets_org = deepcopy(self.rulesets_org) - self._init_rulesets_repo = deepcopy(self.rulesets_repo) - self._init_milestones = deepcopy(self.milestones) - self._init_issues = deepcopy(self.issues) - self._init_pulls = deepcopy(self.pulls) - self._init_deploy_keys = deepcopy(self.deploy_keys) - self._init_secrets = deepcopy(self.secrets) - self._init_environments = deepcopy(self.environments) - self._init_labels = deepcopy(self.labels) - self._init_pages = deepcopy(self.pages) + self._snapshot = {name: deepcopy(getattr(self, name)) for name in self._stores()} def reset(self): - self.orgs = {} - self.users = {} - self.repos = {} - self.members = {} - self.teams = {} - self.rulesets_org = defaultdict(dict) - self.rulesets_repo = defaultdict(dict) - self.milestones = defaultdict(dict) - self.issues = defaultdict(dict) - self.pulls = defaultdict(dict) - self.deploy_keys = defaultdict(dict) - self.secrets = defaultdict(dict) - self.environments = defaultdict(dict) - self.labels = defaultdict(dict) - self.pages = {} - self._init_orgs = {} - self._init_users = {} - self._init_repos = {} - self._init_members = {} - self._init_teams = {} - self._init_rulesets_org = defaultdict(dict) - self._init_rulesets_repo = defaultdict(dict) - self._init_milestones = defaultdict(dict) - self._init_issues = defaultdict(dict) - self._init_pulls = defaultdict(dict) - self._init_deploy_keys = defaultdict(dict) - self._init_secrets = defaultdict(dict) - self._init_environments = defaultdict(dict) - self._init_labels = defaultdict(dict) - self._init_pages = {} + for fld in fields(self): + if fld.name != "base_url": + setattr(self, fld.name, fld.default_factory()) + self.snapshot() def restore(self): - self.orgs = deepcopy(self._init_orgs) - self.users = deepcopy(self._init_users) - self.repos = deepcopy(self._init_repos) - self.members = deepcopy(self._init_members) - self.teams = deepcopy(self._init_teams) - self.rulesets_org = deepcopy(self._init_rulesets_org) - self.rulesets_repo = deepcopy(self._init_rulesets_repo) - self.milestones = deepcopy(self._init_milestones) - self.issues = deepcopy(self._init_issues) - self.pulls = deepcopy(self._init_pulls) - self.deploy_keys = deepcopy(self._init_deploy_keys) - self.secrets = deepcopy(self._init_secrets) - self.environments = deepcopy(self._init_environments) - self.labels = deepcopy(self._init_labels) - self.pages = deepcopy(self._init_pages) + for name, value in self._snapshot.items(): + setattr(self, name, deepcopy(value)) def _next_issue_number(self, repo_id: RepoId) -> int: # Issues and pull requests share one number sequence per repo. @@ -199,112 +150,61 @@ def get_repo(self, repo_id: RepoId | Sequence[str] | str, *, exc=NotFound) -> Re raise exc(f"Requested nonexistent repo {resolved.name=} from org {resolved.owner=}") return Repo(self, self.repos[resolved]) - def add(self, entity: EntityData | Entity): # pylint: disable=too-many-return-statements - if isinstance(entity, Entity): - data = entity.data + def add(self, entity: EntityData | Entity): + data = entity.data if isinstance(entity, Entity) else entity + + # Auto-numbering for issue-like entities + if isinstance(data, MilestoneData) and data.number < 0: + data = data.with_(number=len(self.milestones[data.repo]) + 1) + elif isinstance(data, (IssueData, PullRequestData)) and data.number < 0: + data = data.with_(number=self._next_issue_number(data.repo)) + + spec = _STORES.get(type(data)) + if spec is None: + raise TypeError(f"Unknown object: {data=}") + entity_cls, store_name, outer_key, inner_key, parent_store = spec + if parent_store is not None: + assert outer_key(data) in getattr(self, parent_store) + store = getattr(self, store_name) + if inner_key is None: + store[outer_key(data)] = data else: - data = entity - - if isinstance(data, RepoData): - self.repos[data.repo_id] = data - return Repo(self, data) - if isinstance(data, MemberData): - self.members[(data.org, data.login)] = data - return Member(self, data) - if isinstance(data, TeamData): - self.teams[(data.org, data.slug)] = data - return Team(self, data) - if isinstance(data, RepoRulesetData): - assert data.source in self.repos - self.rulesets_repo[data.source][data.id] = data - return Ruleset(self, data) - if isinstance(data, OrgRulesetData): - assert data.source in self.orgs - self.rulesets_org.setdefault(data.source, {})[data.id] = data - return Ruleset(self, data) - if isinstance(data, DeployKeyData): - assert data.repo in self.repos - self.deploy_keys[data.repo][data.id] = data - return DeployKey(self, data) - if isinstance(data, SecretData): - assert data.repo in self.repos - self.secrets[data.repo][data.name] = data - return Secret(self, data) - if isinstance(data, EnvironmentData): - assert data.repo in self.repos - self.environments[data.repo][data.name] = data - return Environment(self, data) - if isinstance(data, LabelData): - assert data.repo in self.repos - self.labels[data.repo][data.name] = data - return Label(self, data) - if isinstance(data, PagesData): - assert data.repo in self.repos - self.pages[data.repo] = data - return Pages(self, data) - if isinstance(data, MilestoneData): - if data.number < 0: - data = data.with_(number=len(self.milestones[data.repo]) + 1) - self.milestones[data.repo][data.number] = data - return Milestone(self, data) - if isinstance(data, IssueData): - if data.number < 0: - data = data.with_(number=self._next_issue_number(data.repo)) - self.issues[data.repo][data.number] = data - return Issue(self, data) - if isinstance(data, PullRequestData): - if data.number < 0: - data = data.with_(number=self._next_issue_number(data.repo)) - self.pulls[data.repo][data.number] = data - return PullRequest(self, data) - if isinstance(data, UserData): - self.users[data.login] = data - return User(self, data) - if isinstance(data, OrganizationData): - self.orgs[data.login] = data - return Organization(self, data) - raise TypeError(f"Unknown object: {data=}") + store[outer_key(data)][inner_key(data)] = data + return entity_cls(self, data) def replace(self, old: EntityData | Entity, new: EntityData | Entity): - if isinstance(old, Entity): - old_data = old.data - else: - old_data = old - if isinstance(new, Entity): - new_data = new.data - else: - new_data = new + old_data = old.data if isinstance(old, Entity) else old + new_data = new.data if isinstance(new, Entity) else new assert type(old_data) is type(new_data) - if isinstance(old_data, RepoData): - self.repos.pop(old_data.repo_id) - elif isinstance(old_data, MemberData): - self.members.pop((old_data.org, old_data.login)) - elif isinstance(old_data, TeamData): - self.teams.pop((old_data.org, old_data.slug)) - elif isinstance(old_data, RepoRulesetData): - self.rulesets_repo[old_data.source].pop(old_data.id) - elif isinstance(old_data, OrgRulesetData): - self.rulesets_org[old_data.source].pop(old_data.id) - elif isinstance(old_data, DeployKeyData): - self.deploy_keys[old_data.repo].pop(old_data.id) - elif isinstance(old_data, SecretData): - self.secrets[old_data.repo].pop(old_data.name) - elif isinstance(old_data, EnvironmentData): - self.environments[old_data.repo].pop(old_data.name) - elif isinstance(old_data, LabelData): - self.labels[old_data.repo].pop(old_data.name) - elif isinstance(old_data, PagesData): - self.pages.pop(old_data.repo) - elif isinstance(old_data, MilestoneData): - self.milestones[old_data.repo].pop(old_data.number) - elif isinstance(old_data, IssueData): - self.issues[old_data.repo].pop(old_data.number) - elif isinstance(old_data, PullRequestData): - self.pulls[old_data.repo].pop(old_data.number) - elif isinstance(old_data, UserData): - self.users.pop(old_data.login) - elif isinstance(old_data, OrganizationData): - self.orgs.pop(old_data.login) - else: + spec = _STORES.get(type(old_data)) + if spec is None: raise TypeError(f"Unknown object: {old_data=}") + _, store_name, outer_key, inner_key, _ = spec + store = getattr(self, store_name) + if inner_key is None: + store.pop(outer_key(old_data)) + else: + store[outer_key(old_data)].pop(inner_key(old_data)) return self.add(new_data) + + +# EntityData type -> (entity class, store attribute, outer key, inner key, +# store the outer key must exist in). Two-level stores are scoped by the +# outer key, flat ones use it directly. +_STORES = { + OrganizationData: (Organization, "orgs", lambda d: d.login, None, None), + UserData: (User, "users", lambda d: d.login, None, None), + RepoData: (Repo, "repos", lambda d: d.repo_id, None, None), + MemberData: (Member, "members", lambda d: (d.org, d.login), None, None), + TeamData: (Team, "teams", lambda d: (d.org, d.slug), None, None), + OrgRulesetData: (Ruleset, "rulesets_org", lambda d: d.source, lambda d: d.id, "orgs"), + RepoRulesetData: (Ruleset, "rulesets_repo", lambda d: d.source, lambda d: d.id, "repos"), + MilestoneData: (Milestone, "milestones", lambda d: d.repo, lambda d: d.number, "repos"), + IssueData: (Issue, "issues", lambda d: d.repo, lambda d: d.number, "repos"), + PullRequestData: (PullRequest, "pulls", lambda d: d.repo, lambda d: d.number, "repos"), + DeployKeyData: (DeployKey, "deploy_keys", lambda d: d.repo, lambda d: d.id, "repos"), + SecretData: (Secret, "secrets", lambda d: d.repo, lambda d: d.name, "repos"), + EnvironmentData: (Environment, "environments", lambda d: d.repo, lambda d: d.name, "repos"), + LabelData: (Label, "labels", lambda d: d.repo, lambda d: d.name, "repos"), + PagesData: (Pages, "pages", lambda d: d.repo, None, "repos"), +} From e5adc4c40f55b1ea1d203e8568679430749737f9 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Mon, 12 Oct 2026 22:06:52 +0200 Subject: [PATCH 4/6] Fix pre-commit in CI --- src/saltext/github/states/github_org.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/saltext/github/states/github_org.py b/src/saltext/github/states/github_org.py index 72f02190..0ee223c1 100644 --- a/src/saltext/github/states/github_org.py +++ b/src/saltext/github/states/github_org.py @@ -35,7 +35,11 @@ def member_present(name, profile="github"): try: info = __salt__["github_org.get_member"](name, profile=profile) except GitHubNotFoundError as err: - raise CommandExecutionError(f"User {name} does not exist") from err + # astroid on Python 3.10 fails to infer `err` as an exception + # FIXME: Remove this once CI lint runs on a more recent Python version + raise CommandExecutionError( # pylint: disable=bad-exception-cause + f"User {name} does not exist" + ) from err if info["in_org"]: ret["comment"] = f"User {name} is already a member of the organization" From 025d1b277de5cd616ec9edd3c73d2c41d11713fc Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 13 Oct 2026 01:05:58 +0200 Subject: [PATCH 5/6] Support user repositories --- changelog/+user_owned_repos.added.md | 2 +- src/saltext/github/modules/github.py | 6 +- src/saltext/github/modules/github_org.py | 303 ++++++------ src/saltext/github/modules/github_repo.py | 464 ++++++++++++++---- src/saltext/github/states/github_repo.py | 242 ++++++--- src/saltext/github/utils/gh/actions.py | 66 ++- src/saltext/github/utils/gh/client.py | 69 ++- src/saltext/github/utils/gh/deploy_keys.py | 32 +- src/saltext/github/utils/gh/environments.py | 58 ++- src/saltext/github/utils/gh/issues.py | 22 +- src/saltext/github/utils/gh/labels.py | 39 +- src/saltext/github/utils/gh/members.py | 58 ++- src/saltext/github/utils/gh/milestones.py | 15 +- src/saltext/github/utils/gh/pages.py | 34 +- src/saltext/github/utils/gh/profile.py | 78 ++- src/saltext/github/utils/gh/pulls.py | 6 +- src/saltext/github/utils/gh/repos.py | 118 +++-- src/saltext/github/utils/gh/rulesets.py | 38 +- src/saltext/github/utils/gh/teams.py | 107 ++-- tests/functional/conftest.py | 21 + .../modules/github_org/test_owner.py | 70 +++ .../modules/github_org/test_repos.py | 55 --- .../modules/github_repo/test_owner.py | 188 +++++++ .../modules/github_repo/test_repo.py | 53 ++ .../states/github_repo/test_owner.py | 91 ++++ tests/support/github/base.py | 14 +- tests/support/github/entities.py | 42 +- tests/support/github/routes/actions.py | 8 +- tests/support/github/routes/deploy_keys.py | 8 +- tests/support/github/routes/environments.py | 16 +- tests/support/github/routes/issues.py | 8 +- tests/support/github/routes/labels.py | 10 +- tests/support/github/routes/milestones.py | 4 +- tests/support/github/routes/orgs.py | 7 +- tests/support/github/routes/pages.py | 8 +- tests/support/github/routes/repos.py | 58 ++- tests/support/github/routes/rulesets.py | 10 +- tests/support/github/state.py | 28 +- tests/unit/utils/test_client.py | 32 ++ 39 files changed, 1831 insertions(+), 657 deletions(-) create mode 100644 tests/functional/modules/github_org/test_owner.py delete mode 100644 tests/functional/modules/github_org/test_repos.py create mode 100644 tests/functional/modules/github_repo/test_owner.py create mode 100644 tests/functional/states/github_repo/test_owner.py diff --git a/changelog/+user_owned_repos.added.md b/changelog/+user_owned_repos.added.md index 462cfbd9..1d53297d 100644 --- a/changelog/+user_owned_repos.added.md +++ b/changelog/+user_owned_repos.added.md @@ -1 +1 @@ -The ``github_repo`` execution module and its states can manage user-owned repositories by pointing the profile's ``org_name`` at the user login (except repository creation and team management, which are organization-only) +The new modules support repositories of any owner. Profiles take an ``owner`` (organization or user; preferred over the legacy ``org_name`` alias), which defaults to the token's user when unset. Repository listing and creation live on the ``github_repo`` module (``list``/``create``), and all of its functions and states as well as all functions of the (now strictly organization-scoped) ``github_org`` module accept a per-call ``owner`` override, so repositories, teams, members and rulesets of foreign organizations can be managed as well. Due to GitHub API limitations, repository creation is only supported for organizations and the authenticated user, private repositories of other users are not listable, tokens without an associated user account (e.g. GitHub App installation tokens) require an explicit ``owner`` and teams always belong to an organization diff --git a/src/saltext/github/modules/github.py b/src/saltext/github/modules/github.py index a8b27807..83b585d9 100644 --- a/src/saltext/github/modules/github.py +++ b/src/saltext/github/modules/github.py @@ -104,7 +104,7 @@ def _get_conn(profile): """ Return the GitHub connection, cached into __context__ for performance """ - return gh_profile.get_conn(profile, salt=__salt__, context=__context__) + return gh_profile.get_conn(profile, __salt__=__salt__, __context__=__context__) def get_user(name, profile="github", user_details=False): @@ -163,7 +163,9 @@ def _get_config_value(profile, config_name): config_name The configuration item's name to use to return configuration values. """ - return gh_profile.get_config_value(profile, config_name, salt=__salt__, context=__context__) + return gh_profile.get_config_value( + profile, config_name, __salt__=__salt__, __context__=__context__ + ) def list_users(profile="github", ignore_cache=False): diff --git a/src/saltext/github/modules/github_org.py b/src/saltext/github/modules/github_org.py index da230a79..bdf697bc 100644 --- a/src/saltext/github/modules/github_org.py +++ b/src/saltext/github/modules/github_org.py @@ -1,6 +1,5 @@ """ -Manage a GitHub organization: members, repositories, teams and -organization rulesets. +Manage a GitHub organization: members, teams and organization rulesets. .. versionadded:: 1.1.0 @@ -8,22 +7,38 @@ the functions in this module raise errors (usually subclasses of ``CommandExecutionError``) instead of logging them and returning ``False``. -Collections with a unique natural key (repositories, teams) are returned -as mappings keyed by it, id-addressed collections (rulesets) as lists. +Collections with a unique natural key (teams, team repositories) are +returned as mappings keyed by it, id-addressed collections (rulesets) +as lists. + +.. note:: + + This module is organization-scoped: all functions target an + organization, defaulting to the profile's ``owner`` (which hence + must be an organization) and accepting an ``owner`` argument to + manage another organization. Repository listing and creation live + in the :py:mod:`github_repo ` + module since they also apply to user accounts. :depends: PyGithub python module Configuration ------------- -This module uses the same configuration profiles as the +This module uses similar configuration profiles as the :py:mod:`github ` module: .. code-block:: yaml github: token: abc1234 - org_name: my_organization + + # Optional: The default owner (organization or user) whose + # resources are managed. org_name is supported as a legacy alias. + # Defaults to the token's user, so organization management and + # tokens without an associated user account (e.g. GitHub App + # installation tokens) require it to be set. + owner: my_organization # Optional: Allow automated repository privacy modifications. allow_repo_privacy_changes: False @@ -40,7 +55,6 @@ from saltext.github.utils import filter_fields from saltext.github.utils.gh import members as gh_members from saltext.github.utils.gh import profile as gh_profile -from saltext.github.utils.gh import repos as gh_repos from saltext.github.utils.gh import rulesets as gh_rulesets from saltext.github.utils.gh import teams as gh_teams from saltext.github.utils.gh.exceptions import GitHubNotFoundError @@ -51,26 +65,28 @@ def _get_conn(profile): - return gh_profile.get_conn(profile, salt=__salt__, context=__context__) + return gh_profile.get_conn(profile, __salt__=__salt__, __context__=__context__) -def _get_team(conn, name): +def _get_team(conn, name, owner=None): """ Return a team's cached details, raising when it does not exist. Teams are matched by exact display name first, then by slug and case-insensitive display name since GitHub derives team identity (the slug) case-insensitively. """ - team = gh_teams.get(conn, name) + team = gh_teams.get(conn, name, owner=owner) if team is None: - for candidate in gh_teams.list_all(conn).values(): + for candidate in gh_teams.list_all(conn, owner=owner).values(): if name == candidate["slug"] or name.lower() == candidate["name"].lower(): return candidate - raise GitHubNotFoundError(f"Team {name} does not exist in organization {conn.org_name}.") + raise GitHubNotFoundError( + f"Team {name} does not exist in organization {owner or conn.owner}." + ) return team -def list_members(ignore_cache=False, profile="github"): +def list_members(owner=None, ignore_cache=False, profile="github"): """ List the logins of all members of the organization. @@ -80,16 +96,19 @@ def list_members(ignore_cache=False, profile="github"): salt-call github_org.list_members + owner + Organization to target. Defaults to the profile's ``owner``. + ignore_cache Bypass the use of cached members. profile Profile configuration name to use. Defaults to ``github``. """ - return gh_members.list_all(_get_conn(profile), ignore_cache=ignore_cache) + return gh_members.list_all(_get_conn(profile), owner=owner, ignore_cache=ignore_cache) -def get_member(name, profile="github"): +def get_member(name, owner=None, profile="github"): """ Return information about a user, including their organization membership state (``in_org`` and ``membership_state``). @@ -103,13 +122,16 @@ def get_member(name, profile="github"): name Login of the user. + owner + Organization to target. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ - return gh_members.get_info(_get_conn(profile), name) + return gh_members.get_info(_get_conn(profile), name, owner=owner) -def add_member(name, profile="github"): +def add_member(name, owner=None, profile="github"): """ Invite a user into the organization. Returns whether the resulting membership is ``pending`` (i.e. an invitation that needs to be @@ -124,13 +146,16 @@ def add_member(name, profile="github"): name Login of the user to invite. + owner + Organization to target. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ - return gh_members.invite(_get_conn(profile), name) + return gh_members.invite(_get_conn(profile), name, owner=owner) -def remove_member(name, profile="github"): +def remove_member(name, owner=None, profile="github"): """ Remove a user from the organization. @@ -143,118 +168,14 @@ def remove_member(name, profile="github"): name Login of the user to remove. - profile - Profile configuration name to use. Defaults to ``github``. - """ - gh_members.remove(_get_conn(profile), name) - return True - - -def list_repos(private=None, fields=None, ignore_cache=False, profile="github"): - """ - List all repositories within the organization the profile token - can see, as a mapping of repository name to details. - - CLI Example: - - .. code-block:: bash - - salt-call github_org.list_repos - salt-call github_org.list_repos private=true - - private - Only list private (``true``) or public (``false``) repositories. - By default, all repositories are listed. - - fields - Only include these fields in the output. A single field name - reduces each entry to the field's value. - - ignore_cache - Bypass the use of cached repositories. + owner + Organization to target. Defaults to the profile's ``owner``. profile Profile configuration name to use. Defaults to ``github``. """ - return filter_fields( - { - repo.name: gh_repos.to_dict(repo) - for repo in gh_repos.list_all(_get_conn(profile), ignore_cache=ignore_cache) - if private is None or repo.private is private - }, - fields, - ) - - -def add_repo( - name, - description=None, - homepage=None, - private=None, - has_issues=None, - has_wiki=None, - has_downloads=None, - auto_init=None, - gitignore_template=None, - license_template=None, - profile="github", -): - """ - Create a new repository in the organization. - Returns information about the created repository. - - CLI Example: - - .. code-block:: bash - - salt-call github_org.add_repo 'repo_name' - - name - Name of the repository to create. - - description - Repository description. - - homepage - URL with more information about the repository, e.g. rendered docs. - - private - Repository visibility. - - has_issues - Enable issues for this repository. - - has_wiki - Enable the wiki for this repository. - - has_downloads - Enable downloads for this repository. - - auto_init - Create an initial commit with an empty README. - - gitignore_template - Desired language or platform for a .gitignore, e.g. "Haskell". - - license_template - Desired LICENSE template to apply, e.g. "mit" or "mozilla". - - profile - Profile configuration name to use. Defaults to ``github``. - """ - return gh_repos.create( - _get_conn(profile), - name, - description=description, - homepage=homepage, - private=private, - has_issues=has_issues, - has_wiki=has_wiki, - has_downloads=has_downloads, - auto_init=auto_init, - gitignore_template=gitignore_template, - license_template=license_template, - ) + gh_members.remove(_get_conn(profile), name, owner=owner) + return True def _strip_team_caches(team_info): @@ -265,7 +186,7 @@ def _strip_team_caches(team_info): return {key: val for key, val in team_info.items() if key not in ("repos", "members")} -def list_teams(fields=None, ignore_cache=False, profile="github"): +def list_teams(owner=None, fields=None, ignore_cache=False, profile="github"): """ List all teams of the organization, as a mapping of team name to team details. @@ -276,6 +197,9 @@ def list_teams(fields=None, ignore_cache=False, profile="github"): salt-call github_org.list_teams + owner + Organization owning the team. Defaults to the profile's ``owner``. + fields Only include these fields in the output. A single field name reduces each entry to the field's value. @@ -286,11 +210,11 @@ def list_teams(fields=None, ignore_cache=False, profile="github"): profile Profile configuration name to use. Defaults to ``github``. """ - teams = gh_teams.list_all(_get_conn(profile), ignore_cache=ignore_cache) + teams = gh_teams.list_all(_get_conn(profile), owner=owner, ignore_cache=ignore_cache) return filter_fields({name: _strip_team_caches(team) for name, team in teams.items()}, fields) -def get_team(name, profile="github"): +def get_team(name, owner=None, profile="github"): """ Return the details of a single team. @@ -303,10 +227,13 @@ def get_team(name, profile="github"): name Name of the team. + owner + Organization owning the team. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ - return _strip_team_caches(_get_team(_get_conn(profile), name)) + return _strip_team_caches(_get_team(_get_conn(profile), name, owner=owner)) def add_team( @@ -315,6 +242,7 @@ def add_team( repo_names=None, privacy=None, permission=None, + owner=None, profile="github", ): """ @@ -343,6 +271,9 @@ def add_team( Default permission for new repositories added to the team, either ``pull``, ``push`` or ``admin``. + owner + Organization owning the team. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -354,11 +285,12 @@ def add_team( repo_names=repo_names, privacy=privacy, permission=permission, + owner=owner, ) - return _strip_team_caches(_get_team(conn, name)) + return _strip_team_caches(_get_team(conn, name, owner=owner)) -def edit_team(name, description=None, privacy=None, permission=None, profile="github"): +def edit_team(name, description=None, privacy=None, permission=None, owner=None, profile="github"): """ Update an existing team. Returns the refreshed team details. @@ -382,22 +314,26 @@ def edit_team(name, description=None, privacy=None, permission=None, profile="gi Default permission for new repositories added to the team, either ``pull``, ``push`` or ``admin``. + owner + Organization owning the team. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) gh_teams.update( conn, - _get_team(conn, name), + _get_team(conn, name, owner=owner), name, description=description, privacy=privacy, permission=permission, + owner=owner, ) - return _strip_team_caches(_get_team(conn, name)) + return _strip_team_caches(_get_team(conn, name, owner=owner)) -def remove_team(name, profile="github"): +def remove_team(name, owner=None, profile="github"): """ Remove a team from the organization. @@ -410,15 +346,18 @@ def remove_team(name, profile="github"): name Name of the team to remove. + owner + Organization owning the team. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - gh_teams.delete(conn, _get_team(conn, name)) + gh_teams.delete(conn, _get_team(conn, name, owner=owner), owner=owner) return True -def list_team_repos(team_name, fields=None, ignore_cache=False, profile="github"): +def list_team_repos(team_name, owner=None, fields=None, ignore_cache=False, profile="github"): """ Return the repositories of a team, as a mapping of repository name to team-specific details (``permission``). @@ -432,6 +371,9 @@ def list_team_repos(team_name, fields=None, ignore_cache=False, profile="github" team_name Name of the team from which to list repos. + owner + Organization owning the team. Defaults to the profile's ``owner``. + fields Only include these fields in the output. A single field name reduces each entry to the field's value. @@ -443,11 +385,13 @@ def list_team_repos(team_name, fields=None, ignore_cache=False, profile="github" Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - repos = gh_teams.list_repos(conn, _get_team(conn, team_name), ignore_cache=ignore_cache) + repos = gh_teams.list_repos( + conn, _get_team(conn, team_name, owner=owner), owner=owner, ignore_cache=ignore_cache + ) return filter_fields({repo["name"]: repo for repo in repos.values()}, fields) -def add_team_repo(repo_name, team_name, permission=None, profile="github"): +def add_team_repo(repo_name, team_name, permission=None, owner=None, profile="github"): """ Add a repository to a team, or update the permission the team has on it. @@ -468,18 +412,23 @@ def add_team_repo(repo_name, team_name, permission=None, profile="github"): Permission for team members within the repository, either ``pull``, ``push`` or ``admin``. If not specified, the team's default permission is used. + owner + Organization owning the team. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - if not gh_teams.add_repo(conn, _get_team(conn, team_name), repo_name, permission=permission): + if not gh_teams.add_repo( + conn, _get_team(conn, team_name, owner=owner), repo_name, permission=permission, owner=owner + ): raise CommandExecutionError( f"Failed setting permission {permission} on repo {repo_name} for team {team_name}." ) return True -def remove_team_repo(repo_name, team_name, profile="github"): +def remove_team_repo(repo_name, team_name, owner=None, profile="github"): """ Remove a repository from a team. @@ -495,15 +444,18 @@ def remove_team_repo(repo_name, team_name, profile="github"): team_name Name of the team to remove the repository from. + owner + Organization owning the team. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - gh_teams.remove_repo(conn, _get_team(conn, team_name), repo_name) + gh_teams.remove_repo(conn, _get_team(conn, team_name, owner=owner), repo_name, owner=owner) return True -def list_team_members(team_name, ignore_cache=False, profile="github"): +def list_team_members(team_name, owner=None, ignore_cache=False, profile="github"): """ Return the logins of a team's members, in lower case. @@ -516,6 +468,9 @@ def list_team_members(team_name, ignore_cache=False, profile="github"): team_name Name of the team from which to list members. + owner + Organization owning the team. Defaults to the profile's ``owner``. + ignore_cache Bypass the use of cached team members. @@ -523,10 +478,12 @@ def list_team_members(team_name, ignore_cache=False, profile="github"): Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - return gh_teams.list_members(conn, _get_team(conn, team_name), ignore_cache=ignore_cache) + return gh_teams.list_members( + conn, _get_team(conn, team_name, owner=owner), owner=owner, ignore_cache=ignore_cache + ) -def is_team_member(name, team_name, profile="github"): +def is_team_member(name, team_name, owner=None, profile="github"): """ Return whether a user is a member of a team. @@ -542,13 +499,16 @@ def is_team_member(name, team_name, profile="github"): team_name Name of the team to check membership in. + owner + Organization owning the team. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ - return name.lower() in list_team_members(team_name, profile=profile) + return name.lower() in list_team_members(team_name, owner=owner, profile=profile) -def add_team_member(name, team_name, profile="github"): +def add_team_member(name, team_name, owner=None, profile="github"): """ Add an organization member to a team. @@ -564,15 +524,18 @@ def add_team_member(name, team_name, profile="github"): team_name Name of the team to add the user to. + owner + Organization owning the team. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - gh_teams.add_member(conn, _get_team(conn, team_name), name) + gh_teams.add_member(conn, _get_team(conn, team_name, owner=owner), name, owner=owner) return True -def remove_team_member(name, team_name, profile="github"): +def remove_team_member(name, team_name, owner=None, profile="github"): """ Remove a member from a team. @@ -588,15 +551,18 @@ def remove_team_member(name, team_name, profile="github"): team_name Name of the team to remove the user from. + owner + Organization owning the team. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - gh_teams.remove_member(conn, _get_team(conn, team_name), name) + gh_teams.remove_member(conn, _get_team(conn, team_name, owner=owner), name, owner=owner) return True -def list_rulesets(per_page=None, fields=None, profile="github"): +def list_rulesets(owner=None, per_page=None, fields=None, profile="github"): """ List all rulesets of the organization. @@ -606,6 +572,9 @@ def list_rulesets(per_page=None, fields=None, profile="github"): salt-call github_org.list_rulesets + owner + Organization to target. Defaults to the profile's ``owner``. + per_page Pagination size, maximum 100. @@ -618,11 +587,11 @@ def list_rulesets(per_page=None, fields=None, profile="github"): """ conn = _get_conn(profile) return filter_fields( - gh_rulesets.list_all(conn, "org", org_name=conn.org_name, per_page=per_page), fields + gh_rulesets.list_all(conn, "org", org_name=owner, per_page=per_page), fields ) -def get_ruleset(ruleset_id, profile="github"): +def get_ruleset(ruleset_id, owner=None, profile="github"): """ Return the data of a single organization ruleset. @@ -635,14 +604,17 @@ def get_ruleset(ruleset_id, profile="github"): ruleset_id ID of the ruleset. + owner + Organization to target. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - return gh_rulesets.get(conn, ruleset_id, "org", org_name=conn.org_name) + return gh_rulesets.get(conn, ruleset_id, "org", org_name=owner) -def add_ruleset(ruleset_params, profile="github"): +def add_ruleset(ruleset_params, owner=None, profile="github"): """ Create a new organization ruleset. Returns the data of the created ruleset. @@ -656,14 +628,17 @@ def add_ruleset(ruleset_params, profile="github"): ruleset_params Ruleset definition. Must contain at least ``name`` and ``enforcement``. + owner + Organization to target. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - return gh_rulesets.create(conn, ruleset_params, "org", org_name=conn.org_name) + return gh_rulesets.create(conn, ruleset_params, "org", org_name=owner) -def update_ruleset(ruleset_id, ruleset_params, profile="github"): +def update_ruleset(ruleset_id, ruleset_params, owner=None, profile="github"): """ Update an existing organization ruleset. Returns the data of the updated ruleset. @@ -680,14 +655,17 @@ def update_ruleset(ruleset_id, ruleset_params, profile="github"): ruleset_params Updated ruleset definition. + owner + Organization to target. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - return gh_rulesets.update(conn, ruleset_id, ruleset_params, "org", org_name=conn.org_name) + return gh_rulesets.update(conn, ruleset_id, ruleset_params, "org", org_name=owner) -def delete_ruleset(ruleset_id, profile="github"): +def delete_ruleset(ruleset_id, owner=None, profile="github"): """ Delete an organization ruleset. @@ -700,9 +678,12 @@ def delete_ruleset(ruleset_id, profile="github"): ruleset_id ID of the ruleset. + owner + Organization to target. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ conn = _get_conn(profile) - gh_rulesets.delete(conn, ruleset_id, "org", org_name=conn.org_name) + gh_rulesets.delete(conn, ruleset_id, "org", org_name=owner) return True diff --git a/src/saltext/github/modules/github_repo.py b/src/saltext/github/modules/github_repo.py index 84709269..57430406 100644 --- a/src/saltext/github/modules/github_repo.py +++ b/src/saltext/github/modules/github_repo.py @@ -1,7 +1,8 @@ """ -Manage a single GitHub repository within an organization: settings, -teams, issues, milestones, pull requests, rulesets, deploy keys, -Actions secrets and settings, environments, labels and Pages. +Manage GitHub repositories within an organization or user account: +listing and creation as well as, per repository, settings, teams, +issues, milestones, pull requests, rulesets, deploy keys, Actions +secrets and settings, environments, labels and Pages. .. versionadded:: 1.1.0 @@ -9,25 +10,27 @@ the functions in this module raise errors (usually subclasses of ``CommandExecutionError``) instead of logging them and returning ``False``. -Collections with a unique natural key (teams, Actions secrets, -environments, labels) are returned as mappings keyed by it, id-addressed -collections (issues, pull requests, milestones, rulesets, deploy keys, -deployment branch policies) as lists. +Collections with a unique natural key (repositories, teams, Actions +secrets, environments, labels) are returned as mappings keyed by it, +id-addressed collections (issues, pull requests, milestones, rulesets, +deploy keys, deployment branch policies) as lists. .. note:: - This module also works on user-owned repositories since the - corresponding API endpoints are shared: set the profile's - ``org_name`` to the user login. Repository creation - (``github_org.add_repo``, the ``github_repo.present`` state) - and team management remain organization-only. + This module works on repositories of any owner (organization or + user): all functions accept an ``owner`` argument that defaults to + the profile's ``owner``. Teams always belong to the owning + organization, and repository creation (:py:func:`create + `, the creating branch + of the ``github_repo.present`` state) is only supported for + organizations and the authenticated user, a GitHub API limitation. :depends: PyGithub python module Configuration ------------- -This module uses the same configuration profiles as the +This module uses similar configuration profiles as the :py:mod:`github ` module. All functions operate on the repository named in ``repo_name``, which is required unless a default is set in the profile: @@ -36,7 +39,13 @@ github: token: abc1234 - org_name: my_organization + + # Optional: The default owner (organization or user) whose + # repositories are managed. org_name is supported as a legacy + # alias. Defaults to the token's user, so tokens without an + # associated user account (e.g. GitHub App installation tokens) + # require it to be set. + owner: my_organization # optional: the default repository to operate on when no # repo_name is passed. @@ -71,17 +80,143 @@ log = logging.getLogger(__name__) __virtualname__ = "github_repo" +__func_alias__ = {"list_": "list"} def _get_conn(profile): - return gh_profile.get_conn(profile, salt=__salt__, context=__context__) + return gh_profile.get_conn(profile, __salt__=__salt__, __context__=__context__) def _get_config_value(profile, config_name): - return gh_profile.get_config_value(profile, config_name, salt=__salt__, context=__context__) + return gh_profile.get_config_value( + profile, config_name, __salt__=__salt__, __context__=__context__ + ) + + +def list_(private=None, owner=None, fields=None, ignore_cache=False, profile="github"): + """ + List all repositories of an owner the profile token can see, as a + mapping of repository name to details. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list + salt-call github_repo.list private=true + + private + Only list private (``true``) or public (``false``) repositories. + By default, all repositories are listed. + + owner + Owner (organization or user) whose repositories to list. + Defaults to the profile's ``owner``. Private repositories of + users other than the authenticated one are not visible. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + ignore_cache + Bypass the use of cached repositories. -def get_info(repo_name=None, ignore_cache=False, profile="github"): + profile + Profile configuration name to use. Defaults to ``github``. + """ + return filter_fields( + { + repo.name: gh_repos.to_dict(repo) + for repo in gh_repos.list_all( + _get_conn(profile), owner=owner, ignore_cache=ignore_cache + ) + if private is None or repo.private is private + }, + fields, + ) + + +def create( + repo_name=None, + description=None, + homepage=None, + private=None, + has_issues=None, + has_wiki=None, + has_downloads=None, + auto_init=None, + gitignore_template=None, + license_template=None, + owner=None, + profile="github", +): + """ + Create a new repository. + Returns information about the created repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.create my-repo + + repo_name + Name of the repository to create. Defaults to the profile's + ``repo_name``. + + description + Repository description. + + homepage + URL with more information about the repository, e.g. rendered docs. + + private + Repository visibility. + + has_issues + Enable issues for this repository. + + has_wiki + Enable the wiki for this repository. + + has_downloads + Enable downloads for this repository. + + auto_init + Create an initial commit with an empty README. + + gitignore_template + Desired language or platform for a .gitignore, e.g. "Haskell". + + license_template + Desired LICENSE template to apply, e.g. "mit" or "mozilla". + + owner + Owner to create the repository for. Defaults to the profile's + ``owner``. The GitHub API only supports creating repositories + for organizations and the authenticated user. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_repos.create( + _get_conn(profile), + repo_name, + owner=owner, + description=description, + homepage=homepage, + private=private, + has_issues=has_issues, + has_wiki=has_wiki, + has_downloads=has_downloads, + auto_init=auto_init, + gitignore_template=gitignore_template, + license_template=license_template, + ) + + +def get_info(repo_name=None, owner=None, ignore_cache=False, profile="github"): """ Return information about a repository. @@ -94,6 +229,9 @@ def get_info(repo_name=None, ignore_cache=False, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + ignore_cache Bypass the use of cached repository information. @@ -101,10 +239,10 @@ def get_info(repo_name=None, ignore_cache=False, profile="github"): Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_repos.get_info(_get_conn(profile), repo_name, ignore_cache=ignore_cache) + return gh_repos.get_info(_get_conn(profile), repo_name, owner=owner, ignore_cache=ignore_cache) -def get_teams(repo_name=None, fields=None, profile="github"): +def get_teams(repo_name=None, owner=None, fields=None, profile="github"): """ Return the teams a repository belongs to, as a mapping of team name to details. @@ -118,6 +256,9 @@ def get_teams(repo_name=None, fields=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + fields Only include these fields in the output. A single field name reduces each entry to the field's value. @@ -126,12 +267,13 @@ def get_teams(repo_name=None, fields=None, profile="github"): Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - teams = gh_repos.get_teams(_get_conn(profile), repo_name) + teams = gh_repos.get_teams(_get_conn(profile), repo_name, owner=owner) return filter_fields({team["name"]: team for team in teams}, fields) def update( repo_name=None, + owner=None, description=None, homepage=None, private=None, @@ -154,6 +296,9 @@ def update( repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + description Repository description. @@ -187,6 +332,7 @@ def update( return gh_repos.update( _get_conn(profile), repo_name, + owner=owner, description=description, homepage=homepage, private=private, @@ -198,7 +344,7 @@ def update( ) -def delete(repo_name=None, profile="github"): +def delete(repo_name=None, owner=None, profile="github"): """ Delete a repository. @@ -211,16 +357,20 @@ def delete(repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - gh_repos.delete(_get_conn(profile), repo_name) + gh_repos.delete(_get_conn(profile), repo_name, owner=owner) return True def list_issues( repo_name=None, + owner=None, milestone=None, state="open", assignee=None, @@ -247,6 +397,9 @@ def list_issues( repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + milestone Number of a GitHub milestone, or a string of either ``*`` or ``none``. @@ -303,11 +456,12 @@ def list_issues( direction=direction, since=since, per_page=per_page, + owner=owner, ) return filter_fields(issues, fields) -def get_issue(number, repo_name=None, profile="github"): +def get_issue(number, repo_name=None, owner=None, profile="github"): """ Return information about a single issue in the repository. @@ -323,14 +477,17 @@ def get_issue(number, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_issues.get(_get_conn(profile), repo_name, number) + return gh_issues.get(_get_conn(profile), repo_name, number, owner=owner) -def get_issue_comments(number, repo_name=None, since=None, profile="github"): +def get_issue_comments(number, repo_name=None, owner=None, since=None, profile="github"): """ Return the comments of an issue in the repository. @@ -346,6 +503,9 @@ def get_issue_comments(number, repo_name=None, since=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + since Only return comments updated at or after this time. This is a timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. @@ -354,11 +514,12 @@ def get_issue_comments(number, repo_name=None, since=None, profile="github"): Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_issues.get_comments(_get_conn(profile), repo_name, number, since=since) + return gh_issues.get_comments(_get_conn(profile), repo_name, number, since=since, owner=owner) def list_milestones( repo_name=None, + owner=None, state="open", sort="due_on", direction="asc", @@ -378,6 +539,9 @@ def list_milestones( repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + state Filter milestones by their state. Either ``open``, ``closed``, or ``all``. Default is ``open``. @@ -407,11 +571,12 @@ def list_milestones( sort=sort, direction=direction, per_page=per_page, + owner=owner, ) return filter_fields(milestones, fields) -def get_milestone(number=None, name=None, repo_name=None, profile="github"): +def get_milestone(number=None, name=None, repo_name=None, owner=None, profile="github"): """ Return information about a single milestone in the repository. @@ -431,6 +596,9 @@ def get_milestone(number=None, name=None, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -439,9 +607,9 @@ def get_milestone(number=None, name=None, repo_name=None, profile="github"): repo_name = _resolve_repo_name(repo_name, profile) if number: - return gh_milestones.get(_get_conn(profile), repo_name, number) + return gh_milestones.get(_get_conn(profile), repo_name, number, owner=owner) - milestones = gh_milestones.list_all(_get_conn(profile), repo_name, state="all") + milestones = gh_milestones.list_all(_get_conn(profile), repo_name, state="all", owner=owner) for milestone in milestones or []: if milestone.get("title") == name: return milestone @@ -450,6 +618,7 @@ def get_milestone(number=None, name=None, repo_name=None, profile="github"): def list_pulls( repo_name=None, + owner=None, state="open", head=None, base=None, @@ -471,6 +640,9 @@ def list_pulls( repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + state Filter returned pull requests by their state. Can be either ``open``, ``closed``, or ``all``. Default is ``open``. @@ -510,11 +682,12 @@ def list_pulls( sort=sort, direction=direction, per_page=per_page, + owner=owner, ) return filter_fields(pulls, fields) -def list_rulesets(repo_name=None, per_page=None, fields=None, profile="github"): +def list_rulesets(repo_name=None, owner=None, per_page=None, fields=None, profile="github"): """ List all rulesets of the repository. @@ -527,6 +700,9 @@ def list_rulesets(repo_name=None, per_page=None, fields=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + per_page Pagination size, maximum 100. @@ -542,14 +718,14 @@ def list_rulesets(repo_name=None, per_page=None, fields=None, profile="github"): rulesets = gh_rulesets.list_all( conn, "repo", - owner=conn.org_name, + owner=owner, repo_name=repo_name, per_page=per_page, ) return filter_fields(rulesets, fields) -def get_ruleset(ruleset_id, repo_name=None, profile="github"): +def get_ruleset(ruleset_id, repo_name=None, owner=None, profile="github"): """ Return the data of a single repository ruleset. @@ -565,15 +741,18 @@ def get_ruleset(ruleset_id, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) conn = _get_conn(profile) - return gh_rulesets.get(conn, ruleset_id, "repo", owner=conn.org_name, repo_name=repo_name) + return gh_rulesets.get(conn, ruleset_id, "repo", owner=owner, repo_name=repo_name) -def add_ruleset(ruleset_params, repo_name=None, profile="github"): +def add_ruleset(ruleset_params, repo_name=None, owner=None, profile="github"): """ Create a new repository ruleset. Returns the data of the created ruleset. @@ -590,17 +769,18 @@ def add_ruleset(ruleset_params, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) conn = _get_conn(profile) - return gh_rulesets.create( - conn, ruleset_params, "repo", owner=conn.org_name, repo_name=repo_name - ) + return gh_rulesets.create(conn, ruleset_params, "repo", owner=owner, repo_name=repo_name) -def update_ruleset(ruleset_id, ruleset_params, repo_name=None, profile="github"): +def update_ruleset(ruleset_id, ruleset_params, repo_name=None, owner=None, profile="github"): """ Update an existing repository ruleset. Returns the data of the updated ruleset. @@ -620,6 +800,9 @@ def update_ruleset(ruleset_id, ruleset_params, repo_name=None, profile="github") repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -630,12 +813,12 @@ def update_ruleset(ruleset_id, ruleset_params, repo_name=None, profile="github") ruleset_id, ruleset_params, "repo", - owner=conn.org_name, + owner=owner, repo_name=repo_name, ) -def delete_ruleset(ruleset_id, repo_name=None, profile="github"): +def delete_ruleset(ruleset_id, repo_name=None, owner=None, profile="github"): """ Delete a repository ruleset. @@ -651,12 +834,15 @@ def delete_ruleset(ruleset_id, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) conn = _get_conn(profile) - gh_rulesets.delete(conn, ruleset_id, "repo", owner=conn.org_name, repo_name=repo_name) + gh_rulesets.delete(conn, ruleset_id, "repo", owner=owner, repo_name=repo_name) return True @@ -669,7 +855,7 @@ def _resolve_repo_name(repo_name, profile): return repo_name -def list_deploy_keys(repo_name=None, fields=None, profile="github"): +def list_deploy_keys(repo_name=None, owner=None, fields=None, profile="github"): """ List the deploy keys of a repository. @@ -682,6 +868,9 @@ def list_deploy_keys(repo_name=None, fields=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + fields Only include these fields in the output. A single field name reduces each entry to the field's value. @@ -690,10 +879,12 @@ def list_deploy_keys(repo_name=None, fields=None, profile="github"): Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return filter_fields(gh_deploy_keys.list_all(_get_conn(profile), repo_name), fields) + return filter_fields( + gh_deploy_keys.list_all(_get_conn(profile), repo_name, owner=owner), fields + ) -def get_deploy_key(key_id, repo_name=None, profile="github"): +def get_deploy_key(key_id, repo_name=None, owner=None, profile="github"): """ Return the data of a single deploy key of a repository. @@ -709,14 +900,17 @@ def get_deploy_key(key_id, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_deploy_keys.get(_get_conn(profile), repo_name, key_id) + return gh_deploy_keys.get(_get_conn(profile), repo_name, key_id, owner=owner) -def add_deploy_key(title, key, read_only=False, repo_name=None, profile="github"): +def add_deploy_key(title, key, read_only=False, repo_name=None, owner=None, profile="github"): """ Add a deploy key to a repository. Returns the data of the created key. @@ -741,16 +935,19 @@ def add_deploy_key(title, key, read_only=False, repo_name=None, profile="github" repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_deploy_keys.create( - _get_conn(profile), repo_name, title, key, read_only=bool(read_only) + _get_conn(profile), repo_name, title, key, read_only=bool(read_only), owner=owner ) -def remove_deploy_key(key_id, repo_name=None, profile="github"): +def remove_deploy_key(key_id, repo_name=None, owner=None, profile="github"): """ Remove a deploy key from a repository. @@ -766,15 +963,18 @@ def remove_deploy_key(key_id, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - gh_deploy_keys.delete(_get_conn(profile), repo_name, key_id) + gh_deploy_keys.delete(_get_conn(profile), repo_name, key_id, owner=owner) return True -def list_actions_secrets(repo_name=None, fields=None, profile="github"): +def list_actions_secrets(repo_name=None, owner=None, fields=None, profile="github"): """ List the Actions secrets of a repository, as a mapping of secret name to metadata. Secret values are write-only, so this only returns @@ -789,6 +989,9 @@ def list_actions_secrets(repo_name=None, fields=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + fields Only include these fields in the output. A single field name reduces each entry to the field's value. @@ -797,11 +1000,11 @@ def list_actions_secrets(repo_name=None, fields=None, profile="github"): Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - secrets = gh_actions.list_secrets(_get_conn(profile), repo_name) + secrets = gh_actions.list_secrets(_get_conn(profile), repo_name, owner=owner) return filter_fields({secret["name"]: secret for secret in secrets}, fields) -def get_actions_secret(name, repo_name=None, profile="github"): +def get_actions_secret(name, repo_name=None, owner=None, profile="github"): """ Return the metadata of a single Actions secret of a repository. The value itself is write-only and cannot be retrieved. @@ -818,14 +1021,17 @@ def get_actions_secret(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_actions.get_secret(_get_conn(profile), repo_name, name) + return gh_actions.get_secret(_get_conn(profile), repo_name, name, owner=owner) -def set_actions_secret(name, value, repo_name=None, profile="github"): +def set_actions_secret(name, value, repo_name=None, owner=None, profile="github"): """ Create or update an Actions secret of a repository. The value is encrypted to the repository public key before leaving the minion. @@ -845,15 +1051,18 @@ def set_actions_secret(name, value, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - gh_actions.set_secret(_get_conn(profile), repo_name, name, value) + gh_actions.set_secret(_get_conn(profile), repo_name, name, value, owner=owner) return True -def remove_actions_secret(name, repo_name=None, profile="github"): +def remove_actions_secret(name, repo_name=None, owner=None, profile="github"): """ Remove an Actions secret from a repository. @@ -869,15 +1078,18 @@ def remove_actions_secret(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - gh_actions.delete_secret(_get_conn(profile), repo_name, name) + gh_actions.delete_secret(_get_conn(profile), repo_name, name, owner=owner) return True -def get_workflow_permissions(repo_name=None, profile="github"): +def get_workflow_permissions(repo_name=None, owner=None, profile="github"): """ Return the default workflow permissions granted to the ``GITHUB_TOKEN`` in a repository, a mapping with ``default_workflow_permissions`` @@ -892,17 +1104,21 @@ def get_workflow_permissions(repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_actions.get_workflow_permissions(_get_conn(profile), repo_name) + return gh_actions.get_workflow_permissions(_get_conn(profile), repo_name, owner=owner) def set_workflow_permissions( default_workflow_permissions=None, can_approve_pull_request_reviews=None, repo_name=None, + owner=None, profile="github", ): """ @@ -925,6 +1141,9 @@ def set_workflow_permissions( repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -934,11 +1153,12 @@ def set_workflow_permissions( repo_name, default_workflow_permissions=default_workflow_permissions, can_approve_pull_request_reviews=can_approve_pull_request_reviews, + owner=owner, ) return True -def list_environments(repo_name=None, fields=None, profile="github"): +def list_environments(repo_name=None, owner=None, fields=None, profile="github"): """ List the environments of a repository, as a mapping of environment name to details. @@ -952,6 +1172,9 @@ def list_environments(repo_name=None, fields=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + fields Only include these fields in the output. A single field name reduces each entry to the field's value. @@ -960,11 +1183,11 @@ def list_environments(repo_name=None, fields=None, profile="github"): Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - environments = gh_environments.list_all(_get_conn(profile), repo_name) + environments = gh_environments.list_all(_get_conn(profile), repo_name, owner=owner) return filter_fields({env["name"]: env for env in environments}, fields) -def get_environment(name, repo_name=None, profile="github"): +def get_environment(name, repo_name=None, owner=None, profile="github"): """ Return the data of a single environment of a repository. @@ -980,11 +1203,14 @@ def get_environment(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_environments.get(_get_conn(profile), repo_name, name) + return gh_environments.get(_get_conn(profile), repo_name, name, owner=owner) def set_environment( @@ -994,6 +1220,7 @@ def set_environment( reviewers=None, deployment_branch_policy=None, repo_name=None, + owner=None, profile="github", ): """ @@ -1034,6 +1261,9 @@ def set_environment( repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -1046,10 +1276,11 @@ def set_environment( prevent_self_review=prevent_self_review, reviewers=reviewers, deployment_branch_policy=deployment_branch_policy, + owner=owner, ) -def remove_environment(name, repo_name=None, profile="github"): +def remove_environment(name, repo_name=None, owner=None, profile="github"): """ Remove an environment from a repository. @@ -1065,15 +1296,20 @@ def remove_environment(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - gh_environments.delete(_get_conn(profile), repo_name, name) + gh_environments.delete(_get_conn(profile), repo_name, name, owner=owner) return True -def list_deployment_branch_policies(environment, repo_name=None, fields=None, profile="github"): +def list_deployment_branch_policies( + environment, repo_name=None, owner=None, fields=None, profile="github" +): """ List the deployment branch policies of an environment. @@ -1089,6 +1325,9 @@ def list_deployment_branch_policies(environment, repo_name=None, fields=None, pr repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + fields Only include these fields in the output. A single field name reduces each entry to the field's value. @@ -1098,14 +1337,17 @@ def list_deployment_branch_policies(environment, repo_name=None, fields=None, pr """ repo_name = _resolve_repo_name(repo_name, profile) return filter_fields( - gh_environments.list_branch_policies(_get_conn(profile), repo_name, environment), fields + gh_environments.list_branch_policies( + _get_conn(profile), repo_name, environment, owner=owner + ), + fields, ) def add_deployment_branch_policy( - environment, name, type_="branch", repo_name=None, profile="github" + environment, name, typ="branch", repo_name=None, owner=None, profile="github" ): - r""" + """ Add a deployment branch (or tag) policy to an environment. Returns the data of the created policy. @@ -1124,23 +1366,28 @@ def add_deployment_branch_policy( Name pattern that branches (or tags) must match in order to deploy to the environment. - type\_ + typ Target of this rule, either ``branch`` or ``tag``. Defaults to ``branch``. repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_environments.create_branch_policy( - _get_conn(profile), repo_name, environment, name, type_=type_ + _get_conn(profile), repo_name, environment, name, typ=typ, owner=owner ) -def remove_deployment_branch_policy(environment, policy_id, repo_name=None, profile="github"): +def remove_deployment_branch_policy( + environment, policy_id, repo_name=None, owner=None, profile="github" +): """ Remove a deployment branch policy from an environment. @@ -1159,15 +1406,20 @@ def remove_deployment_branch_policy(environment, policy_id, repo_name=None, prof repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - gh_environments.delete_branch_policy(_get_conn(profile), repo_name, environment, policy_id) + gh_environments.delete_branch_policy( + _get_conn(profile), repo_name, environment, policy_id, owner=owner + ) return True -def list_labels(repo_name=None, fields=None, profile="github"): +def list_labels(repo_name=None, owner=None, fields=None, profile="github"): """ List the labels of a repository, as a mapping of label name to details. @@ -1181,6 +1433,9 @@ def list_labels(repo_name=None, fields=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + fields Only include these fields in the output. A single field name reduces each entry to the field's value. @@ -1189,11 +1444,11 @@ def list_labels(repo_name=None, fields=None, profile="github"): Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - labels = gh_labels.list_all(_get_conn(profile), repo_name) + labels = gh_labels.list_all(_get_conn(profile), repo_name, owner=owner) return filter_fields({label["name"]: label for label in labels}, fields) -def get_label(name, repo_name=None, profile="github"): +def get_label(name, repo_name=None, owner=None, profile="github"): """ Return the data of a single label of a repository. @@ -1209,14 +1464,17 @@ def get_label(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_labels.get(_get_conn(profile), repo_name, name) + return gh_labels.get(_get_conn(profile), repo_name, name, owner=owner) -def add_label(name, color=None, description=None, repo_name=None, profile="github"): +def add_label(name, color=None, description=None, repo_name=None, owner=None, profile="github"): """ Create a label in a repository. Returns the data of the created label. @@ -1239,16 +1497,21 @@ def add_label(name, color=None, description=None, repo_name=None, profile="githu repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) return gh_labels.create( - _get_conn(profile), repo_name, name, color=color, description=description + _get_conn(profile), repo_name, name, color=color, description=description, owner=owner ) -def edit_label(name, new_name=None, color=None, description=None, repo_name=None, profile="github"): +def edit_label( + name, new_name=None, color=None, description=None, repo_name=None, owner=None, profile="github" +): """ Update an existing label of a repository. Returns the data of the updated label. @@ -1274,6 +1537,9 @@ def edit_label(name, new_name=None, color=None, description=None, repo_name=None repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -1285,10 +1551,11 @@ def edit_label(name, new_name=None, color=None, description=None, repo_name=None new_name=new_name, color=color, description=description, + owner=owner, ) -def remove_label(name, repo_name=None, profile="github"): +def remove_label(name, repo_name=None, owner=None, profile="github"): """ Delete a label from a repository. @@ -1304,15 +1571,18 @@ def remove_label(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - gh_labels.delete(_get_conn(profile), repo_name, name) + gh_labels.delete(_get_conn(profile), repo_name, name, owner=owner) return True -def get_pages(repo_name=None, profile="github"): +def get_pages(repo_name=None, owner=None, profile="github"): """ Return information about the GitHub Pages site of a repository. @@ -1325,15 +1595,23 @@ def get_pages(repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - return gh_pages.get(_get_conn(profile), repo_name) + return gh_pages.get(_get_conn(profile), repo_name, owner=owner) def setup_pages( - build_type=None, source_branch=None, source_path=None, repo_name=None, profile="github" + build_type=None, + source_branch=None, + source_path=None, + repo_name=None, + owner=None, + profile="github", ): """ Configure a GitHub Pages site for a repository. @@ -1359,6 +1637,9 @@ def setup_pages( repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -1368,6 +1649,7 @@ def setup_pages( repo_name, build_type=build_type, source=gh_pages.build_source(source_branch, source_path), + owner=owner, ) @@ -1378,6 +1660,7 @@ def update_pages( cname=..., https_enforced=None, repo_name=None, + owner=None, profile="github", ): """ @@ -1410,6 +1693,9 @@ def update_pages( repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -1421,11 +1707,12 @@ def update_pages( source=gh_pages.build_source(source_branch, source_path), cname=cname, https_enforced=https_enforced, + owner=owner, ) return True -def remove_pages(repo_name=None, profile="github"): +def remove_pages(repo_name=None, owner=None, profile="github"): """ Delete the GitHub Pages site of a repository. @@ -1438,9 +1725,12 @@ def remove_pages(repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ repo_name = _resolve_repo_name(repo_name, profile) - gh_pages.delete(_get_conn(profile), repo_name) + gh_pages.delete(_get_conn(profile), repo_name, owner=owner) return True diff --git a/src/saltext/github/states/github_repo.py b/src/saltext/github/states/github_repo.py index 63e9bbe9..689bf9ce 100644 --- a/src/saltext/github/states/github_repo.py +++ b/src/saltext/github/states/github_repo.py @@ -1,7 +1,7 @@ """ -Manage a single GitHub repository within an organization: the repository -itself, its rulesets, deploy keys, Actions secrets and settings, -environments, labels and Pages. +Manage a single GitHub repository within an organization or user +account: the repository itself, its rulesets, deploy keys, Actions +secrets and settings, environments, labels and Pages. .. versionadded:: 1.1.0 @@ -11,6 +11,14 @@ `, :py:func:`pages_present `, ...), ``name`` doubles as the repository name instead. + +All states accept an ``owner`` argument (defaulting to the profile's +``owner``) to manage repositories of other owners, including user-owned +ones. The ``teams`` parameter of :py:func:`present +` only applies to +organization-owned repositories (teams belong to the owning +organization), and repository creation is only supported for +organizations and the authenticated user, a GitHub API limitation. """ import logging @@ -51,12 +59,14 @@ def _repo_settings_diff(ret, target, managed): return update_params -def _repo_teams_diff(ret, name, teams, profile): +def _repo_teams_diff(ret, name, teams, owner, profile): """ Record the team association changes of an existing repository in ``ret`` and return them. """ - current_teams = set(__salt__["github_repo.get_teams"](repo_name=name, profile=profile)) + current_teams = set( + __salt__["github_repo.get_teams"](repo_name=name, owner=owner, profile=profile) + ) teams_added = sorted(set(teams) - current_teams) teams_removed = sorted(current_teams - set(teams)) team_permissions = {} @@ -64,7 +74,7 @@ def _repo_teams_diff(ret, name, teams, profile): team_repos = { repo_name.lower(): repo for repo_name, repo in __salt__["github_org.list_team_repos"]( - team_name, profile=profile + team_name, owner=owner, profile=profile ).items() } current_permission = team_repos.get(name.lower(), {}).get("permission") @@ -79,14 +89,14 @@ def _repo_teams_diff(ret, name, teams, profile): return teams_added, teams_removed, team_permissions -def _create_repo(name, create_params, teams, profile): +def _create_repo(name, create_params, teams, owner, profile): """ Create a repository and associate the requested teams. """ - __salt__["github_org.add_repo"](name, profile=profile, **create_params) + __salt__["github_repo.create"](repo_name=name, owner=owner, profile=profile, **create_params) for team_name, permission in (teams or {}).items(): __salt__["github_org.add_team_repo"]( - name, team_name, permission=permission, profile=profile + name, team_name, permission=permission, owner=owner, profile=profile ) @@ -103,6 +113,7 @@ def present( license_template=None, default_branch=None, teams=None, + owner=None, profile="github", ): """ @@ -150,7 +161,14 @@ def present( teams Teams this repo should belong to, specified as a dict of team name to permission (``pull``, ``push`` or ``admin``). - Unlisted teams are removed from the repository. + Unlisted teams are removed from the repository. The teams + belong to the repository's owning organization, so this is + only supported for organization-owned repositories. + + owner + Owner of the repository. Defaults to the profile's ``owner``. + Repository creation is only supported for organizations and + the authenticated user. profile Profile configuration name to use. Defaults to ``github``. @@ -163,13 +181,13 @@ def present( } try: - # This is an optimization to cache all repos in the organization up front. + # This is an optimization to cache all of the owner's repos up front. # The first use of this state will collect all of the repos and save a bunch # of API calls for future use. - __salt__["github_org.list_repos"](profile=profile) + __salt__["github_repo.list"](owner=owner, profile=profile) try: - target = __salt__["github_repo.get_info"](repo_name=name, profile=profile) + target = __salt__["github_repo.get_info"](repo_name=name, owner=owner, profile=profile) except GitHubNotFoundError: target = None @@ -195,7 +213,7 @@ def present( ) if teams is not None: teams_added, teams_removed, team_permissions = _repo_teams_diff( - ret, name, teams, profile + ret, name, teams, owner, profile ) if not ret["changes"]: @@ -222,20 +240,25 @@ def present( "license_template": license_template, }, teams, + owner, profile, ) else: if update_params: - __salt__["github_repo.update"](repo_name=name, profile=profile, **update_params) + __salt__["github_repo.update"]( + repo_name=name, owner=owner, profile=profile, **update_params + ) for team_name in teams_added: __salt__["github_org.add_team_repo"]( - name, team_name, permission=teams[team_name], profile=profile + name, team_name, permission=teams[team_name], owner=owner, profile=profile ) for team_name in teams_removed: - __salt__["github_org.remove_team_repo"](name, team_name, profile=profile) + __salt__["github_org.remove_team_repo"]( + name, team_name, owner=owner, profile=profile + ) for team_name in team_permissions: __salt__["github_org.add_team_repo"]( - name, team_name, permission=teams[team_name], profile=profile + name, team_name, permission=teams[team_name], owner=owner, profile=profile ) ret["comment"] = f"Repo {name} has been {verb}" except CommandExecutionError as err: @@ -245,12 +268,15 @@ def present( return ret -def absent(name, profile="github"): +def absent(name, owner=None, profile="github"): """ Ensure a repository is absent. name - Name of the repository in the organization. + Name of the repository. + + owner + Owner of the repository. Defaults to the profile's ``owner``. profile Profile configuration name to use. Defaults to ``github``. @@ -259,7 +285,7 @@ def absent(name, profile="github"): try: try: - __salt__["github_repo.get_info"](repo_name=name, profile=profile) + __salt__["github_repo.get_info"](repo_name=name, owner=owner, profile=profile) except GitHubNotFoundError: ret["comment"] = f"Repo {name} does not exist" return ret @@ -270,7 +296,7 @@ def absent(name, profile="github"): ret["comment"] = f"Repo {name} would have been deleted" return ret - __salt__["github_repo.delete"](repo_name=name, profile=profile) + __salt__["github_repo.delete"](repo_name=name, owner=owner, profile=profile) ret["comment"] = f"Repo {name} has been deleted" except CommandExecutionError as err: ret["result"] = False @@ -279,7 +305,7 @@ def absent(name, profile="github"): return ret -def ruleset_present(name, ruleset_params=None, repo_name=None, profile="github"): +def ruleset_present(name, ruleset_params=None, repo_name=None, owner=None, profile="github"): """ Ensure a repository ruleset is present and configured as specified. Only the parameters included in ``ruleset_params`` are compared, @@ -308,6 +334,9 @@ def ruleset_present(name, ruleset_params=None, repo_name=None, profile="github") repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -317,7 +346,9 @@ def ruleset_present(name, ruleset_params=None, repo_name=None, profile="github") ruleset_params["name"] = name try: - rulesets = __salt__["github_repo.list_rulesets"](repo_name=repo_name, profile=profile) + rulesets = __salt__["github_repo.list_rulesets"]( + repo_name=repo_name, owner=owner, profile=profile + ) ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) if ruleset is None: @@ -327,13 +358,13 @@ def ruleset_present(name, ruleset_params=None, repo_name=None, profile="github") ret["comment"] = f"Ruleset {name} would have been created" return ret __salt__["github_repo.add_ruleset"]( - ruleset_params, repo_name=repo_name, profile=profile + ruleset_params, repo_name=repo_name, owner=owner, profile=profile ) ret["comment"] = f"Ruleset {name} has been created" return ret ruleset_info = __salt__["github_repo.get_ruleset"]( - ruleset["id"], repo_name=repo_name, profile=profile + ruleset["id"], repo_name=repo_name, owner=owner, profile=profile ) # Only diff the parameters that are being managed. The server @@ -352,7 +383,7 @@ def ruleset_present(name, ruleset_params=None, repo_name=None, profile="github") return ret __salt__["github_repo.update_ruleset"]( - ruleset["id"], ruleset_params, repo_name=repo_name, profile=profile + ruleset["id"], ruleset_params, repo_name=repo_name, owner=owner, profile=profile ) ret["comment"] = f"Ruleset {name} has been updated" except CommandExecutionError as err: @@ -362,7 +393,7 @@ def ruleset_present(name, ruleset_params=None, repo_name=None, profile="github") return ret -def ruleset_absent(name, repo_name=None, profile="github"): +def ruleset_absent(name, repo_name=None, owner=None, profile="github"): """ Ensure a repository ruleset is absent. @@ -372,13 +403,18 @@ def ruleset_absent(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - rulesets = __salt__["github_repo.list_rulesets"](repo_name=repo_name, profile=profile) + rulesets = __salt__["github_repo.list_rulesets"]( + repo_name=repo_name, owner=owner, profile=profile + ) ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) if ruleset is None: ret["comment"] = f"Ruleset {name} does not exist" @@ -390,7 +426,9 @@ def ruleset_absent(name, repo_name=None, profile="github"): ret["comment"] = f"Ruleset {name} would have been deleted" return ret - __salt__["github_repo.delete_ruleset"](ruleset["id"], repo_name=repo_name, profile=profile) + __salt__["github_repo.delete_ruleset"]( + ruleset["id"], repo_name=repo_name, owner=owner, profile=profile + ) ret["comment"] = f"Ruleset {name} has been deleted" except CommandExecutionError as err: ret["result"] = False @@ -407,7 +445,7 @@ def _key_body(key): return " ".join(key.split()[:2]) -def deploy_key_present(name, key, read_only=False, repo_name=None, profile="github"): +def deploy_key_present(name, key, read_only=False, repo_name=None, owner=None, profile="github"): """ Ensure a deploy key with this title and public key is present in a repository. @@ -438,13 +476,18 @@ def deploy_key_present(name, key, read_only=False, repo_name=None, profile="gith repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - keys = __salt__["github_repo.list_deploy_keys"](repo_name=repo_name, profile=profile) + keys = __salt__["github_repo.list_deploy_keys"]( + repo_name=repo_name, owner=owner, profile=profile + ) current = next((cur for cur in keys if cur["title"] == name), None) if current is not None: @@ -466,10 +509,10 @@ def deploy_key_present(name, key, read_only=False, repo_name=None, profile="gith if current is not None: __salt__["github_repo.remove_deploy_key"]( - current["id"], repo_name=repo_name, profile=profile + current["id"], repo_name=repo_name, owner=owner, profile=profile ) __salt__["github_repo.add_deploy_key"]( - name, key, read_only=read_only, repo_name=repo_name, profile=profile + name, key, read_only=read_only, repo_name=repo_name, owner=owner, profile=profile ) ret["comment"] = f"Deploy key {name} has been {verb}" except CommandExecutionError as err: @@ -479,7 +522,7 @@ def deploy_key_present(name, key, read_only=False, repo_name=None, profile="gith return ret -def deploy_key_absent(name, repo_name=None, profile="github"): +def deploy_key_absent(name, repo_name=None, owner=None, profile="github"): """ Ensure no deploy key with this title is present in a repository. @@ -489,13 +532,18 @@ def deploy_key_absent(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - keys = __salt__["github_repo.list_deploy_keys"](repo_name=repo_name, profile=profile) + keys = __salt__["github_repo.list_deploy_keys"]( + repo_name=repo_name, owner=owner, profile=profile + ) current = next((cur for cur in keys if cur["title"] == name), None) if current is None: ret["comment"] = f"Deploy key {name} is already absent" @@ -508,7 +556,7 @@ def deploy_key_absent(name, repo_name=None, profile="github"): return ret __salt__["github_repo.remove_deploy_key"]( - current["id"], repo_name=repo_name, profile=profile + current["id"], repo_name=repo_name, owner=owner, profile=profile ) ret["comment"] = f"Deploy key {name} has been removed" except CommandExecutionError as err: @@ -518,7 +566,9 @@ def deploy_key_absent(name, repo_name=None, profile="github"): return ret -def actions_secret_present(name, value=None, force=False, repo_name=None, profile="github"): +def actions_secret_present( + name, value=None, force=False, repo_name=None, owner=None, profile="github" +): """ Ensure an Actions secret is present in a repository. @@ -543,13 +593,18 @@ def actions_secret_present(name, value=None, force=False, repo_name=None, profil repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - secrets = __salt__["github_repo.list_actions_secrets"](repo_name=repo_name, profile=profile) + secrets = __salt__["github_repo.list_actions_secrets"]( + repo_name=repo_name, owner=owner, profile=profile + ) current = next((sec for sec in secrets if sec.lower() == name.lower()), None) if current is not None and not force: @@ -575,7 +630,7 @@ def actions_secret_present(name, value=None, force=False, repo_name=None, profil return ret __salt__["github_repo.set_actions_secret"]( - name, value, repo_name=repo_name, profile=profile + name, value, repo_name=repo_name, owner=owner, profile=profile ) ret["comment"] = f"Secret {name} has been {verb}" except CommandExecutionError as err: @@ -585,7 +640,7 @@ def actions_secret_present(name, value=None, force=False, repo_name=None, profil return ret -def actions_secret_absent(name, repo_name=None, profile="github"): +def actions_secret_absent(name, repo_name=None, owner=None, profile="github"): """ Ensure an Actions secret is absent from a repository. @@ -596,13 +651,18 @@ def actions_secret_absent(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ ret = {"name": name, "changes": {}, "result": True, "comment": ""} try: - secrets = __salt__["github_repo.list_actions_secrets"](repo_name=repo_name, profile=profile) + secrets = __salt__["github_repo.list_actions_secrets"]( + repo_name=repo_name, owner=owner, profile=profile + ) current = next((sec for sec in secrets if sec.lower() == name.lower()), None) if current is None: ret["comment"] = f"Secret {name} is already absent" @@ -614,7 +674,9 @@ def actions_secret_absent(name, repo_name=None, profile="github"): ret["comment"] = f"Secret {name} would have been removed" return ret - __salt__["github_repo.remove_actions_secret"](current, repo_name=repo_name, profile=profile) + __salt__["github_repo.remove_actions_secret"]( + current, repo_name=repo_name, owner=owner, profile=profile + ) ret["comment"] = f"Secret {name} has been removed" except CommandExecutionError as err: ret["result"] = False @@ -628,6 +690,7 @@ def workflow_permissions_managed( default_workflow_permissions=None, can_approve_pull_request_reviews=None, repo_name=None, + owner=None, profile="github", ): """ @@ -647,6 +710,9 @@ def workflow_permissions_managed( repo_name Name of the repository, if it differs from ``name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -664,7 +730,7 @@ def workflow_permissions_managed( raise CommandExecutionError("At least one setting to manage must be specified") current = __salt__["github_repo.get_workflow_permissions"]( - repo_name=repo_name, profile=profile + repo_name=repo_name, owner=owner, profile=profile ) diff = {key: val for key, val in desired.items() if current.get(key) != val} if not diff: @@ -679,7 +745,7 @@ def workflow_permissions_managed( return ret __salt__["github_repo.set_workflow_permissions"]( - repo_name=repo_name, profile=profile, **diff + repo_name=repo_name, owner=owner, profile=profile, **diff ) ret["comment"] = "Workflow permissions have been updated" except CommandExecutionError as err: @@ -735,6 +801,7 @@ def environment_present( reviewers=None, deployment_branches=None, repo_name=None, + owner=None, profile="github", ): """ @@ -764,6 +831,9 @@ def environment_present( repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -777,7 +847,7 @@ def environment_present( try: try: current = __salt__["github_repo.get_environment"]( - name, repo_name=repo_name, profile=profile + name, repo_name=repo_name, owner=owner, profile=profile ) except GitHubNotFoundError: current = None @@ -808,7 +878,7 @@ def environment_present( if isinstance(deployment_branches, list): try: current_policies = __salt__["github_repo.list_deployment_branch_policies"]( - name, repo_name=repo_name, profile=profile + name, repo_name=repo_name, owner=owner, profile=profile ) except GitHubNotFoundError: # Not found when the current policy type is not "custom" @@ -834,16 +904,16 @@ def environment_present( if current is None or settings_diff: __salt__["github_repo.set_environment"]( - name, repo_name=repo_name, profile=profile, **set_kwargs + name, repo_name=repo_name, owner=owner, profile=profile, **set_kwargs ) for pattern in branches_added: __salt__["github_repo.add_deployment_branch_policy"]( - name, pattern, repo_name=repo_name, profile=profile + name, pattern, repo_name=repo_name, owner=owner, profile=profile ) for policy in current_policies: if policy["name"] in branches_removed: __salt__["github_repo.remove_deployment_branch_policy"]( - name, policy["id"], repo_name=repo_name, profile=profile + name, policy["id"], repo_name=repo_name, owner=owner, profile=profile ) ret["comment"] = f"Environment {name} has been {verb}" except CommandExecutionError as err: @@ -853,7 +923,7 @@ def environment_present( return ret -def environment_absent(name, repo_name=None, profile="github"): +def environment_absent(name, repo_name=None, owner=None, profile="github"): """ Ensure an environment is absent from a repository. @@ -863,6 +933,9 @@ def environment_absent(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -870,7 +943,9 @@ def environment_absent(name, repo_name=None, profile="github"): try: try: - __salt__["github_repo.get_environment"](name, repo_name=repo_name, profile=profile) + __salt__["github_repo.get_environment"]( + name, repo_name=repo_name, owner=owner, profile=profile + ) except GitHubNotFoundError: ret["comment"] = f"Environment {name} is already absent" return ret @@ -881,7 +956,9 @@ def environment_absent(name, repo_name=None, profile="github"): ret["comment"] = f"Environment {name} would have been removed" return ret - __salt__["github_repo.remove_environment"](name, repo_name=repo_name, profile=profile) + __salt__["github_repo.remove_environment"]( + name, repo_name=repo_name, owner=owner, profile=profile + ) ret["comment"] = f"Environment {name} has been removed" except CommandExecutionError as err: ret["result"] = False @@ -899,7 +976,7 @@ def _normalize_color(color): return color.lstrip("#").lower() -def label_present(name, color=None, description=None, repo_name=None, profile="github"): +def label_present(name, color=None, description=None, repo_name=None, owner=None, profile="github"): """ Ensure a label is present in a repository and configured as specified. Unspecified settings are left unmanaged. @@ -917,6 +994,9 @@ def label_present(name, color=None, description=None, repo_name=None, profile="g repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -924,7 +1004,9 @@ def label_present(name, color=None, description=None, repo_name=None, profile="g try: try: - current = __salt__["github_repo.get_label"](name, repo_name=repo_name, profile=profile) + current = __salt__["github_repo.get_label"]( + name, repo_name=repo_name, owner=owner, profile=profile + ) except GitHubNotFoundError: current = None @@ -935,7 +1017,12 @@ def label_present(name, color=None, description=None, repo_name=None, profile="g ret["comment"] = f"Label {name} would have been created" return ret __salt__["github_repo.add_label"]( - name, color=color, description=description, repo_name=repo_name, profile=profile + name, + color=color, + description=description, + repo_name=repo_name, + owner=owner, + profile=profile, ) ret["comment"] = f"Label {name} has been created" return ret @@ -957,7 +1044,9 @@ def label_present(name, color=None, description=None, repo_name=None, profile="g ret["comment"] = f"Label {name} would have been updated" return ret - __salt__["github_repo.edit_label"](name, repo_name=repo_name, profile=profile, **diff) + __salt__["github_repo.edit_label"]( + name, repo_name=repo_name, owner=owner, profile=profile, **diff + ) ret["comment"] = f"Label {name} has been updated" except CommandExecutionError as err: ret["result"] = False @@ -966,7 +1055,7 @@ def label_present(name, color=None, description=None, repo_name=None, profile="g return ret -def label_absent(name, repo_name=None, profile="github"): +def label_absent(name, repo_name=None, owner=None, profile="github"): """ Ensure a label is absent from a repository. @@ -976,6 +1065,9 @@ def label_absent(name, repo_name=None, profile="github"): repo_name Name of the repository. Defaults to the profile's ``repo_name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -983,7 +1075,9 @@ def label_absent(name, repo_name=None, profile="github"): try: try: - __salt__["github_repo.get_label"](name, repo_name=repo_name, profile=profile) + __salt__["github_repo.get_label"]( + name, repo_name=repo_name, owner=owner, profile=profile + ) except GitHubNotFoundError: ret["comment"] = f"Label {name} is already absent" return ret @@ -994,7 +1088,9 @@ def label_absent(name, repo_name=None, profile="github"): ret["comment"] = f"Label {name} would have been removed" return ret - __salt__["github_repo.remove_label"](name, repo_name=repo_name, profile=profile) + __salt__["github_repo.remove_label"]( + name, repo_name=repo_name, owner=owner, profile=profile + ) ret["comment"] = f"Label {name} has been removed" except CommandExecutionError as err: ret["result"] = False @@ -1011,6 +1107,7 @@ def pages_present( cname=None, https_enforced=None, repo_name=None, + owner=None, profile="github", ): """ @@ -1040,6 +1137,9 @@ def pages_present( repo_name Name of the repository, if it differs from ``name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -1048,7 +1148,9 @@ def pages_present( try: try: - current = __salt__["github_repo.get_pages"](repo_name=repo_name, profile=profile) + current = __salt__["github_repo.get_pages"]( + repo_name=repo_name, owner=owner, profile=profile + ) except GitHubNotFoundError: current = None @@ -1063,13 +1165,16 @@ def pages_present( source_branch=source_branch, source_path=source_path, repo_name=repo_name, + owner=owner, profile=profile, ) if cname is not None or https_enforced is not None: extra = {"https_enforced": https_enforced} if cname is not None: extra["cname"] = cname - __salt__["github_repo.update_pages"](repo_name=repo_name, profile=profile, **extra) + __salt__["github_repo.update_pages"]( + repo_name=repo_name, owner=owner, profile=profile, **extra + ) ret["comment"] = f"Pages have been enabled for {repo_name}" return ret @@ -1109,7 +1214,9 @@ def pages_present( # unchanged half update_kwargs.setdefault("source_branch", current_flat["source_branch"]) update_kwargs.setdefault("source_path", current_flat["source_path"]) - __salt__["github_repo.update_pages"](repo_name=repo_name, profile=profile, **update_kwargs) + __salt__["github_repo.update_pages"]( + repo_name=repo_name, owner=owner, profile=profile, **update_kwargs + ) ret["comment"] = f"Pages configuration for {repo_name} has been updated" except CommandExecutionError as err: ret["result"] = False @@ -1118,7 +1225,7 @@ def pages_present( return ret -def pages_absent(name, repo_name=None, profile="github"): +def pages_absent(name, repo_name=None, owner=None, profile="github"): """ Ensure GitHub Pages are disabled for a repository. @@ -1128,6 +1235,9 @@ def pages_absent(name, repo_name=None, profile="github"): repo_name Name of the repository, if it differs from ``name``. + owner + Owner of the repository. Defaults to the profile's ``owner``. + profile Profile configuration name to use. Defaults to ``github``. """ @@ -1136,7 +1246,7 @@ def pages_absent(name, repo_name=None, profile="github"): try: try: - __salt__["github_repo.get_pages"](repo_name=repo_name, profile=profile) + __salt__["github_repo.get_pages"](repo_name=repo_name, owner=owner, profile=profile) except GitHubNotFoundError: ret["comment"] = f"Pages are already disabled for {repo_name}" return ret @@ -1147,7 +1257,7 @@ def pages_absent(name, repo_name=None, profile="github"): ret["comment"] = f"Pages would have been disabled for {repo_name}" return ret - __salt__["github_repo.remove_pages"](repo_name=repo_name, profile=profile) + __salt__["github_repo.remove_pages"](repo_name=repo_name, owner=owner, profile=profile) ret["comment"] = f"Pages have been disabled for {repo_name}" except CommandExecutionError as err: ret["result"] = False diff --git a/src/saltext/github/utils/gh/actions.py b/src/saltext/github/utils/gh/actions.py index b62b5578..a1ee6d91 100644 --- a/src/saltext/github/utils/gh/actions.py +++ b/src/saltext/github/utils/gh/actions.py @@ -14,11 +14,11 @@ log = logging.getLogger(__name__) -def _secrets_path(conn, repo_name, *suffix): - return ["repos", conn.org_name, repo_name, "actions", "secrets", *suffix] +def _secrets_path(conn, owner, repo_name, *suffix): + return ["repos", owner or conn.owner, repo_name, "actions", "secrets", *suffix] -def list_secrets(conn, repo_name): +def list_secrets(conn, repo_name, *, owner=None): """ Return all Actions secrets of a repository. Secret values are write-only, so this only carries metadata (``name``, ``created_at``, ``updated_at``). @@ -28,11 +28,14 @@ def list_secrets(conn, repo_name): repo_name Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - return client.query(conn, _secrets_path(conn, repo_name), per_page=100)["secrets"] + return client.query(conn, _secrets_path(conn, owner, repo_name), per_page=100)["secrets"] -def get_secret(conn, repo_name, name): +def get_secret(conn, repo_name, name, *, owner=None): """ Return the metadata of a single Actions secret. @@ -45,13 +48,16 @@ def get_secret(conn, repo_name, name): name Secret name. + owner + Owner of the repository. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the secret does not exist. """ - return client.query(conn, _secrets_path(conn, repo_name, name)) + return client.query(conn, _secrets_path(conn, owner, repo_name, name)) -def get_public_key(conn, repo_name): +def get_public_key(conn, repo_name, *, owner=None): """ Return the public key used for encrypting Actions secrets of a repository, a mapping with ``key_id`` and (base64-encoded) ``key``. @@ -61,8 +67,11 @@ def get_public_key(conn, repo_name): repo_name Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - return client.query(conn, _secrets_path(conn, repo_name, "public-key")) + return client.query(conn, _secrets_path(conn, owner, repo_name, "public-key")) def encrypt_secret(public_key, value): @@ -81,7 +90,7 @@ def encrypt_secret(public_key, value): return base64.b64encode(sealed_box.encrypt(value.encode())).decode() -def set_secret(conn, repo_name, name, value): +def set_secret(conn, repo_name, name, value, *, owner=None): """ Create or update an Actions secret. The value is encrypted to the repository public key before leaving this host. @@ -97,11 +106,14 @@ def set_secret(conn, repo_name, name, value): value Plaintext secret value. + + owner + Owner of the repository. Defaults to the connection's owner. """ - pubkey = get_public_key(conn, repo_name) + pubkey = get_public_key(conn, repo_name, owner=owner) client.query( conn, - _secrets_path(conn, repo_name, name), + _secrets_path(conn, owner, repo_name, name), method="PUT", data={ "encrypted_value": encrypt_secret(pubkey["key"], value), @@ -110,7 +122,7 @@ def set_secret(conn, repo_name, name, value): ) -def delete_secret(conn, repo_name, name): +def delete_secret(conn, repo_name, name, *, owner=None): """ Remove an Actions secret from a repository. @@ -122,11 +134,18 @@ def delete_secret(conn, repo_name, name): name Secret name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - client.query(conn, _secrets_path(conn, repo_name, name), method="DELETE") + client.query(conn, _secrets_path(conn, owner, repo_name, name), method="DELETE") -def get_workflow_permissions(conn, repo_name): +def _workflow_permissions_path(conn, owner, repo_name): + return ["repos", owner or conn.owner, repo_name, "actions", "permissions", "workflow"] + + +def get_workflow_permissions(conn, repo_name, *, owner=None): """ Return the default workflow permissions granted to the ``GITHUB_TOKEN`` in a repository, a mapping with ``default_workflow_permissions`` @@ -137,14 +156,20 @@ def get_workflow_permissions(conn, repo_name): repo_name Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - return client.query( - conn, ["repos", conn.org_name, repo_name, "actions", "permissions", "workflow"] - ) + return client.query(conn, _workflow_permissions_path(conn, owner, repo_name)) def set_workflow_permissions( - conn, repo_name, *, default_workflow_permissions=None, can_approve_pull_request_reviews=None + conn, + repo_name, + *, + default_workflow_permissions=None, + can_approve_pull_request_reviews=None, + owner=None, ): """ Update the default workflow permissions granted to the ``GITHUB_TOKEN`` @@ -162,10 +187,13 @@ def set_workflow_permissions( can_approve_pull_request_reviews Allow GitHub Actions to approve pull requests. + + owner + Owner of the repository. Defaults to the connection's owner. """ client.query( conn, - ["repos", conn.org_name, repo_name, "actions", "permissions", "workflow"], + _workflow_permissions_path(conn, owner, repo_name), method="PUT", data=filter_unset( { diff --git a/src/saltext/github/utils/gh/client.py b/src/saltext/github/utils/gh/client.py index 0f083740..f5793c3b 100644 --- a/src/saltext/github/utils/gh/client.py +++ b/src/saltext/github/utils/gh/client.py @@ -27,36 +27,87 @@ class GitHubConnection: """ - An authenticated PyGithub client bound to a single organization, - together with caches for API responses. + An authenticated PyGithub client bound to a default repository owner + (an organization or user), together with caches for API responses. token Personal access token to authenticate with. - org_name - Organization name this connection is scoped to. + owner + Login of the organization or user this connection is scoped to. + Organization-scoped operations require this to be an organization. + Defaults to the token's user, which requires a token that is tied + to a user account. api_url Base URL of the GitHub API. Set this when targeting a GitHub Enterprise Server instance. Defaults to the public GitHub API. """ - def __init__(self, token, org_name, *, api_url=API_URL_DEFAULT): - self.org_name = org_name + def __init__(self, token, owner=None, *, api_url=API_URL_DEFAULT): + self._owner = owner self.api_url = (api_url or API_URL_DEFAULT).rstrip("/") self.client = github.Github( auth=github.Auth.Token(token), base_url=self.api_url, per_page=100 ) self.cache = {} + @property + def owner(self): + """ + Login of the organization or user this connection is scoped to. + Defaults to the token's user (lazily resolved). + """ + if self._owner is None: + try: + self._owner = self.auth_user.login + except github.GithubException as err: + raise CommandExecutionError( + "Failed to look up the token's user for the default repository " + f"owner: {_error_message(err.status, err.data)}. Tokens that are " + "not tied to a user account (e.g. GitHub App installation tokens) " + "require the 'owner' profile parameter." + ) from err + return self._owner + @property def org(self): """ PyGithub ``Organization`` object this connection is scoped to (cached). + The connection's owner must be an organization. + """ + return self.get_org() + + def get_org(self, owner=None): + """ + Return a PyGithub ``Organization`` object for an owner login (cached). + Defaults to the connection's owner. + """ + owner = owner or self.owner + key = ("org", owner.lower()) + if key not in self.cache: + self.cache[key] = self.client.get_organization(owner) + return self.cache[key] + + @property + def auth_user(self): + """ + PyGithub ``AuthenticatedUser`` object of the token's user (cached). + """ + if "auth_user" not in self.cache: + self.cache["auth_user"] = self.client.get_user() + return self.cache["auth_user"] + + def owner_type(self, owner=None): + """ + Return the account type of an owner login, either ``Organization`` + or ``User`` (cached). Defaults to the connection's owner. """ - if "org" not in self.cache: - self.cache["org"] = self.client.get_organization(self.org_name) - return self.cache["org"] + owner = owner or self.owner + key = ("owner_type", owner.lower()) + if key not in self.cache: + self.cache[key] = self.client.get_user(owner).type + return self.cache[key] def _error_message(status, data, fallback=""): diff --git a/src/saltext/github/utils/gh/deploy_keys.py b/src/saltext/github/utils/gh/deploy_keys.py index ba0382cd..aa5c1818 100644 --- a/src/saltext/github/utils/gh/deploy_keys.py +++ b/src/saltext/github/utils/gh/deploy_keys.py @@ -9,11 +9,11 @@ log = logging.getLogger(__name__) -def _path(conn, repo_name, *suffix): - return ["repos", conn.org_name, repo_name, "keys", *suffix] +def _path(conn, owner, repo_name, *suffix): + return ["repos", owner or conn.owner, repo_name, "keys", *suffix] -def list_all(conn, repo_name): +def list_all(conn, repo_name, *, owner=None): """ Return all deploy keys of a repository. @@ -22,11 +22,14 @@ def list_all(conn, repo_name): repo_name Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - return client.query(conn, _path(conn, repo_name)) + return client.query(conn, _path(conn, owner, repo_name)) -def get(conn, repo_name, key_id): +def get(conn, repo_name, key_id, *, owner=None): """ Return the data of a single deploy key. @@ -39,13 +42,16 @@ def get(conn, repo_name, key_id): key_id Deploy key ID. + owner + Owner of the repository. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the deploy key does not exist. """ - return client.query(conn, _path(conn, repo_name, key_id)) + return client.query(conn, _path(conn, owner, repo_name, key_id)) -def create(conn, repo_name, title, key, *, read_only=False): +def create(conn, repo_name, title, key, *, read_only=False, owner=None): """ Add a deploy key to a repository. Returns the data of the created key. @@ -65,16 +71,19 @@ def create(conn, repo_name, title, key, *, read_only=False): read_only Deny write access to the repository for this key. Defaults to false. + + owner + Owner of the repository. Defaults to the connection's owner. """ return client.query( conn, - _path(conn, repo_name), + _path(conn, owner, repo_name), method="POST", data={"title": title, "key": key, "read_only": read_only}, ) -def delete(conn, repo_name, key_id): +def delete(conn, repo_name, key_id, *, owner=None): """ Remove a deploy key from a repository. @@ -86,5 +95,8 @@ def delete(conn, repo_name, key_id): key_id Deploy key ID. + + owner + Owner of the repository. Defaults to the connection's owner. """ - client.query(conn, _path(conn, repo_name, key_id), method="DELETE") + client.query(conn, _path(conn, owner, repo_name, key_id), method="DELETE") diff --git a/src/saltext/github/utils/gh/environments.py b/src/saltext/github/utils/gh/environments.py index 2303aecd..dcbc83af 100644 --- a/src/saltext/github/utils/gh/environments.py +++ b/src/saltext/github/utils/gh/environments.py @@ -11,9 +11,9 @@ log = logging.getLogger(__name__) -def _path(conn, repo_name, *suffix): +def _path(conn, owner, repo_name, *suffix): # Environment names can contain characters that are invalid in URL paths - return ["repos", conn.org_name, repo_name, "environments"] + [ + return ["repos", owner or conn.owner, repo_name, "environments"] + [ quote(str(segment), safe="") for segment in suffix ] @@ -46,7 +46,7 @@ def normalize(env): return ret -def list_all(conn, repo_name): +def list_all(conn, repo_name, *, owner=None): """ Return all environments of a repository. @@ -55,11 +55,14 @@ def list_all(conn, repo_name): repo_name Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - return client.query(conn, _path(conn, repo_name), per_page=100)["environments"] + return client.query(conn, _path(conn, owner, repo_name), per_page=100)["environments"] -def get(conn, repo_name, name): +def get(conn, repo_name, name, *, owner=None): """ Return the data of a single environment. @@ -72,10 +75,13 @@ def get(conn, repo_name, name): name Environment name. + owner + Owner of the repository. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the environment does not exist. """ - return client.query(conn, _path(conn, repo_name, name)) + return client.query(conn, _path(conn, owner, repo_name, name)) def set_( @@ -87,6 +93,7 @@ def set_( prevent_self_review=None, reviewers=None, deployment_branch_policy=None, + owner=None, ): """ Create or update an environment, replacing its whole configuration. @@ -120,6 +127,9 @@ def set_( ``protected_branches`` and ``custom_branch_policies`` booleans. Branch name patterns for custom policies are managed separately, see :func:`create_branch_policy`. + + owner + Owner of the repository. Defaults to the connection's owner. """ data = filter_unset( { @@ -129,10 +139,10 @@ def set_( } ) data["deployment_branch_policy"] = deployment_branch_policy - return client.query(conn, _path(conn, repo_name, name), method="PUT", data=data) + return client.query(conn, _path(conn, owner, repo_name, name), method="PUT", data=data) -def delete(conn, repo_name, name): +def delete(conn, repo_name, name, *, owner=None): """ Delete an environment from a repository. @@ -144,11 +154,14 @@ def delete(conn, repo_name, name): name Environment name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - client.query(conn, _path(conn, repo_name, name), method="DELETE") + client.query(conn, _path(conn, owner, repo_name, name), method="DELETE") -def list_branch_policies(conn, repo_name, environment): +def list_branch_policies(conn, repo_name, environment, *, owner=None): """ Return all deployment branch policies of an environment. @@ -160,16 +173,19 @@ def list_branch_policies(conn, repo_name, environment): environment Environment name. + + owner + Owner of the repository. Defaults to the connection's owner. """ return client.query( conn, - _path(conn, repo_name, environment, "deployment-branch-policies"), + _path(conn, owner, repo_name, environment, "deployment-branch-policies"), per_page=100, )["branch_policies"] -def create_branch_policy(conn, repo_name, environment, name, *, type_="branch"): - r""" +def create_branch_policy(conn, repo_name, environment, name, *, typ="branch", owner=None): + """ Add a deployment branch (or tag) policy to an environment. Returns the data of the created policy. @@ -189,19 +205,22 @@ def create_branch_policy(conn, repo_name, environment, name, *, type_="branch"): Name pattern that branches (or tags) must match in order to deploy to the environment. - type\_ + typ Target of this rule, either ``branch`` or ``tag``. Defaults to ``branch``. + + owner + Owner of the repository. Defaults to the connection's owner. """ return client.query( conn, - _path(conn, repo_name, environment, "deployment-branch-policies"), + _path(conn, owner, repo_name, environment, "deployment-branch-policies"), method="POST", - data={"name": name, "type": type_}, + data={"name": name, "type": typ}, ) -def delete_branch_policy(conn, repo_name, environment, policy_id): +def delete_branch_policy(conn, repo_name, environment, policy_id, *, owner=None): """ Remove a deployment branch policy from an environment. @@ -216,9 +235,12 @@ def delete_branch_policy(conn, repo_name, environment, policy_id): policy_id Branch policy ID. + + owner + Owner of the repository. Defaults to the connection's owner. """ client.query( conn, - _path(conn, repo_name, environment, "deployment-branch-policies", policy_id), + _path(conn, owner, repo_name, environment, "deployment-branch-policies", policy_id), method="DELETE", ) diff --git a/src/saltext/github/utils/gh/issues.py b/src/saltext/github/utils/gh/issues.py index 7657d5cd..e4785c81 100644 --- a/src/saltext/github/utils/gh/issues.py +++ b/src/saltext/github/utils/gh/issues.py @@ -9,7 +9,7 @@ log = logging.getLogger(__name__) -def get(conn, repo_name, number): +def get(conn, repo_name, number, *, owner=None): """ Return the raw data of a single issue. @@ -21,11 +21,14 @@ def get(conn, repo_name, number): number Issue number to retrieve. + + owner + Owner of the repository. Defaults to the connection's owner. """ - return client.query(conn, ["repos", conn.org_name, repo_name, "issues", str(number)]) + return client.query(conn, ["repos", owner or conn.owner, repo_name, "issues", str(number)]) -def get_comments(conn, repo_name, number, since=None): +def get_comments(conn, repo_name, number, since=None, *, owner=None): """ Return the raw comment data for a single issue. @@ -41,12 +44,17 @@ def get_comments(conn, repo_name, number, since=None): since Only comments updated at or after this time are returned. This is a timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. + + owner + Owner of the repository. Defaults to the connection's owner. """ params = {} if since: params["since"] = since return client.query( - conn, ["repos", conn.org_name, repo_name, "issues", str(number), "comments"], params=params + conn, + ["repos", owner or conn.owner, repo_name, "issues", str(number), "comments"], + params=params, ) @@ -64,6 +72,7 @@ def list_all( direction="desc", since=None, per_page=None, + owner=None, ): """ Return the raw data of all issues in a repository matching the search @@ -110,6 +119,9 @@ def list_all( per_page Pagination size, maximum 100. + + owner + Owner of the repository. Defaults to the connection's owner. """ params = {} @@ -137,7 +149,7 @@ def list_all( if direction and direction != "desc": params["direction"] = direction - issues = client.query(conn, ["repos", conn.org_name, repo_name, "issues"], params=params) + issues = client.query(conn, ["repos", owner or conn.owner, repo_name, "issues"], params=params) # Pull requests are included in the issue list from GitHub. # Let's not include those in the return. diff --git a/src/saltext/github/utils/gh/labels.py b/src/saltext/github/utils/gh/labels.py index 8a044145..6ec3137e 100644 --- a/src/saltext/github/utils/gh/labels.py +++ b/src/saltext/github/utils/gh/labels.py @@ -11,9 +11,9 @@ log = logging.getLogger(__name__) -def _path(conn, repo_name, *suffix): +def _path(conn, owner, repo_name, *suffix): # Label names can contain characters that are invalid in URL paths - return ["repos", conn.org_name, repo_name, "labels"] + [ + return ["repos", owner or conn.owner, repo_name, "labels"] + [ quote(str(segment), safe="") for segment in suffix ] @@ -30,7 +30,7 @@ def normalize_color(color): return color.lstrip("#").lower() -def list_all(conn, repo_name): +def list_all(conn, repo_name, *, owner=None): """ Return all labels of a repository. @@ -39,11 +39,14 @@ def list_all(conn, repo_name): repo_name Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - return client.query(conn, _path(conn, repo_name)) + return client.query(conn, _path(conn, owner, repo_name)) -def get(conn, repo_name, name): +def get(conn, repo_name, name, *, owner=None): """ Return the data of a single label. @@ -56,13 +59,16 @@ def get(conn, repo_name, name): name Label name. + owner + Owner of the repository. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the label does not exist. """ - return client.query(conn, _path(conn, repo_name, name)) + return client.query(conn, _path(conn, owner, repo_name, name)) -def create(conn, repo_name, name, *, color=None, description=None): +def create(conn, repo_name, name, *, color=None, description=None, owner=None): """ Create a label in a repository. Returns the data of the created label. @@ -81,12 +87,15 @@ def create(conn, repo_name, name, *, color=None, description=None): description Short description of the label. + + owner + Owner of the repository. Defaults to the connection's owner. """ data = filter_unset({"name": name, "color": normalize_color(color), "description": description}) - return client.query(conn, _path(conn, repo_name), method="POST", data=data) + return client.query(conn, _path(conn, owner, repo_name), method="POST", data=data) -def update(conn, repo_name, name, *, new_name=None, color=None, description=None): +def update(conn, repo_name, name, *, new_name=None, color=None, description=None, owner=None): """ Update an existing label. Returns the data of the updated label. @@ -107,14 +116,17 @@ def update(conn, repo_name, name, *, new_name=None, color=None, description=None description Short description of the label. + + owner + Owner of the repository. Defaults to the connection's owner. """ data = filter_unset( {"new_name": new_name, "color": normalize_color(color), "description": description} ) - return client.query(conn, _path(conn, repo_name, name), method="PATCH", data=data) + return client.query(conn, _path(conn, owner, repo_name, name), method="PATCH", data=data) -def delete(conn, repo_name, name): +def delete(conn, repo_name, name, *, owner=None): """ Delete a label from a repository. @@ -126,5 +138,8 @@ def delete(conn, repo_name, name): name Label name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - client.query(conn, _path(conn, repo_name, name), method="DELETE") + client.query(conn, _path(conn, owner, repo_name, name), method="DELETE") diff --git a/src/saltext/github/utils/gh/members.py b/src/saltext/github/utils/gh/members.py index 0244bcef..527c6b28 100644 --- a/src/saltext/github/utils/gh/members.py +++ b/src/saltext/github/utils/gh/members.py @@ -12,23 +12,28 @@ @api_request -def list_all(conn, ignore_cache=False): +def list_all(conn, *, owner=None, ignore_cache=False): """ - Return the logins of all members of the organization (cached). + Return the logins of all members of an organization (cached). conn :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + owner + Organization to target. Defaults to the connection's owner. + ignore_cache Bypass the cached member list. """ - if "users" not in conn.cache or ignore_cache: - conn.cache["users"] = [member.login for member in conn.org.get_members()] - return conn.cache["users"] + owner = owner or conn.owner + key = ("members", owner.lower()) + if key not in conn.cache or ignore_cache: + conn.cache[key] = [member.login for member in conn.get_org(owner).get_members()] + return conn.cache[key] @api_request -def get_info(conn, login): +def get_info(conn, login, *, owner=None): """ Return information about a user, including their organization membership state. @@ -39,6 +44,9 @@ def get_info(conn, login): login User handle to look up. + owner + Organization to target. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the user does not exist. """ @@ -56,21 +64,22 @@ def get_info(conn, login): "url": user.url, } + org = conn.get_org(owner) try: - membership = user.get_organization_membership(conn.org) + membership = user.get_organization_membership(org) except UnknownObjectException: ret["membership_state"] = "nonexistent" ret["in_org"] = False return ret - ret["in_org"] = conn.org.has_in_members(user) + ret["in_org"] = org.has_in_members(user) ret["membership_state"] = membership.state return ret @api_request -def invite(conn, login): +def invite(conn, login, *, owner=None): """ Invite a user into the organization. Returns whether the resulting membership state is ``pending``. @@ -81,6 +90,9 @@ def invite(conn, login): login User handle to invite. + owner + Organization to target. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the user does not exist. """ @@ -88,14 +100,14 @@ def invite(conn, login): # There is no public method that also returns the resulting membership state _, data = conn.client.requester.requestJsonAndCheck( - "PUT", conn.org.url + "/memberships/" + user.login + "PUT", conn.get_org(owner).url + "/memberships/" + user.login ) return data.get("state") == "pending" @api_request -def remove(conn, login): +def remove(conn, login, *, owner=None): """ Remove a user from the organization (if they are a member). @@ -105,19 +117,23 @@ def remove(conn, login): login User handle to remove. + owner + Organization to target. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the user does not exist. """ user = conn.client.get_user(login) - if conn.org.has_in_members(user): - conn.org.remove_from_members(user) + org = conn.get_org(owner) + if org.has_in_members(user): + org.remove_from_members(user) return True @api_request -def list_without_mfa(conn, ignore_cache=False): +def list_without_mfa(conn, *, owner=None, ignore_cache=False): """ Return the logins (lowercase) of all organization members without MFA turned on (cached). @@ -125,11 +141,17 @@ def list_without_mfa(conn, ignore_cache=False): conn :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + owner + Organization to target. Defaults to the connection's owner. + ignore_cache Bypass the cached member list. """ - if "members_without_mfa" not in conn.cache or ignore_cache: - conn.cache["members_without_mfa"] = [ - member.login.lower() for member in conn.org.get_members(filter_="2fa_disabled") + owner = owner or conn.owner + key = ("members_without_mfa", owner.lower()) + if key not in conn.cache or ignore_cache: + conn.cache[key] = [ + member.login.lower() + for member in conn.get_org(owner).get_members(filter_="2fa_disabled") ] - return conn.cache["members_without_mfa"] + return conn.cache[key] diff --git a/src/saltext/github/utils/gh/milestones.py b/src/saltext/github/utils/gh/milestones.py index a3e1a09d..52ca12d6 100644 --- a/src/saltext/github/utils/gh/milestones.py +++ b/src/saltext/github/utils/gh/milestones.py @@ -9,7 +9,7 @@ log = logging.getLogger(__name__) -def get(conn, repo_name, number): +def get(conn, repo_name, number, *, owner=None): """ Return the raw data of a single milestone. @@ -21,8 +21,11 @@ def get(conn, repo_name, number): number Milestone number to retrieve. + + owner + Owner of the repository. Defaults to the connection's owner. """ - return client.query(conn, ["repos", conn.org_name, repo_name, "milestones", str(number)]) + return client.query(conn, ["repos", owner or conn.owner, repo_name, "milestones", str(number)]) def list_all( @@ -33,6 +36,7 @@ def list_all( sort="due_on", direction="asc", per_page=None, + owner=None, ): """ Return the raw data of all milestones in a repository matching the @@ -57,6 +61,9 @@ def list_all( per_page Pagination size, maximum 100. + + owner + Owner of the repository. Defaults to the connection's owner. """ params = {} @@ -71,7 +78,9 @@ def list_all( if direction and direction != "asc": params["direction"] = direction - return client.query(conn, ["repos", conn.org_name, repo_name, "milestones"], params=params) + return client.query( + conn, ["repos", owner or conn.owner, repo_name, "milestones"], params=params + ) def format_milestone(milestone): diff --git a/src/saltext/github/utils/gh/pages.py b/src/saltext/github/utils/gh/pages.py index 364c4a4d..0554bbbd 100644 --- a/src/saltext/github/utils/gh/pages.py +++ b/src/saltext/github/utils/gh/pages.py @@ -10,8 +10,8 @@ log = logging.getLogger(__name__) -def _path(conn, repo_name): - return ["repos", conn.org_name, repo_name, "pages"] +def _path(conn, owner, repo_name): + return ["repos", owner or conn.owner, repo_name, "pages"] def build_source(branch=None, path=None): @@ -29,7 +29,7 @@ def build_source(branch=None, path=None): return source or None -def get(conn, repo_name): +def get(conn, repo_name, *, owner=None): """ Return information about the GitHub Pages site of a repository. @@ -39,13 +39,16 @@ def get(conn, repo_name): repo_name Repository name. + owner + Owner of the repository. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the repository has no Pages site. """ - return client.query(conn, _path(conn, repo_name)) + return client.query(conn, _path(conn, owner, repo_name)) -def create(conn, repo_name, *, build_type=None, source=None): +def create(conn, repo_name, *, build_type=None, source=None, owner=None): """ Configure a GitHub Pages site for a repository. Returns the data of the created site. @@ -63,16 +66,21 @@ def create(conn, repo_name, *, build_type=None, source=None): source For ``legacy`` builds, the deployment source as a mapping with ``branch`` and ``path``. + + owner + Owner of the repository. Defaults to the connection's owner. """ return client.query( conn, - _path(conn, repo_name), + _path(conn, owner, repo_name), method="POST", data=filter_unset({"build_type": build_type, "source": source}), ) -def update(conn, repo_name, *, build_type=None, source=None, cname=..., https_enforced=None): +def update( + conn, repo_name, *, build_type=None, source=None, cname=..., https_enforced=None, owner=None +): """ Update the GitHub Pages site configuration of a repository. @@ -96,16 +104,19 @@ def update(conn, repo_name, *, build_type=None, source=None, cname=..., https_en https_enforced Enforce HTTPS for the site. + + owner + Owner of the repository. Defaults to the connection's owner. """ data = filter_unset( {"build_type": build_type, "source": source, "https_enforced": https_enforced} ) if cname is not ...: data["cname"] = cname - client.query(conn, _path(conn, repo_name), method="PUT", data=data) + client.query(conn, _path(conn, owner, repo_name), method="PUT", data=data) -def delete(conn, repo_name): +def delete(conn, repo_name, *, owner=None): """ Delete the GitHub Pages site of a repository. @@ -114,5 +125,8 @@ def delete(conn, repo_name): repo_name Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. """ - client.query(conn, _path(conn, repo_name), method="DELETE") + client.query(conn, _path(conn, owner, repo_name), method="DELETE") diff --git a/src/saltext/github/utils/gh/profile.py b/src/saltext/github/utils/gh/profile.py index bc926ed1..437c14a3 100644 --- a/src/saltext/github/utils/gh/profile.py +++ b/src/saltext/github/utils/gh/profile.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) -def get_config_value(profile, config_name, *, salt, context): +def get_config_value(profile, config_name, *, __salt__, __context__): """ Return a profile's configuration value based on the supplied configuration name. Values are rendered via ``config.get`` @@ -27,24 +27,24 @@ def get_config_value(profile, config_name, *, salt, context): config_name Configuration item name to look up. - salt - ``__salt__`` dunder of the calling module. + __salt__ + Pass ``__salt__``. - context - ``__context__`` dunder of the calling module. + __context__ + Pass ``__context__``. """ - if "gh_profile" not in context or profile not in context["gh_profile"]: - if not salt["config.get"](profile): + if "gh_profile" not in __context__ or profile not in __context__["gh_profile"]: + if not __salt__["config.get"](profile): raise CommandExecutionError( f"Authentication information could not be found for the '{profile}' profile." ) - context.setdefault("gh_profile", {})[profile] = {} + __context__.setdefault("gh_profile", {})[profile] = {} - cache = context["gh_profile"][profile] + cache = __context__["gh_profile"][profile] if config_name not in cache: # Unlike config.option, config.get resolves SDB URIs in scalar values, # hence request each value separately and cache the rendered result. - cache[config_name] = salt["config.get"](f"{profile}:{config_name}", default=None) + cache[config_name] = __salt__["config.get"](f"{profile}:{config_name}", default=None) config_value = cache[config_name] if config_value is None: @@ -55,26 +55,52 @@ def get_config_value(profile, config_name, *, salt, context): return config_value -def get_api_url(profile, *, salt, context): +def get_api_url(profile, *, __salt__, __context__): """ Return the configured GitHub API base URL, defaulting to the public one. profile Profile name that contains configuration information. - salt - ``__salt__`` dunder of the calling module. + __salt__ + Pass ``__salt__``. - context - ``__context__`` dunder of the calling module. + __context__ + Pass ``__context__``. """ try: - return get_config_value(profile, "api_url", salt=salt, context=context).rstrip("/") + return get_config_value( + profile, "api_url", __salt__=__salt__, __context__=__context__ + ).rstrip("/") except CommandExecutionError: return API_URL_DEFAULT -def get_conn(profile, *, salt, context): +def get_owner(profile, *, __salt__, __context__): + """ + Return the configured default repository owner: the profile's + ``owner``, falling back to the legacy ``org_name`` alias. Returns + ``None`` when neither is set, making the connection default to the + token's user. + + profile + Profile name that contains configuration information. + + __salt__ + Pass ``__salt__``. + + __context__ + Pass ``__context__``. + """ + for key in ("owner", "org_name"): + try: + return get_config_value(profile, key, __salt__=__salt__, __context__=__context__) + except CommandExecutionError: + continue + return None + + +def get_conn(profile, *, __salt__, __context__): """ Return a :class:`~saltext.github.utils.gh.client.GitHubConnection` for a profile, cached in ``context`` for performance. @@ -83,18 +109,18 @@ def get_conn(profile, *, salt, context): Profile name that contains configuration information. salt - ``__salt__`` dunder of the calling module. + Pass ``__salt__``. context - ``__context__`` dunder of the calling module. + Pass ``__context__``. """ - token = get_config_value(profile, "token", salt=salt, context=context) - org_name = get_config_value(profile, "org_name", salt=salt, context=context) - api_url = get_api_url(profile, salt=salt, context=context) + token = get_config_value(profile, "token", __salt__=__salt__, __context__=__context__) + owner = get_owner(profile, __salt__=__salt__, __context__=__context__) + api_url = get_api_url(profile, __salt__=__salt__, __context__=__context__) # Hash the token to keep the secret out of context dumps token_hash = hashlib.sha256(token.encode()).hexdigest()[:16] - key = f"github.{token_hash}:{org_name}:{api_url}" + key = f"github.{token_hash}:{owner}:{api_url}" - if key not in context: - context[key] = GitHubConnection(token, org_name, api_url=api_url) - return context[key] + if key not in __context__: + __context__[key] = GitHubConnection(token, owner, api_url=api_url) + return __context__[key] diff --git a/src/saltext/github/utils/gh/pulls.py b/src/saltext/github/utils/gh/pulls.py index 6d00e614..35997e76 100644 --- a/src/saltext/github/utils/gh/pulls.py +++ b/src/saltext/github/utils/gh/pulls.py @@ -19,6 +19,7 @@ def list_all( sort="created", direction="desc", per_page=None, + owner=None, ): """ Return the raw data of all pull requests in a repository matching the @@ -52,6 +53,9 @@ def list_all( per_page Pagination size, maximum 100. + + owner + Owner of the repository. Defaults to the connection's owner. """ params = {} @@ -71,7 +75,7 @@ def list_all( if direction and direction != "desc": params["direction"] = direction - return client.query(conn, ["repos", conn.org_name, repo_name, "pulls"], params=params) + return client.query(conn, ["repos", owner or conn.owner, repo_name, "pulls"], params=params) def format_pr(pr_): diff --git a/src/saltext/github/utils/gh/repos.py b/src/saltext/github/utils/gh/repos.py index ca6ccea1..c3d850a7 100644 --- a/src/saltext/github/utils/gh/repos.py +++ b/src/saltext/github/utils/gh/repos.py @@ -1,5 +1,6 @@ """ -Operations on GitHub organization repositories. +Operations on GitHub repositories. Repository listing and creation are +organization-scoped, everything else works on any owner's repositories. """ import logging @@ -44,29 +45,44 @@ def to_dict(repo): @api_request -def list_all(conn, ignore_cache=False): +def list_all(conn, *, owner=None, ignore_cache=False): """ - Return all repositories of the organization as PyGithub ``Repository`` + Return all repositories of an owner as PyGithub ``Repository`` objects (cached). conn :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + owner + Owner whose repositories to list. Defaults to the connection's + owner. Private repositories of users other than the + authenticated one are not visible. + ignore_cache Bypass the cached repository list. """ - if "repos" not in conn.cache or ignore_cache: + owner = owner or conn.owner + key = ("repos", owner.lower()) + if key not in conn.cache or ignore_cache: + if conn.owner_type(owner) == "Organization": + found = conn.get_org(owner).get_repos() + elif owner.lower() == conn.auth_user.login.lower(): + # Listing the authenticated user's repos directly includes + # private ones, which /users/{owner}/repos does not carry. + found = conn.auth_user.get_repos(affiliation="owner") + else: + found = conn.client.get_user(owner).get_repos() repos = [] - for repo in conn.org.get_repos(): + for repo in found: repos.append(repo) # Cache a copy of each repo for single lookups - conn.cache[("repo_info", repo.name.lower())] = to_dict(repo) - conn.cache["repos"] = repos - return conn.cache["repos"] + conn.cache[("repo_info", owner.lower(), repo.name.lower())] = to_dict(repo) + conn.cache[key] = repos + return conn.cache[key] @api_request -def get_info(conn, name, ignore_cache=False): +def get_info(conn, name, *, owner=None, ignore_cache=False): """ Return information about a single repository as a dictionary (cached). @@ -76,24 +92,27 @@ def get_info(conn, name, ignore_cache=False): name Repository name. + owner + Owner of the repository. Defaults to the connection's owner. + ignore_cache Bypass the cached repository information. Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the repository does not exist. """ - key = ("repo_info", name.lower()) + owner = owner or conn.owner + key = ("repo_info", owner.lower(), name.lower()) if key not in conn.cache or ignore_cache: try: # With lazy fetching, client.get_repo returns a Repository object # even if the repo does not exist - the exception is only raised # once attributes of the object are accessed. - repo = conn.client.get_repo(f"{conn.org_name}/{name}") + repo = conn.client.get_repo(f"{owner}/{name}") conn.cache[key] = to_dict(repo) except UnknownObjectException as exc: raise GitHubNotFoundError( - f"The '{name}' repository under the '{conn.org_name}' organization " - "could not be found.", + f"The '{name}' repository belonging to '{owner}' could not be found.", status=exc.status, data=exc.data, ) from exc @@ -101,7 +120,7 @@ def get_info(conn, name, ignore_cache=False): @api_request -def get_teams(conn, name): +def get_teams(conn, name, *, owner=None): """ Return the teams a repository belongs to. @@ -111,17 +130,20 @@ def get_teams(conn, name): name Repository name. + owner + Owner of the repository. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the repository does not exist. """ + owner = owner or conn.owner try: - repo = conn.client.get_repo(f"{conn.org_name}/{name}") + repo = conn.client.get_repo(f"{owner}/{name}") teams = repo.get_teams() return [{"id": team.id, "name": team.name, "permission": team.permission} for team in teams] except UnknownObjectException as exc: raise GitHubNotFoundError( - f"The '{name}' repository under the '{conn.org_name}' organization " - "could not be found.", + f"The '{name}' repository belonging to '{owner}' could not be found.", status=exc.status, data=exc.data, ) from exc @@ -132,6 +154,7 @@ def create( conn, name, *, + owner=None, description=None, homepage=None, private=None, @@ -143,8 +166,7 @@ def create( license_template=None, ): """ - Create a new repository in the organization and wait for it to - become visible. + Create a new repository and wait for it to become visible. conn :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. @@ -152,6 +174,11 @@ def create( name Repository name to create. + owner + Owner to create the repository for. Defaults to the connection's + owner. The GitHub API only supports creating repositories + for organizations and the authenticated user. + description Repository description. @@ -192,13 +219,25 @@ def create( "license_template": license_template, } ) - repo = conn.org.create_repo(name, **parameters) + owner = owner or conn.owner + if conn.owner_type(owner) == "Organization": + creator = conn.get_org(owner) + elif owner.lower() == conn.auth_user.login.lower(): + creator = conn.auth_user + else: + raise CommandExecutionError( + f"Cannot create a repository for '{owner}': the GitHub API only supports " + "creating repositories for organizations and the authenticated user." + ) + repo = creator.create_repo(name, **parameters) # Fetching a new repo's associations can 404 for a short while - # after creation, wait until it settles. + # after creation, wait until it settles. User repos have no team + # associations, probe the repo itself there. + probe = get_teams if creator is not conn.auth_user else get_info for attempt in range(3): try: - get_teams(conn, name) + probe(conn, name, owner=owner) break except GitHubNotFoundError: log.info("Attempt %s to fetch new repo %s failed", attempt, name) @@ -207,9 +246,9 @@ def create( raise CommandExecutionError(f"Failed to verify repo {name} after creation.") info = to_dict(repo) - conn.cache[("repo_info", repo.name.lower())] = info - if "repos" in conn.cache: - conn.cache["repos"].append(repo) + conn.cache[("repo_info", owner.lower(), repo.name.lower())] = info + if ("repos", owner.lower()) in conn.cache: + conn.cache[("repos", owner.lower())].append(repo) return info @@ -218,6 +257,7 @@ def update( conn, name, *, + owner=None, description=None, homepage=None, private=None, @@ -228,7 +268,7 @@ def update( allow_privacy_change=False, ): """ - Update an existing repository in the organization. + Update an existing repository. conn :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. @@ -236,6 +276,9 @@ def update( name Repository name to update. + owner + Owner of the repository. Defaults to the connection's owner. + description Repository description. @@ -266,9 +309,8 @@ def update( f"The private field is set to be changed for repo {name} " "but allow_repo_privacy_changes disallows this." ) - # Resolve via the owner-agnostic endpoint, which also works for - # user-owned repositories - repo = conn.client.get_repo(f"{conn.org_name}/{name}") + owner = owner or conn.owner + repo = conn.client.get_repo(f"{owner}/{name}") parameters = filter_unset( { "description": description, @@ -281,22 +323,26 @@ def update( } ) repo.edit(name, **parameters) - return get_info(conn, name, ignore_cache=True) # Refresh cache + return get_info(conn, name, owner=owner, ignore_cache=True) # Refresh cache @api_request -def delete(conn, name): +def delete(conn, name, *, owner=None): """ - Delete a repository from the organization. + Delete a repository. conn :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. name Repository name to delete. + + owner + Owner of the repository. Defaults to the connection's owner. """ - # Resolve via the owner-agnostic endpoint, which also works for - # user-owned repositories - repo = conn.client.get_repo(f"{conn.org_name}/{name}") + owner = owner or conn.owner + repo = conn.client.get_repo(f"{owner}/{name}") repo.delete() - list_all(conn, ignore_cache=True) # Refresh cache + conn.cache.pop(("repo_info", owner.lower(), name.lower()), None) + if ("repos", owner.lower()) in conn.cache: + list_all(conn, owner=owner, ignore_cache=True) # Refresh the repo list cache diff --git a/src/saltext/github/utils/gh/rulesets.py b/src/saltext/github/utils/gh/rulesets.py index 8703d5db..da440b12 100644 --- a/src/saltext/github/utils/gh/rulesets.py +++ b/src/saltext/github/utils/gh/rulesets.py @@ -19,13 +19,15 @@ def build_path(ruleset_type, *, owner=None, repo_name=None, org_name=None, rules Ruleset type, either ``repo`` or ``org``. owner - Owner of the repository. Required for ``repo`` rulesets. + Owner of the repository. Required for ``repo`` rulesets + (the calling functions default it to the connection's owner). repo_name Repo name. Required for ``repo`` rulesets. org_name - Org name. Required for ``org`` rulesets. + Org name. Required for ``org`` rulesets (the calling functions + default it to the connection's owner). ruleset_id ID of a specific ruleset to address. @@ -72,7 +74,12 @@ def list_all( Other parameters as described in :func:`build_path`. ``headers`` is a mapping of extra headers to send. """ - path = build_path(ruleset_type, owner=owner, repo_name=repo_name, org_name=org_name) + path = build_path( + ruleset_type, + owner=owner or conn.owner, + repo_name=repo_name, + org_name=org_name or conn.owner, + ) return client.query(conn, path, headers=headers, per_page=per_page) @@ -90,7 +97,11 @@ def get(conn, ruleset_id, ruleset_type, *, owner=None, repo_name=None, org_name= mapping of extra headers to send. """ path = build_path( - ruleset_type, owner=owner, repo_name=repo_name, org_name=org_name, ruleset_id=ruleset_id + ruleset_type, + owner=owner or conn.owner, + repo_name=repo_name, + org_name=org_name or conn.owner, + ruleset_id=ruleset_id, ) return client.query(conn, path, headers=headers) @@ -111,7 +122,12 @@ def create( mapping of extra headers to send. """ check_params(ruleset_params, required={"name", "enforcement"}) - path = build_path(ruleset_type, owner=owner, repo_name=repo_name, org_name=org_name) + path = build_path( + ruleset_type, + owner=owner or conn.owner, + repo_name=repo_name, + org_name=org_name or conn.owner, + ) return client.query(conn, path, method="POST", data=ruleset_params, headers=headers) @@ -143,7 +159,11 @@ def update( """ check_params(ruleset_params) path = build_path( - ruleset_type, owner=owner, repo_name=repo_name, org_name=org_name, ruleset_id=ruleset_id + ruleset_type, + owner=owner or conn.owner, + repo_name=repo_name, + org_name=org_name or conn.owner, + ruleset_id=ruleset_id, ) return client.query(conn, path, method="PUT", data=ruleset_params, headers=headers) @@ -164,6 +184,10 @@ def delete( mapping of extra headers to send. """ path = build_path( - ruleset_type, owner=owner, repo_name=repo_name, org_name=org_name, ruleset_id=ruleset_id + ruleset_type, + owner=owner or conn.owner, + repo_name=repo_name, + org_name=org_name or conn.owner, + ruleset_id=ruleset_id, ) client.query(conn, path, method="DELETE", headers=headers) diff --git a/src/saltext/github/utils/gh/teams.py b/src/saltext/github/utils/gh/teams.py index 439f64d2..f3a40c47 100644 --- a/src/saltext/github/utils/gh/teams.py +++ b/src/saltext/github/utils/gh/teams.py @@ -15,20 +15,25 @@ @api_request -def list_all(conn, ignore_cache=False): +def list_all(conn, *, owner=None, ignore_cache=False): """ - Return all teams of the organization as a mapping of team name to + Return all teams of an organization as a mapping of team name to team details (cached). conn :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + owner + Organization owning the teams. Defaults to the connection's owner. + ignore_cache Bypass the cached team list. """ - if "teams" not in conn.cache or ignore_cache: + owner = owner or conn.owner + key = ("teams", owner.lower()) + if key not in conn.cache or ignore_cache: teams = {} - for team in conn.org.get_teams(): + for team in conn.get_org(owner).get_teams(): # These attributes are all present in the list response. Take care # not to access any lazily completable attributes missing from it # here since each one would trigger a separate request per team. @@ -40,11 +45,11 @@ def list_all(conn, ignore_cache=False): "permission": team.permission, "privacy": team.privacy, } - conn.cache["teams"] = teams - return conn.cache["teams"] + conn.cache[key] = teams + return conn.cache[key] -def get(conn, name): +def get(conn, name, *, owner=None): """ Return the (cached) details of a single team, or None if it does not exist. @@ -54,12 +59,17 @@ def get(conn, name): name Team name. + + owner + Organization owning the team. Defaults to the connection's owner. """ - return list_all(conn).get(name) + return list_all(conn, owner=owner).get(name) @api_request -def create(conn, name, *, description=None, repo_names=None, privacy=None, permission=None): +def create( + conn, name, *, description=None, repo_names=None, privacy=None, permission=None, owner=None +): """ Create a new team in the organization. @@ -81,6 +91,9 @@ def create(conn, name, *, description=None, repo_names=None, privacy=None, permi permission Default permission for new repositories added to the team, either ``pull``, ``push`` or ``admin``. + + owner + Organization owning the team. Defaults to the connection's owner. """ parameters = filter_unset( { @@ -89,18 +102,18 @@ def create(conn, name, *, description=None, repo_names=None, privacy=None, permi "privacy": privacy, } ) + org = conn.get_org(owner) if repo_names is not None: # create_team expects Repository objects, resolve the (full) names parameters["repo_names"] = [ - conn.client.get_repo(repo) if "/" in repo else conn.org.get_repo(repo) - for repo in repo_names + conn.client.get_repo(repo) if "/" in repo else org.get_repo(repo) for repo in repo_names ] - conn.org.create_team(name, **parameters) - list_all(conn, ignore_cache=True) # Refresh cache + org.create_team(name, **parameters) + list_all(conn, owner=owner, ignore_cache=True) # Refresh cache @api_request -def update(conn, team_info, name, *, description=None, privacy=None, permission=None): +def update(conn, team_info, name, *, description=None, privacy=None, permission=None, owner=None): """ Update an existing team. @@ -122,8 +135,11 @@ def update(conn, team_info, name, *, description=None, privacy=None, permission= permission Default permission for new repositories added to the team, either ``pull``, ``push`` or ``admin``. + + owner + Organization owning the team. Defaults to the connection's owner. """ - team = conn.org.get_team_by_slug(team_info["slug"]) + team = conn.get_org(owner).get_team_by_slug(team_info["slug"]) team.edit( name, **filter_unset( @@ -134,11 +150,11 @@ def update(conn, team_info, name, *, description=None, privacy=None, permission= } ), ) - list_all(conn, ignore_cache=True) # Refresh cache + list_all(conn, owner=owner, ignore_cache=True) # Refresh cache @api_request -def delete(conn, team_info): +def delete(conn, team_info, *, owner=None): """ Delete a team from the organization. @@ -147,14 +163,17 @@ def delete(conn, team_info): team_info Team's entry from :func:`list_all`. + + owner + Organization owning the team. Defaults to the connection's owner. """ - team = conn.org.get_team_by_slug(team_info["slug"]) + team = conn.get_org(owner).get_team_by_slug(team_info["slug"]) team.delete() - list_all(conn, ignore_cache=True) # Refresh cache + list_all(conn, owner=owner, ignore_cache=True) # Refresh cache @api_request -def list_repos(conn, team_info, ignore_cache=False): +def list_repos(conn, team_info, *, owner=None, ignore_cache=False): """ Return the repositories of a team as a mapping of (lowercase) repository name to repository details (cached). @@ -165,13 +184,16 @@ def list_repos(conn, team_info, ignore_cache=False): team_info Team's entry from :func:`list_all`. + owner + Organization owning the team. Defaults to the connection's owner. + ignore_cache Bypass the cached team repositories. """ if team_info.get("repos") and not ignore_cache: return team_info["repos"] - team = conn.org.get_team_by_slug(team_info["slug"]) + team = conn.get_org(owner).get_team_by_slug(team_info["slug"]) repos = {} for repo in team.get_repos(): permission = "pull" @@ -186,7 +208,7 @@ def list_repos(conn, team_info, ignore_cache=False): @api_request -def add_repo(conn, team_info, repo_name, permission=None): +def add_repo(conn, team_info, repo_name, permission=None, *, owner=None): """ Add a repository to a team, or update the permission the team has on it. Returns false if updating the permission failed. @@ -203,19 +225,23 @@ def add_repo(conn, team_info, repo_name, permission=None): permission Permission for team members within the repository. Either ``pull``, ``push`` or ``admin``. Defaults to the default permission on the team. + + owner + Organization owning the team. Defaults to the connection's owner. """ - team = conn.org.get_team_by_slug(team_info["slug"]) - repo = conn.org.get_repo(repo_name) + org = conn.get_org(owner) + team = org.get_team_by_slug(team_info["slug"]) + repo = org.get_repo(repo_name) if permission is None: team.add_to_repos(repo) elif not team.update_team_repository(repo, permission): return False - list_repos(conn, team_info, ignore_cache=True) # Refresh cache + list_repos(conn, team_info, owner=owner, ignore_cache=True) # Refresh cache return True @api_request -def remove_repo(conn, team_info, repo_name): +def remove_repo(conn, team_info, repo_name, *, owner=None): """ Remove a repository from a team. @@ -227,9 +253,13 @@ def remove_repo(conn, team_info, repo_name): repo_name Repository name to remove. + + owner + Organization owning the team. Defaults to the connection's owner. """ - team = conn.org.get_team_by_slug(team_info["slug"]) - repo = conn.org.get_repo(repo_name) + org = conn.get_org(owner) + team = org.get_team_by_slug(team_info["slug"]) + repo = org.get_repo(repo_name) team.remove_from_repos(repo) if team_info.get("repos"): team_info["repos"].pop(repo_name.lower(), None) @@ -237,7 +267,7 @@ def remove_repo(conn, team_info, repo_name): @api_request -def list_members(conn, team_info, ignore_cache=False): +def list_members(conn, team_info, *, owner=None, ignore_cache=False): """ Return the (lowercase) logins of a team's members (cached). @@ -247,19 +277,22 @@ def list_members(conn, team_info, ignore_cache=False): team_info Team's entry from :func:`list_all`. + owner + Organization owning the team. Defaults to the connection's owner. + ignore_cache Bypass the cached team members. """ if team_info.get("members") and not ignore_cache: return team_info["members"] - team = conn.org.get_team_by_slug(team_info["slug"]) + team = conn.get_org(owner).get_team_by_slug(team_info["slug"]) team_info["members"] = [member.login.lower() for member in team.get_members()] return team_info["members"] @api_request -def add_member(conn, team_info, login): +def add_member(conn, team_info, login, *, owner=None): """ Add an organization member to a team. @@ -271,14 +304,17 @@ def add_member(conn, team_info, login): login User handle to invite. + + owner + Organization owning the team. Defaults to the connection's owner. """ - team = conn.org.get_team_by_slug(team_info["slug"]) + team = conn.get_org(owner).get_team_by_slug(team_info["slug"]) member = conn.client.get_user(login) team.add_membership(member, "member") @api_request -def remove_member(conn, team_info, login): +def remove_member(conn, team_info, login, *, owner=None): """ Remove a member from a team. @@ -291,10 +327,13 @@ def remove_member(conn, team_info, login): login User handle to remove. + owner + Organization owning the team. Defaults to the connection's owner. + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` if the team or user does not exist. """ - team = conn.org.get_team_by_slug(team_info["slug"]) + team = conn.get_org(owner).get_team_by_slug(team_info["slug"]) member = conn.client.get_user(login) team.remove_membership(member) if team_info.get("members"): diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 62c1c4fa..5cd947cb 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -192,6 +192,15 @@ def fake_org(fake_state, fake_setup): # pylint: disable=unused-argument return fake_state.get_org("testorg") +@pytest.fixture(scope="module") +def fake_user(fake_state, fake_setup): # pylint: disable=unused-argument + """ + Fake user ``user1``, which the fake API treats as the token's + (authenticated) user. Ensure ``fake_setup`` creates it. + """ + return fake_state.get_user("user1") + + @pytest.fixture(autouse=True) def _restore_fake_state(fake_github): """ @@ -237,6 +246,18 @@ def fake_setup(fake_state): bug.add_comment(user1, "Thanks!", updated_at="2026-10-11T15:00:00Z") repo_a.add_issue("Stale issue", user2, state="closed") repo_a.add_pull("Fix the bug", user2, head="bugfix") + # User-owned repositories, incl. one shadowing an org repo's name + user1.add_repo("repo-a", description="User-owned repo-a") + user_repo = user1.add_repo("user-repo", private=True) + user_repo.add_label("user-bug", color="123456") + user_repo.add_milestone("u1.0", user1) + user_repo.add_issue("User issue", user2) + user_repo.add_pull("User PR", user1) + # A second organization for foreign-owner management + other_org = fake_state.add_org("otherorg") + other_org.add_repo("other-repo") + other_org.add_team("Team X") + other_org.add_member(user2) @pytest.fixture(scope="module", autouse=True) diff --git a/tests/functional/modules/github_org/test_owner.py b/tests/functional/modules/github_org/test_owner.py new file mode 100644 index 00000000..37888344 --- /dev/null +++ b/tests/functional/modules/github_org/test_owner.py @@ -0,0 +1,70 @@ +""" +Verify that all functions can manage another organization via the +``owner`` argument. +""" + +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_members_owner(github_org): + assert github_org.list_members(owner="otherorg") == ["user2"] + assert set(github_org.list_members()) == {"user1", "user2"} + + +def test_get_member_owner(github_org): + assert github_org.get_member("user2", owner="otherorg")["in_org"] is True + assert github_org.get_member("user1", owner="otherorg")["in_org"] is False + + +def test_add_member_owner(github_org, fake_state): + # Invitations result in a pending membership + assert github_org.add_member("newuser", owner="otherorg") is True + member = fake_state.get_org("otherorg").get_member("newuser") + assert member.state == "pending" + assert "newuser" not in fake_state.get_org("testorg").members + + +def test_remove_member_owner(github_org, fake_state): + assert github_org.remove_member("user2", owner="otherorg") is True + assert "user2" not in fake_state.get_org("otherorg").members + assert "user2" in fake_state.get_org("testorg").members + + +def test_org_rulesets_owner(github_org, fake_state): + other_org = fake_state.get_org("otherorg") + created = github_org.add_ruleset( + {"name": "other-rules", "enforcement": "active"}, owner="otherorg" + ) + assert "other-rules" in other_org.rulesets + assert github_org.list_rulesets(owner="otherorg", fields="name") == ["other-rules"] + assert github_org.list_rulesets(fields="name") == ["org-rules"] + assert github_org.get_ruleset(created["id"], owner="otherorg")["name"] == "other-rules" + github_org.update_ruleset(created["id"], {"enforcement": "disabled"}, owner="otherorg") + assert other_org.get_ruleset(created["id"]).enforcement == "disabled" + assert github_org.delete_ruleset(created["id"], owner="otherorg") is True + assert "other-rules" not in other_org.rulesets + + +def test_list_teams_owner(github_org): + assert set(github_org.list_teams(owner="otherorg")) == {"Team X"} + assert set(github_org.list_teams()) == {"Team A"} + + +def test_get_team_owner(github_org): + assert github_org.get_team("Team X", owner="otherorg")["slug"] == "team-x" + + +def test_add_team_repo_owner(github_org, fake_state): + ret = github_org.add_team_repo("other-repo", "Team X", permission="push", owner="otherorg") + assert ret is True + team = fake_state.get_org("otherorg").get_team("team-x") + assert team.repos[("otherorg", "other-repo")] == "push" + repos = github_org.list_team_repos("Team X", owner="otherorg") + assert repos["other-repo"]["permission"] == "push" + + +def test_team_owner_user_fails(github_org): + # Users cannot own teams + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.list_teams(owner="user1") diff --git a/tests/functional/modules/github_org/test_repos.py b/tests/functional/modules/github_org/test_repos.py deleted file mode 100644 index 3c2c3d33..00000000 --- a/tests/functional/modules/github_org/test_repos.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest -from salt.exceptions import CommandExecutionError - - -def test_list_repos(github_org): - ret = github_org.list_repos() - assert set(ret) == {"repo-a", "repo-b"} - # Full repository details are included - assert ret["repo-a"]["full_name"] == "testorg/repo-a" - - -def test_list_repos_fields(github_org): - ret = github_org.list_repos(fields=["private"]) - assert ret == {"repo-a": {"private": False}, "repo-b": {"private": True}} - # A single field name implodes the entries to its values - assert github_org.list_repos(fields="private") == {"repo-a": False, "repo-b": True} - - -def test_list_repos_unknown_field(github_org): - with pytest.raises(CommandExecutionError, match="Unknown field"): - github_org.list_repos(fields="bogus") - - -def test_list_repos_private_filter(github_org): - assert list(github_org.list_repos(private=True)) == ["repo-b"] - assert list(github_org.list_repos(private=False)) == ["repo-a"] - - -def test_list_repos_uses_cache(github_org, fake_org): - assert set(github_org.list_repos()) == {"repo-a", "repo-b"} - fake_org.add_repo("repo-c") - # Cached result is returned until the cache is bypassed - assert set(github_org.list_repos()) == {"repo-a", "repo-b"} - assert "repo-c" in github_org.list_repos(ignore_cache=True) - - -def test_add_repo(github_org, fake_org): - ret = github_org.add_repo("new-repo", description="Brand new") - assert ret["name"] == "new-repo" - assert ret["description"] == "Brand new" - assert fake_org.get_repo("new-repo").description == "Brand new" - - -def test_add_repo_refreshes_caches(github_org, modules): - assert set(github_org.list_repos()) == {"repo-a", "repo-b"} - github_org.add_repo("new-repo", description="Brand new") - # Both the repo list and single-repo caches know the new repo - assert "new-repo" in github_org.list_repos() - assert modules.github_repo.get_info(repo_name="new-repo")["description"] == "Brand new" - - -def test_add_repo_duplicate(github_org): - # The API refuses to create a repo with an existing name - with pytest.raises(CommandExecutionError): - github_org.add_repo("repo-a") diff --git a/tests/functional/modules/github_repo/test_owner.py b/tests/functional/modules/github_repo/test_owner.py new file mode 100644 index 00000000..9e9b55d7 --- /dev/null +++ b/tests/functional/modules/github_repo/test_owner.py @@ -0,0 +1,188 @@ +""" +Verify that the ``github_repo`` functions can manage repositories of +other owners (incl. user-owned ones) via the ``owner`` argument and +via ``owner``-keyed profiles. +""" + +import pytest +from salt.exceptions import CommandExecutionError + +from tests.support.github import DEPLOY_KEY_A + + +@pytest.fixture(scope="module") +def minion_config_defaults(github_profile): + # A profile whose owner is the (authenticated) user instead of an org + user_profile = { + "token": github_profile["token"], + "owner": "user1", + "api_url": github_profile["api_url"], + "repo_name": "user-repo", + } + noowner_profile = { + "token": github_profile["token"], + "api_url": github_profile["api_url"], + } + return { + "github": github_profile, + "github_user": user_profile, + "github_noowner": noowner_profile, + } + + +def test_get_info(github_repo): + ret = github_repo.get_info(repo_name="user-repo", owner="user1") + assert ret["full_name"] == "user1/user-repo" + assert ret["owner"] == "user1" + assert ret["private"] is True + + +def test_get_info_missing_owner(github_repo): + with pytest.raises(CommandExecutionError, match="could not be found"): + github_repo.get_info(repo_name="user-repo", owner="ghost-owner") + + +def test_get_info_cache_is_owner_scoped(github_repo): + # The org and user1 both own a repo named repo-a + assert github_repo.get_info(repo_name="repo-a")["full_name"] == "testorg/repo-a" + assert github_repo.get_info(repo_name="repo-a", owner="user1")["full_name"] == "user1/repo-a" + # Cached entries must not bleed between owners + assert github_repo.get_info(repo_name="repo-a")["full_name"] == "testorg/repo-a" + + +def test_owner_profile(github_repo): + # The profile's owner (preferred over org_name) scopes all calls + ret = github_repo.get_info(profile="github_user") + assert ret["full_name"] == "user1/user-repo" + + +def test_profile_owner_defaults_to_token_user(github_repo): + # Without owner/org_name, the owner is looked up via the token + ret = github_repo.get_info(repo_name="user-repo", profile="github_noowner") + assert ret["full_name"] == "user1/user-repo" + + +def test_list_auth_user(github_repo): + # user1 is the token's user, so private repos are included + ret = github_repo.list(owner="user1") + assert set(ret) == {"repo-a", "user-repo"} + assert ret["user-repo"]["private"] is True + + +def test_list_other_user_public_only(github_repo, fake_state): + user2 = fake_state.get_user("user2") + user2.add_repo("public-repo") + user2.add_repo("private-repo", private=True) + assert set(github_repo.list(owner="user2")) == {"public-repo"} + + +def test_list_missing_owner(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.list(owner="ghost-owner") + + +def test_create_auth_user(github_repo, fake_user): + # Prime the owner-scoped repo list cache to verify it is maintained + assert "new-user-repo" not in github_repo.list(owner="user1") + ret = github_repo.create("new-user-repo", description="Mine", owner="user1") + assert ret["full_name"] == "user1/new-user-repo" + assert fake_user.get_repo("new-user-repo").description == "Mine" + assert "new-user-repo" in github_repo.list(owner="user1") + + +def test_create_foreign_user_fails(github_repo): + with pytest.raises(CommandExecutionError, match="only supports creating"): + github_repo.create("nope", owner="user2") + + +def test_delete_refreshes_repo_list_cache(github_repo): + assert "user-repo" in github_repo.list(owner="user1") + github_repo.delete(repo_name="user-repo", owner="user1") + # The cached (not refetched) listing must not contain the repo anymore + assert "user-repo" not in github_repo.list(owner="user1") + + +def test_get_teams_user_owned(github_repo): + # Teams are org entities, user repos always yield an empty mapping + assert github_repo.get_teams(repo_name="user-repo", owner="user1") == {} + + +def test_update(github_repo, fake_user): + ret = github_repo.update(repo_name="user-repo", owner="user1", description="Updated") + assert ret["description"] == "Updated" + assert fake_user.get_repo("user-repo").description == "Updated" + assert github_repo.get_info(repo_name="user-repo", owner="user1")["description"] == "Updated" + + +def test_delete(github_repo, fake_user): + assert github_repo.delete(repo_name="user-repo", owner="user1") is True + assert "user-repo" not in fake_user.repos + + +def test_list_issues(github_repo): + ret = github_repo.list_issues(repo_name="user-repo", owner="user1", fields="title") + assert ret == ["User issue"] + + +def test_list_pulls(github_repo): + ret = github_repo.list_pulls(repo_name="user-repo", owner="user1", fields="title") + assert ret == ["User PR"] + + +def test_get_milestone(github_repo): + # Exercises both the direct and the list-based lookup + assert github_repo.get_milestone(1, repo_name="user-repo", owner="user1")["title"] == "u1.0" + assert ( + github_repo.get_milestone(name="u1.0", repo_name="user-repo", owner="user1")["number"] == 1 + ) + + +def test_labels(github_repo, fake_user): + github_repo.add_label("release", color="00ff00", repo_name="user-repo", owner="user1") + assert fake_user.get_repo("user-repo").get_label("release").color == "00ff00" + labels = github_repo.list_labels(repo_name="user-repo", owner="user1") + assert set(labels) == {"user-bug", "release"} + + +def test_deploy_keys(github_repo, fake_user): + github_repo.add_deploy_key("autorelease", DEPLOY_KEY_A, repo_name="user-repo", owner="user1") + titles = [key.title for key in fake_user.get_repo("user-repo").deploy_keys] + assert titles == ["autorelease"] + keys = github_repo.list_deploy_keys(repo_name="user-repo", owner="user1", fields="title") + assert keys == ["autorelease"] + + +def test_actions_secrets(github_repo, fake_user): + # Also covers the owner-scoped public key fetch for the sealed box + github_repo.set_actions_secret("MY_SECRET", "hunter2", repo_name="user-repo", owner="user1") + assert fake_user.get_repo("user-repo").get_secret("MY_SECRET").value == "hunter2" + secrets = github_repo.list_actions_secrets(repo_name="user-repo", owner="user1") + assert list(secrets) == ["MY_SECRET"] + + +def test_workflow_permissions(github_repo): + github_repo.set_workflow_permissions("read", repo_name="user-repo", owner="user1") + ret = github_repo.get_workflow_permissions(repo_name="user-repo", owner="user1") + assert ret["default_workflow_permissions"] == "read" + + +def test_environments(github_repo, fake_user): + github_repo.set_environment("release", wait_timer=5, repo_name="user-repo", owner="user1") + assert fake_user.get_repo("user-repo").get_environment("release").wait_timer == 5 + envs = github_repo.list_environments(repo_name="user-repo", owner="user1") + assert list(envs) == ["release"] + + +def test_pages(github_repo, fake_user): + github_repo.setup_pages(build_type="workflow", repo_name="user-repo", owner="user1") + assert fake_user.get_repo("user-repo").pages.build_type == "workflow" + assert github_repo.get_pages(repo_name="user-repo", owner="user1")["build_type"] == "workflow" + + +def test_rulesets(github_repo, fake_user): + github_repo.add_ruleset( + {"name": "user-rules", "enforcement": "active"}, repo_name="user-repo", owner="user1" + ) + assert "user-rules" in fake_user.get_repo("user-repo").rulesets + rulesets = github_repo.list_rulesets(repo_name="user-repo", owner="user1", fields="name") + assert rulesets == ["user-rules"] diff --git a/tests/functional/modules/github_repo/test_repo.py b/tests/functional/modules/github_repo/test_repo.py index 7859a57a..53fa7776 100644 --- a/tests/functional/modules/github_repo/test_repo.py +++ b/tests/functional/modules/github_repo/test_repo.py @@ -68,3 +68,56 @@ def test_delete(github_repo, fake_org): def test_delete_missing(github_repo): with pytest.raises(CommandExecutionError, match="Not Found"): github_repo.delete(repo_name="ghost") + + +def test_list(github_repo): + ret = github_repo.list() + assert set(ret) == {"repo-a", "repo-b"} + # Full repository details are included + assert ret["repo-a"]["full_name"] == "testorg/repo-a" + + +def test_list_fields(github_repo): + ret = github_repo.list(fields=["private"]) + assert ret == {"repo-a": {"private": False}, "repo-b": {"private": True}} + # A single field name implodes the entries to its values + assert github_repo.list(fields="private") == {"repo-a": False, "repo-b": True} + + +def test_list_unknown_field(github_repo): + with pytest.raises(CommandExecutionError, match="Unknown field"): + github_repo.list(fields="bogus") + + +def test_list_private_filter(github_repo): + assert list(github_repo.list(private=True)) == ["repo-b"] + assert list(github_repo.list(private=False)) == ["repo-a"] + + +def test_list_uses_cache(github_repo, fake_org): + assert set(github_repo.list()) == {"repo-a", "repo-b"} + fake_org.add_repo("repo-c") + # Cached result is returned until the cache is bypassed + assert set(github_repo.list()) == {"repo-a", "repo-b"} + assert "repo-c" in github_repo.list(ignore_cache=True) + + +def test_create(github_repo, fake_org): + ret = github_repo.create("new-repo", description="Brand new") + assert ret["name"] == "new-repo" + assert ret["description"] == "Brand new" + assert fake_org.get_repo("new-repo").description == "Brand new" + + +def test_create_refreshes_caches(github_repo): + assert set(github_repo.list()) == {"repo-a", "repo-b"} + github_repo.create("new-repo", description="Brand new") + # Both the repo list and single-repo caches know the new repo + assert "new-repo" in github_repo.list() + assert github_repo.get_info(repo_name="new-repo")["description"] == "Brand new" + + +def test_create_duplicate(github_repo): + # The API refuses to create a repo with an existing name + with pytest.raises(CommandExecutionError): + github_repo.create("repo-a") diff --git a/tests/functional/states/github_repo/test_owner.py b/tests/functional/states/github_repo/test_owner.py new file mode 100644 index 00000000..fe50afa6 --- /dev/null +++ b/tests/functional/states/github_repo/test_owner.py @@ -0,0 +1,91 @@ +""" +Verify that the ``github_repo`` states can manage repositories of other +owners (incl. user-owned ones) via the ``owner`` argument. +""" + + +def test_present_update_user_owned(github_repo, fake_github, fake_user, testmode): + before = len(fake_github.mutations()) + ret = github_repo.present("user-repo", owner="user1", description="Managed", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == { + "old": {"description": "Test repo user-repo"}, + "new": {"description": "Managed"}, + } + assert (len(fake_github.mutations()) == before) is testmode + expected = "Test repo user-repo" if testmode else "Managed" + assert fake_user.get_repo("user-repo").description == expected + + +def test_present_create_auth_user(github_repo, fake_github, fake_user, testmode): + before = len(fake_github.mutations()) + ret = github_repo.present("brand-new", owner="user1", description="Mine", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"created": "brand-new"} + assert (len(fake_github.mutations()) == before) is testmode + assert ("brand-new" in fake_user.repos) is not testmode + + +def test_present_create_foreign_user_fails(github_repo, fake_state): + # Repos can only be created for orgs and the authenticated user. + # This is only detected on application since it requires owner + # resolution, which the projection does not perform. + ret = github_repo.present("brand-new", owner="user2") + assert ret.result is False + assert "only supports creating" in ret.comment + assert not ret.changes + assert "brand-new" not in fake_state.get_user("user2").repos + + +def test_present_teams_foreign_org(github_repo, fake_state, testmode): + # Teams of the repo's owning org can be managed on its repos + ret = github_repo.present( + "other-repo", owner="otherorg", teams={"Team X": "push"}, test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"teams_added": ["Team X"]} + team = fake_state.get_org("otherorg").get_team("team-x") + assert (("otherorg", "other-repo") in team.repos) is not testmode + + +def test_present_teams_user_owner_fails(github_repo): + # Users do not own teams, so the team resolution in the owning + # "org" fails. Like for nonexistent teams, this only surfaces on + # application since the diff does not resolve missing teams. + ret = github_repo.present("user-repo", owner="user1", teams={"Team A": "push"}) + assert ret.result is False + assert "Not Found" in ret.comment + assert not ret.changes + + +def test_present_explicit_profile_owner_manages_teams(github_repo, fake_org, testmode): + # An explicit owner equal to the profile's owner keeps teams usable + ret = github_repo.present("repo-b", owner="testorg", teams={"Team A": "push"}, test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"teams_added": ["Team A"]} + team = fake_org.get_team("team-a") + assert (("testorg", "repo-b") in team.repos) is not testmode + + +def test_absent_user_owned(github_repo, fake_github, fake_user, testmode): + before = len(fake_github.mutations()) + ret = github_repo.absent("user-repo", owner="user1", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"deleted": "user-repo"} + assert (len(fake_github.mutations()) == before) is testmode + assert ("user-repo" in fake_user.repos) is testmode + + +def test_label_present_user_owned(github_repo, fake_user, testmode): + ret = github_repo.label_present( + name="release", color="00ff00", repo_name="user-repo", owner="user1", test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"created": "release"} + assert ("release" in fake_user.get_repo("user-repo").labels) is not testmode diff --git a/tests/support/github/base.py b/tests/support/github/base.py index 2cb718de..7f447e47 100644 --- a/tests/support/github/base.py +++ b/tests/support/github/base.py @@ -42,8 +42,12 @@ class APIBase: via the MRO on instantiation. """ - def __init__(self, token: str = TOKEN, state: GlobalState | None = None): + def __init__( + self, token: str = TOKEN, state: GlobalState | None = None, auth_login: str = "user1" + ): self.token = token + # The user the token belongs to (GET /user, /user/repos) + self.auth_login = auth_login self.base_url = None self.lock = threading.Lock() self.requests = [] @@ -90,10 +94,10 @@ def _resolve_repo(self, match: re.Match | dict[str, str]) -> Repo: groups = match.groupdict() else: groups = match - if "repo" not in groups: - raise TypeError(f"no repo specified: {groups}") # pragma: no cover - org = self._resolve_org(groups) - return org.get_repo(groups["repo"]) + owner = groups.get("owner") or groups.get("org") + if owner is None or "repo" not in groups: + raise TypeError(f"no owner/repo specified: {groups}") # pragma: no cover + return self.state.get_repo((owner, groups["repo"])) # ------------------------------------------------------------------ # Dispatch diff --git a/tests/support/github/entities.py b/tests/support/github/entities.py index 1a73bdb6..4f551d51 100644 --- a/tests/support/github/entities.py +++ b/tests/support/github/entities.py @@ -195,6 +195,7 @@ class OrganizationData(EntityData): _: KW_ONLY login: OrgName = "" company: str = "Example Corp" + type: str = "Organization" def __post_init__(self): if not self.login: @@ -268,15 +269,7 @@ def get_repo(self, name: RepoName, *, exc=NotFound) -> "Repo": return self.gh.get_repo(RepoId(self.data.login, name), exc=exc) def remove_repo(self, name: RepoName, *, exc=NotFound) -> None: - repo_id = RepoId(self.data.login, name) - if repo_id not in self.gh.repos: - raise exc(f"Repo {repo_id=} does not exist") - self.gh.repos.pop(repo_id) - for team in self.teams: - team.data.repos.pop(repo_id, None) - for store in (self.gh.deploy_keys, self.gh.secrets, self.gh.environments, self.gh.labels): - store.pop(repo_id, None) - self.gh.pages.pop(repo_id, None) + self.gh.remove_repo(RepoId(self.data.login, name), exc=exc) def add_member(self, user: "User | UserData | UserName", *, exc=NotFound, **params) -> "Member": login = user if isinstance(user, UserName) else user.login @@ -378,6 +371,29 @@ def payload(self, /, **ovrr): | ovrr ) + @property + def repos(self) -> "EntityView[RepoName, Repo]": + login = self.data.login + return EntityView( + { + repo_id.name: Repo(self.gh, repo) + for repo_id, repo in self.gh.repos.items() + if repo_id.owner == login + } + ) + + def add_repo(self, name: RepoName, **params) -> "Repo": + repo_id = RepoId(self.data.login, name) + assert repo_id not in self.gh.repos + repo = RepoData(name, self.data.login, owner_type="user", **params) + return self.gh.add(repo) + + def get_repo(self, name: RepoName, *, exc=NotFound) -> "Repo": + return self.gh.get_repo(RepoId(self.data.login, name), exc=exc) + + def remove_repo(self, name: RepoName, *, exc=NotFound) -> None: + self.gh.remove_repo(RepoId(self.data.login, name), exc=exc) + @dataclass class MemberData(EntityData): @@ -477,6 +493,8 @@ class RepoData(EntityData): def __post_init__(self): super().__post_init__() + # Names are only unique per owner, include the owner in the ID. + self.id = zlib.crc32(f"{type(self).__name__}/{self.full_name}".encode()) if self.description is None: self.description = f"Test repo {self.name}" @@ -518,9 +536,6 @@ def rulesets(self) -> "EntityView[str, Ruleset]": ) def add_ruleset(self, name: str, **params) -> "Ruleset": - owner = self.owner - if not isinstance(owner, Organization): - raise NotImplementedError ruleset = RepoRulesetData(name, self.data.repo_id, **params) return self.gh.add(ruleset) @@ -539,9 +554,6 @@ def remove_ruleset(self, rs_id: int | str, *, exc=NotFound): def add_milestone( self, title: str, creator: User | UserData | UserName, **params ) -> "Milestone": - owner = self.owner - if not isinstance(owner, Organization): - raise NotImplementedError milestone = MilestoneData( title, self.data.repo_id, diff --git a/tests/support/github/routes/actions.py b/tests/support/github/routes/actions.py index 1763132e..502c6675 100644 --- a/tests/support/github/routes/actions.py +++ b/tests/support/github/routes/actions.py @@ -18,13 +18,13 @@ class ActionsRoutes(APIBase): - @GET("repos///actions/secrets/public-key") + @GET("repos///actions/secrets/public-key") def _secrets_public_key(self, match, _query, _body): self._resolve_repo(match) public_key = base64.b64encode(bytes(self.secrets_private_key.public_key)).decode() return 200, {"key_id": "568250167242549743", "key": public_key} - @GET("repos///actions/secrets") + @GET("repos///actions/secrets") def _list_secrets(self, match, _query, _body): repo = self._resolve_repo(match) secrets = [secret.payload() for secret in sorted(repo.secrets, key=lambda x: x.name)] @@ -59,7 +59,7 @@ def _delete_secret(self, match, _query, _body): repo.remove_secret(match["name"]) return 204 - @GET("repos///actions/permissions/workflow") + @GET("repos///actions/permissions/workflow") def _get_workflow_permissions(self, match, _query, _body): repo = self._resolve_repo(match) return 200, { @@ -67,7 +67,7 @@ def _get_workflow_permissions(self, match, _query, _body): "can_approve_pull_request_reviews": repo.data.can_approve_pull_request_reviews, } - @PUT("repos///actions/permissions/workflow") + @PUT("repos///actions/permissions/workflow") def _set_workflow_permissions(self, match, _query, body): repo = self._resolve_repo(match) upd = { diff --git a/tests/support/github/routes/deploy_keys.py b/tests/support/github/routes/deploy_keys.py index 5768b67b..d6f1d10b 100644 --- a/tests/support/github/routes/deploy_keys.py +++ b/tests/support/github/routes/deploy_keys.py @@ -9,23 +9,23 @@ class DeployKeyRoutes(APIBase): - @GET("repos///keys") + @GET("repos///keys") def _list_deploy_keys(self, match, _query, _body): repo = self._resolve_repo(match) return 200, [key.payload() for key in sorted(repo.deploy_keys, key=lambda x: x.id)] - @POST("repos///keys") + @POST("repos///keys") def _create_deploy_key(self, match, _query, body): repo = self._resolve_repo(match) key = repo.add_deploy_key(body["title"], body["key"], read_only=bool(body.get("read_only"))) return 201, key.payload() - @GET("repos///keys/") + @GET("repos///keys/") def _get_deploy_key(self, match, _query, _body): repo = self._resolve_repo(match) return 200, repo.get_deploy_key(match["key_id"]).payload() - @DELETE("repos///keys/") + @DELETE("repos///keys/") def _delete_deploy_key(self, match, _query, _body): repo = self._resolve_repo(match) repo.remove_deploy_key(match["key_id"]) diff --git a/tests/support/github/routes/environments.py b/tests/support/github/routes/environments.py index 51f93fa1..7b390686 100644 --- a/tests/support/github/routes/environments.py +++ b/tests/support/github/routes/environments.py @@ -10,18 +10,18 @@ class EnvironmentRoutes(APIBase): - @GET("repos///environments") + @GET("repos///environments") def _list_environments(self, match, _query, _body): repo = self._resolve_repo(match) environments = [env.payload() for env in sorted(repo.environments, key=lambda x: x.name)] return 200, {"total_count": len(environments), "environments": environments} - @GET("repos///environments/") + @GET("repos///environments/") def _get_environment(self, match, _query, _body): repo = self._resolve_repo(match) return 200, repo.get_environment(match["environment"]).payload() - @PUT("repos///environments/") + @PUT("repos///environments/") def _put_environment(self, match, _query, body): repo = self._resolve_repo(match) # Like the real API, a PUT replaces the full configuration, @@ -40,27 +40,29 @@ def _put_environment(self, match, _query, body): env = env.update(**settings) return 200, env.payload() - @DELETE("repos///environments/") + @DELETE("repos///environments/") def _delete_environment(self, match, _query, _body): repo = self._resolve_repo(match) repo.remove_environment(match["environment"]) return 204 - @GET("repos///environments//deployment-branch-policies") + @GET("repos///environments//deployment-branch-policies") def _list_branch_policies(self, match, _query, _body): repo = self._resolve_repo(match) env = repo.get_environment(match["environment"]) policies = [policy.payload() for policy in sorted(env.branch_policies, key=lambda x: x.id)] return 200, {"total_count": len(policies), "branch_policies": policies} - @POST("repos///environments//deployment-branch-policies") + @POST("repos///environments//deployment-branch-policies") def _create_branch_policy(self, match, _query, body): repo = self._resolve_repo(match) env = repo.get_environment(match["environment"]) policy = env.add_branch_policy(body["name"], type=body.get("type", "branch")) return 200, policy.payload() - @DELETE("repos///environments//deployment-branch-policies/") + @DELETE( + "repos///environments//deployment-branch-policies/" + ) def _delete_branch_policy(self, match, _query, _body): repo = self._resolve_repo(match) env = repo.get_environment(match["environment"]) diff --git a/tests/support/github/routes/issues.py b/tests/support/github/routes/issues.py index 37b1d3a4..8e7dc2c6 100644 --- a/tests/support/github/routes/issues.py +++ b/tests/support/github/routes/issues.py @@ -12,7 +12,7 @@ def _state_matches(entity_state, query): class IssueRoutes(APIBase): - @GET(r"/repos///issues") + @GET(r"/repos///issues") def _list_issues(self, match, query, _body): repo = self._resolve_repo(match) wanted_labels = set(filter(None, query.get("labels", "").split(","))) @@ -30,12 +30,12 @@ def _list_issues(self, match, query, _body): rend.append(pull.issue_payload()) return 200, rend - @GET(r"/repos///issues/") + @GET(r"/repos///issues/") def _get_issue(self, match, _query, _body): repo = self._resolve_repo(match) return 200, repo.get_issue(match["number"]).payload() - @GET(r"/repos///issues//comments") + @GET(r"/repos///issues//comments") def _list_issue_comments(self, match, query, _body): repo = self._resolve_repo(match) issue = repo.get_issue(match["number"]) @@ -46,7 +46,7 @@ def _list_issue_comments(self, match, query, _body): if since is None or comment.updated_at >= since ] - @GET(r"/repos///pulls") + @GET(r"/repos///pulls") def _list_pulls(self, match, query, _body): repo = self._resolve_repo(match) return 200, [ diff --git a/tests/support/github/routes/labels.py b/tests/support/github/routes/labels.py index dd8f8448..db6a2ddf 100644 --- a/tests/support/github/routes/labels.py +++ b/tests/support/github/routes/labels.py @@ -10,12 +10,12 @@ class LabelRoutes(APIBase): - @GET("repos///labels") + @GET("repos///labels") def _list_labels(self, match, _query, _body): repo = self._resolve_repo(match) return 200, [label.payload() for label in sorted(repo.labels, key=lambda x: x.name)] - @POST("repos///labels") + @POST("repos///labels") def _create_label(self, match, _query, body): repo = self._resolve_repo(match) assert body["name"] not in repo.labels @@ -23,12 +23,12 @@ def _create_label(self, match, _query, body): label = repo.add_label(body["name"], **params) return 201, label.payload() - @GET("repos///labels/") + @GET("repos///labels/") def _get_label(self, match, _query, _body): repo = self._resolve_repo(match) return 200, repo.get_label(match["name"]).payload() - @PATCH("repos///labels/") + @PATCH("repos///labels/") def _edit_label(self, match, _query, body): repo = self._resolve_repo(match) label = repo.get_label(match["name"]) @@ -37,7 +37,7 @@ def _edit_label(self, match, _query, body): upd["name"] = body["new_name"] return 200, label.update(**upd).payload() - @DELETE("repos///labels/") + @DELETE("repos///labels/") def _delete_label(self, match, _query, _body): repo = self._resolve_repo(match) repo.remove_label(match["name"]) diff --git a/tests/support/github/routes/milestones.py b/tests/support/github/routes/milestones.py index 8da4bc5f..ae099d77 100644 --- a/tests/support/github/routes/milestones.py +++ b/tests/support/github/routes/milestones.py @@ -7,12 +7,12 @@ class MilestoneRoutes(APIBase): - @GET("repos///milestones") + @GET("repos///milestones") def _list_milestones(self, match, _query, _body): repo = self._resolve_repo(match) return 200, [milestone.payload() for milestone in repo.milestones] - @GET("repos///milestones/") + @GET("repos///milestones/") def _get_milestone(self, match, _query, _body): repo = self._resolve_repo(match) milestone = repo.get_milestone(match["number"]) diff --git a/tests/support/github/routes/orgs.py b/tests/support/github/routes/orgs.py index 9c65e4f6..9938c8d6 100644 --- a/tests/support/github/routes/orgs.py +++ b/tests/support/github/routes/orgs.py @@ -17,7 +17,12 @@ def _get_org(self, match, _query, _body): @GET("users/") def _get_user(self, match, _query, _body): - return 200, self.state.get_user(match["login"]).payload() + # Like the real endpoint, this also resolves organizations + # (distinguished via the payload's "type" field). + try: + return 200, self.state.get_user(match["login"]).payload() + except NotFound: + return 200, self.state.get_org(match["login"]).payload() @GET("orgs//members") def _list_members(self, match, query, _body): diff --git a/tests/support/github/routes/pages.py b/tests/support/github/routes/pages.py index c298ecd4..70d09fce 100644 --- a/tests/support/github/routes/pages.py +++ b/tests/support/github/routes/pages.py @@ -10,14 +10,14 @@ class PagesRoutes(APIBase): - @GET("repos///pages") + @GET("repos///pages") def _get_pages(self, match, _query, _body): repo = self._resolve_repo(match) if repo.pages is None: return 404, {"message": "Not Found"} return 200, repo.pages.payload() - @POST("repos///pages") + @POST("repos///pages") def _create_pages(self, match, _query, body): repo = self._resolve_repo(match) assert repo.pages is None @@ -26,7 +26,7 @@ def _create_pages(self, match, _query, body): pages = repo.set_pages(**params) return 201, pages.payload() - @PUT("repos///pages") + @PUT("repos///pages") def _update_pages(self, match, _query, body): repo = self._resolve_repo(match) pages = repo.pages @@ -40,7 +40,7 @@ def _update_pages(self, match, _query, body): pages.update(**upd) return 204 - @DELETE("repos///pages") + @DELETE("repos///pages") def _delete_pages(self, match, _query, _body): repo = self._resolve_repo(match) repo.remove_pages() diff --git a/tests/support/github/routes/repos.py b/tests/support/github/routes/repos.py index 5df2b9b3..29c11f5e 100644 --- a/tests/support/github/routes/repos.py +++ b/tests/support/github/routes/repos.py @@ -11,6 +11,14 @@ class RepoRoutes(APIBase): + @staticmethod + def _creation_params(body): + repo_params = {"private": bool(body.get("private"))} + for fld in ("description", "homepage", "has_issues", "has_wiki", "has_downloads"): + if fld in body: + repo_params[fld] = body[fld] + return repo_params + @GET("orgs//repos") def _list_repos(self, match, _query, _body): org = self._resolve_org(match) @@ -19,20 +27,37 @@ def _list_repos(self, match, _query, _body): @POST("orgs//repos") def _create_repo(self, match, _query, body): org = self._resolve_org(match) - name = body["name"] - repo_params = {"private": bool(body.get("private"))} - for fld in ("description", "homepage", "has_issues", "has_wiki", "has_downloads"): - if fld in body: - repo_params[fld] = body[fld] - repo = org.add_repo(name, **repo_params) + repo = org.add_repo(body["name"], **self._creation_params(body)) return 201, repo.payload() - @GET("repos//") + @GET("users//repos") + def _list_user_repos(self, match, _query, _body): + user = self.state.get_user(match["login"]) + # Like the real API, this endpoint only lists public repositories. + return 200, [ + repo.payload() for repo in sorted(user.repos, key=lambda x: x.name) if not repo.private + ] + + @GET("user") + def _get_auth_user(self, _match, _query, _body): + return 200, self.state.get_user(self.auth_login).payload() + + @GET("user/repos") + def _list_auth_user_repos(self, _match, _query, _body): + user = self.state.get_user(self.auth_login) + return 200, [repo.payload() for repo in sorted(user.repos, key=lambda x: x.name)] + + @POST("user/repos") + def _create_auth_user_repo(self, _match, _query, body): + user = self.state.get_user(self.auth_login) + repo = user.add_repo(body["name"], **self._creation_params(body)) + return 201, repo.payload() + + @GET("repos//") def _get_repo(self, match, _query, _body): - org = self._resolve_org(match) - return 200, org.get_repo(match["repo"]).payload() + return 200, self._resolve_repo(match).payload() - @PATCH("repos//") + @PATCH("repos//") def _edit_repo(self, match, _query, body): repo = self._resolve_repo(match) upd = {} @@ -41,20 +66,21 @@ def _edit_repo(self, match, _query, body): upd[fld] = value return 200, repo.update(**upd).payload() - @DELETE("repos//") + @DELETE("repos//") def _delete_repo(self, match, _query, _body): - org = self._resolve_org(match) - org.remove_repo(match["repo"]) + repo = self._resolve_repo(match) + self.state.remove_repo(repo.data.repo_id) return 204 - @GET("repos///teams") + @GET("repos///teams") def _repo_teams(self, match, _query, _body): repo = self._resolve_repo(match) owner = repo.owner if not isinstance(owner, Organization): - raise NotImplementedError + # Teams are organization entities, user-owned repos have none. + return 200, [] return 200, [ team.payload() - for team in sorted(repo.owner.teams, key=lambda x: x.id) + for team in sorted(owner.teams, key=lambda x: x.id) if repo.data.repo_id in team.data.repos ] diff --git a/tests/support/github/routes/rulesets.py b/tests/support/github/routes/rulesets.py index bc341658..226ee3b0 100644 --- a/tests/support/github/routes/rulesets.py +++ b/tests/support/github/routes/rulesets.py @@ -18,7 +18,7 @@ def _get_ruleset(self, match) -> Ruleset: org = self._resolve_org(match) return org.get_ruleset(match["ruleset_id"]) - @GET("repos///rulesets") + @GET("repos///rulesets") def _list_repo_rulesets(self, match, _query, _body): repo = self._resolve_repo(match) rulesets = [ruleset.payload() for ruleset in repo.rulesets] @@ -30,7 +30,7 @@ def _list_org_rulesets(self, match, _query, _body): rulesets = [ruleset.payload() for ruleset in org.rulesets] return 200, rulesets - @POST("repos///rulesets") + @POST("repos///rulesets") def _add_repo_ruleset(self, match, _query, body): repo = self._resolve_repo(match) ruleset = repo.add_ruleset(**dict(body)) @@ -42,7 +42,7 @@ def _add_org_ruleset(self, match, _query, body): ruleset = org.add_ruleset(**dict(body)) return 201, ruleset.payload() - @GET("repos///rulesets/") + @GET("repos///rulesets/") def _get_repo_ruleset(self, match, _query, _body): ruleset = self._get_ruleset(match) return 200, ruleset.payload() @@ -52,7 +52,7 @@ def _get_org_ruleset(self, match, _query, _body): ruleset = self._get_ruleset(match) return 200, ruleset.payload() - @PUT("repos///rulesets/") + @PUT("repos///rulesets/") def _update_repo_ruleset(self, match, _query, body): ruleset = self._get_ruleset(match) return 200, ruleset.update(**body).payload() @@ -62,7 +62,7 @@ def _update_org_ruleset(self, match, _query, body): ruleset = self._get_ruleset(match) return 200, ruleset.update(**body).payload() - @DELETE("repos///rulesets/") + @DELETE("repos///rulesets/") def _delete_repo_ruleset(self, match, _query, _body): repo = self._resolve_repo(match) repo.remove_ruleset(match["ruleset_id"]) diff --git a/tests/support/github/state.py b/tests/support/github/state.py index 34e1d01a..b024f6ab 100644 --- a/tests/support/github/state.py +++ b/tests/support/github/state.py @@ -142,14 +142,32 @@ def get_repo(self, repo_id: RepoId | Sequence[str] | str, *, exc=NotFound) -> Re else: resolved = RepoId.from_seq(repo_id) - if resolved.owner not in self.orgs: - if resolved.owner in self.users: - raise NotImplementedError - raise exc(f"Requested repo from nonexistent org {resolved.owner=}") + if resolved.owner not in self.orgs and resolved.owner not in self.users: + raise exc(f"Requested repo from nonexistent owner {resolved.owner=}") if resolved not in self.repos: - raise exc(f"Requested nonexistent repo {resolved.name=} from org {resolved.owner=}") + raise exc(f"Requested nonexistent repo {resolved.name=} from owner {resolved.owner=}") return Repo(self, self.repos[resolved]) + def remove_repo(self, repo_id: RepoId | Sequence[str] | str, *, exc=NotFound) -> None: + repo = self.get_repo(repo_id, exc=exc) + resolved = repo.data.repo_id + self.repos.pop(resolved) + if repo.data.owner_type == "org": + for team in self.get_org(resolved.owner).teams: + team.data.repos.pop(resolved, None) + for store in ( + self.rulesets_repo, + self.milestones, + self.issues, + self.pulls, + self.deploy_keys, + self.secrets, + self.environments, + self.labels, + ): + store.pop(resolved, None) + self.pages.pop(resolved, None) + def add(self, entity: EntityData | Entity): data = entity.data if isinstance(entity, Entity) else entity diff --git a/tests/unit/utils/test_client.py b/tests/unit/utils/test_client.py index 91019c0f..b6109846 100644 --- a/tests/unit/utils/test_client.py +++ b/tests/unit/utils/test_client.py @@ -1,4 +1,5 @@ from unittest.mock import MagicMock +from unittest.mock import patch import github as pygithub import pytest @@ -84,3 +85,34 @@ def _server_error(): with pytest.raises(GitHubApiError, match="502 bad gateway") as exc: _server_error() assert exc.value.status == 502 + + +@pytest.fixture +def token_conn(): + with patch("saltext.github.utils.gh.client.github.Github", autospec=True): + yield gh_client.GitHubConnection("token") + + +def test_owner_defaults_to_token_user(token_conn): + token_conn.client.get_user.return_value.login = "tokenuser" + assert token_conn.owner == "tokenuser" + token_conn.client.get_user.assert_called_once_with() + # The resolution result is cached + assert token_conn.owner == "tokenuser" + token_conn.client.get_user.assert_called_once_with() + + +def test_owner_lookup_failure_hints_at_profile_parameter(token_conn): + # GitHub App installation tokens are not tied to a user account + token_conn.client.get_user.side_effect = pygithub.GithubException( + 403, {"message": "Resource not accessible by integration"}, None + ) + with pytest.raises(CommandExecutionError, match="require the 'owner' profile parameter"): + token_conn.owner # pylint: disable=pointless-statement + + +def test_explicit_owner_skips_token_lookup(): + with patch("saltext.github.utils.gh.client.github.Github", autospec=True): + explicit = gh_client.GitHubConnection("token", "testorg") + assert explicit.owner == "testorg" + explicit.client.get_user.assert_not_called() From 1f1e5cce7e0905e5e105d080900fa1aca9a817f9 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 13 Oct 2026 03:01:59 +0200 Subject: [PATCH 6/6] Add/adjust docs --- changelog/+actions_secrets.added.md | 2 +- changelog/+add_team_profile.fixed.md | 2 +- changelog/+api_url.added.md | 2 +- changelog/+default_branch.added.md | 2 +- changelog/+deploy_keys.added.md | 2 +- changelog/+environments.added.md | 2 +- changelog/+get_user_pubkeys.removed.md | 2 +- .../+is_team_member_missing_team.fixed.md | 2 +- changelog/+labels.added.md | 2 +- changelog/+legacy_modules.deprecated.md | 2 +- changelog/+list_funcs_error_crash.fixed.md | 2 +- changelog/+milestone_pops.fixed.md | 2 +- changelog/+pages.added.md | 2 +- changelog/+remove_repo_missing.fixed.md | 2 +- changelog/+repo_present_profile.fixed.md | 2 +- .../+repo_present_team_add_comment.fixed.md | 2 +- .../+ruleset_absent_delete_failure.fixed.md | 2 +- changelog/+ruleset_states.fixed.md | 2 +- changelog/+scoped_modules.added.md | 2 +- changelog/+single_http_stack.changed.md | 2 +- changelog/+structured_changes.added.md | 2 +- changelog/+team_present_members.fixed.md | 2 +- .../+testmode_projected_changes.fixed.md | 2 +- changelog/+user_owned_repos.added.md | 2 +- changelog/+utils-refactor.changed.md | 2 +- changelog/+workflow_permissions.added.md | 2 +- docs/_ext/ghdomain.py | 12 + docs/conf.py | 1 + docs/index.rst | 15 +- docs/topics/configuration.md | 80 +++++++ src/saltext/github/modules/github.py | 79 ++----- src/saltext/github/modules/github_org.py | 55 ++--- src/saltext/github/modules/github_repo.py | 207 ++++++++---------- src/saltext/github/states/github.py | 2 +- src/saltext/github/states/github_repo.py | 52 ++--- src/saltext/github/utils/gh/repos.py | 2 +- .../modules/github/test_rulesets.py | 24 ++ 37 files changed, 321 insertions(+), 260 deletions(-) create mode 100644 docs/_ext/ghdomain.py create mode 100644 docs/topics/configuration.md diff --git a/changelog/+actions_secrets.added.md b/changelog/+actions_secrets.added.md index c6ca60f8..4088157f 100644 --- a/changelog/+actions_secrets.added.md +++ b/changelog/+actions_secrets.added.md @@ -1 +1 @@ -Added management of repository Actions secrets, encrypted client-side to the repository public key (``github_repo.list_actions_secrets``/``get_actions_secret``/``set_actions_secret``/``remove_actions_secret`` and the ``github_repo.actions_secret_present``/``actions_secret_absent`` states) +Added management of repository Actions secrets, encrypted client-side to the repository public key ({py:func}`github_repo.list_actions_secrets `/{py:func}`get_actions_secret `/{py:func}`set_actions_secret `/{py:func}`remove_actions_secret ` and the {py:func}`github_repo.actions_secret_present `/{py:func}`actions_secret_absent ` states) diff --git a/changelog/+add_team_profile.fixed.md b/changelog/+add_team_profile.fixed.md index 7c367a47..bd1593c3 100644 --- a/changelog/+add_team_profile.fixed.md +++ b/changelog/+add_team_profile.fixed.md @@ -1 +1 @@ -Fixed `add_team` refreshing the team cache using the default profile instead of the configured one +Fixed {py:func}`github.add_team ` refreshing the team cache using the default profile instead of the configured one diff --git a/changelog/+api_url.added.md b/changelog/+api_url.added.md index 06a447ba..062587b2 100644 --- a/changelog/+api_url.added.md +++ b/changelog/+api_url.added.md @@ -1 +1 @@ -Added the optional `api_url` profile option for targeting GitHub Enterprise Server instances +Added the optional {ghconf}`api_url` profile option for targeting GitHub Enterprise Server instances diff --git a/changelog/+default_branch.added.md b/changelog/+default_branch.added.md index e2a82df3..d77c96e0 100644 --- a/changelog/+default_branch.added.md +++ b/changelog/+default_branch.added.md @@ -1 +1 @@ -Added ``default_branch`` to ``github_repo.update`` and the ``github_repo.present`` state +Added `default_branch` to {py:func}`github_repo.update ` and the {py:func}`github_repo.present ` state diff --git a/changelog/+deploy_keys.added.md b/changelog/+deploy_keys.added.md index e0c575e3..41abce93 100644 --- a/changelog/+deploy_keys.added.md +++ b/changelog/+deploy_keys.added.md @@ -1 +1 @@ -Added management of repository deploy keys (``github_repo.list_deploy_keys``/``get_deploy_key``/``add_deploy_key``/``remove_deploy_key`` and the ``github_repo.deploy_key_present``/``deploy_key_absent`` states) +Added management of repository deploy keys ({py:func}`github_repo.list_deploy_keys `/{py:func}`get_deploy_key `/{py:func}`add_deploy_key `/{py:func}`remove_deploy_key ` and the {py:func}`github_repo.deploy_key_present `/{py:func}`deploy_key_absent ` states) diff --git a/changelog/+environments.added.md b/changelog/+environments.added.md index b5be6845..02f9bda1 100644 --- a/changelog/+environments.added.md +++ b/changelog/+environments.added.md @@ -1 +1 @@ -Added management of repository environments, including deployment branch policies (``github_repo.list_environments``/``get_environment``/``set_environment``/``remove_environment``/``list_deployment_branch_policies``/``add_deployment_branch_policy``/``remove_deployment_branch_policy`` and the ``github_repo.environment_present``/``environment_absent`` states) +Added management of repository environments, including deployment branch policies ({py:func}`github_repo.list_environments `/{py:func}`get_environment `/{py:func}`set_environment `/{py:func}`remove_environment `/{py:func}`list_deployment_branch_policies `/{py:func}`add_deployment_branch_policy `/{py:func}`remove_deployment_branch_policy ` and the {py:func}`github_repo.environment_present `/{py:func}`environment_absent ` states) diff --git a/changelog/+get_user_pubkeys.removed.md b/changelog/+get_user_pubkeys.removed.md index ad723624..51780ef7 100644 --- a/changelog/+get_user_pubkeys.removed.md +++ b/changelog/+get_user_pubkeys.removed.md @@ -1 +1 @@ -Removed the unauthenticated ``get_user_pubkeys`` helper from ``saltext.github.utils.gh``. It ignored the configured ``api_url``, relied on ``salt.utils.http`` and was not exposed in any module +Removed the unauthenticated `get_user_pubkeys` helper from `saltext.github.utils.github`. It ignored the configured {ghconf}`api_url`, relied on `salt.utils.http` and was not exposed in any module diff --git a/changelog/+is_team_member_missing_team.fixed.md b/changelog/+is_team_member_missing_team.fixed.md index 9e1a19b3..b6eb9591 100644 --- a/changelog/+is_team_member_missing_team.fixed.md +++ b/changelog/+is_team_member_missing_team.fixed.md @@ -1 +1 @@ -Fixed ``is_team_member`` crashing with a ``TypeError`` when the team does not exist. It now returns ``False``. +Fixed {py:func}`github.is_team_member ` crashing with a `TypeError` when the team does not exist. It now returns `False`. diff --git a/changelog/+labels.added.md b/changelog/+labels.added.md index dbab6376..eaeb91ab 100644 --- a/changelog/+labels.added.md +++ b/changelog/+labels.added.md @@ -1 +1 @@ -Added management of repository labels (``github_repo.list_labels``/``get_label``/``add_label``/``edit_label``/``remove_label`` and the ``github_repo.label_present``/``label_absent`` states) +Added management of repository labels ({py:func}`github_repo.list_labels `/{py:func}`get_label `/{py:func}`add_label `/{py:func}`edit_label `/{py:func}`remove_label ` and the {py:func}`github_repo.label_present `/{py:func}`label_absent ` states) diff --git a/changelog/+legacy_modules.deprecated.md b/changelog/+legacy_modules.deprecated.md index e7d2915a..5730cd6d 100644 --- a/changelog/+legacy_modules.deprecated.md +++ b/changelog/+legacy_modules.deprecated.md @@ -1 +1 @@ -Deprecated all current functions of the ``github`` execution module as well as the ``github`` state module. Use the corresponding functions of the new ``github_org`` and ``github_repo`` modules instead. The ``github`` execution module remains the namespace for functionality that is not tied to a single organization. Notably, ``github.get_user`` looks up organization membership despite its name - its replacement is ``github_org.get_member``. The ``list_members_without_mfa`` function and the per-team MFA enforcement of ``team_present`` are deprecated without replacement - require 2FA organization-wide in the GitHub settings instead +Deprecated all current functions of the {py:mod}`github execution module ` as well as the {py:mod}`github state module `. Use the corresponding functions of the new {py:mod}`github_org ` and {py:mod}`github_repo ` modules instead. The `github` execution module remains the namespace for functionality that is not tied to a single organization. The {py:func}`github.list_members_without_mfa ` function and the per-team MFA enforcement of {py:func}`github.team_present ` are deprecated without replacement - require 2FA organization-wide in the GitHub settings instead diff --git a/changelog/+list_funcs_error_crash.fixed.md b/changelog/+list_funcs_error_crash.fixed.md index 217e6ee6..9cfe2532 100644 --- a/changelog/+list_funcs_error_crash.fixed.md +++ b/changelog/+list_funcs_error_crash.fixed.md @@ -1 +1 @@ -Fixed ``get_issues``, ``get_issue_comments``, ``get_prs`` and ``get_milestones`` crashing with an ``AttributeError`` when the API returned an error. They now raise a ``CommandExecutionError`` carrying the API error message. +Fixed {py:func}`github.get_issues `, {py:func}`github.get_issue_comments `, {py:func}`github.get_prs ` and {py:func}`github.get_milestones ` crashing with an `AttributeError` when the API returned an error. They now raise a `CommandExecutionError` carrying the API error message. diff --git a/changelog/+milestone_pops.fixed.md b/changelog/+milestone_pops.fixed.md index 0c5c7f17..015e6371 100644 --- a/changelog/+milestone_pops.fixed.md +++ b/changelog/+milestone_pops.fixed.md @@ -1 +1 @@ -Fixed `get_milestones`/`get_milestone` crashing on API payloads missing optional keys +Fixed {py:func}`github.get_milestones ` / {py:func}`github.get_milestone ` crashing on API payloads missing optional keys diff --git a/changelog/+pages.added.md b/changelog/+pages.added.md index f6293365..d6b8c76a 100644 --- a/changelog/+pages.added.md +++ b/changelog/+pages.added.md @@ -1 +1 @@ -Added management of GitHub Pages sites (``github_repo.get_pages``/``setup_pages``/``update_pages``/``remove_pages`` and the ``github_repo.pages_present``/``pages_absent`` states) +Added management of GitHub Pages sites ({py:func}`github_repo.get_pages `/{py:func}`setup_pages `/{py:func}`update_pages `/{py:func}`remove_pages ` and the {py:func}`github_repo.pages_present `/{py:func}`pages_absent ` states) diff --git a/changelog/+remove_repo_missing.fixed.md b/changelog/+remove_repo_missing.fixed.md index 18ca145c..71781531 100644 --- a/changelog/+remove_repo_missing.fixed.md +++ b/changelog/+remove_repo_missing.fixed.md @@ -1 +1 @@ -Fixed `remove_repo` raising an exception instead of returning `False` for nonexistent repositories +Fixed {py:func}`github.remove_repo ` raising an exception instead of returning `False` for nonexistent repositories diff --git a/changelog/+repo_present_profile.fixed.md b/changelog/+repo_present_profile.fixed.md index 39a95a33..30a3e765 100644 --- a/changelog/+repo_present_profile.fixed.md +++ b/changelog/+repo_present_profile.fixed.md @@ -1 +1 @@ -Fixed `repo_present` not passing the configured profile when creating repositories +Fixed {py:func}`github.repo_present ` not passing the configured profile when creating repositories diff --git a/changelog/+repo_present_team_add_comment.fixed.md b/changelog/+repo_present_team_add_comment.fixed.md index 0177a165..a0ab52b4 100644 --- a/changelog/+repo_present_team_add_comment.fixed.md +++ b/changelog/+repo_present_team_add_comment.fixed.md @@ -1 +1 @@ -Fixed ``repo_present`` reporting a failed team *removal* when *adding* a repository to a team failed. +Fixed {py:func}`github.repo_present ` reporting a failed team *removal* when *adding* a repository to a team failed. diff --git a/changelog/+ruleset_absent_delete_failure.fixed.md b/changelog/+ruleset_absent_delete_failure.fixed.md index 110a0971..79250700 100644 --- a/changelog/+ruleset_absent_delete_failure.fixed.md +++ b/changelog/+ruleset_absent_delete_failure.fixed.md @@ -1 +1 @@ -Fixed ``ruleset_absent`` reporting success when the ruleset could not be deleted. The module function returns a truthy error dict on failure, which was mistaken for success, and the failure result was subsequently overwritten by the "does not exist" fallback. +Fixed {py:func}`github.ruleset_absent ` reporting success when the ruleset could not be deleted. The module function returns a truthy error dict on failure, which was mistaken for success, and the failure result was subsequently overwritten by the "does not exist" fallback. diff --git a/changelog/+ruleset_states.fixed.md b/changelog/+ruleset_states.fixed.md index 3257489a..1c09ac05 100644 --- a/changelog/+ruleset_states.fixed.md +++ b/changelog/+ruleset_states.fixed.md @@ -1 +1 @@ -Fixed `ruleset_present` drift detection, which compared server-populated fields and iterated the wrong parameter set, and made the ruleset states report forecast changes in test mode +Fixed {py:func}`github.ruleset_present ` drift detection, which compared server-populated fields and iterated the wrong parameter set, and made the ruleset states report forecast changes in test mode diff --git a/changelog/+scoped_modules.added.md b/changelog/+scoped_modules.added.md index df8066f2..b8a3521e 100644 --- a/changelog/+scoped_modules.added.md +++ b/changelog/+scoped_modules.added.md @@ -1 +1 @@ -Added scoped ``github_org`` and ``github_repo`` execution and state modules, which replace the organization- and repository-related functionality of the monolithic ``github`` modules. Unlike the legacy functions, they raise errors instead of logging them and returning ``False``. Collections with a unique natural key (repos, teams, team repos, labels, environments, Actions secrets) are returned as name-keyed mappings, id-addressed ones (issues, PRs, milestones, rulesets, deploy keys) as lists. All collection outputs can be reduced to specific fields via the ``fields`` argument. The new ``github_org.team_present`` state manages members as a simple list and drops the legacy per-team MFA enforcement options - require 2FA org-wide in the GitHub organization settings instead +Added scoped {py:mod}`github_org ` and {py:mod}`github_repo ` execution and state modules, which replace the organization- and repository-related functionality of the monolithic `github` modules. Unlike the legacy functions, they raise errors instead of logging them and returning `False`. Collections with a unique natural key (repos, teams, team repos, labels, environments, Actions secrets) are returned as name-keyed mappings, id-addressed ones (issues, PRs, milestones, rulesets, deploy keys) as lists. All collection outputs can be reduced to specific fields via the `fields` argument. The new `github_org.team_present` state manages members as a simple list and drops the legacy per-team MFA enforcement options - require 2FA org-wide in the GitHub organization settings instead diff --git a/changelog/+single_http_stack.changed.md b/changelog/+single_http_stack.changed.md index b9997402..94f44823 100644 --- a/changelog/+single_http_stack.changed.md +++ b/changelog/+single_http_stack.changed.md @@ -1 +1 @@ -All API calls are now routed through the PyGithub client, honoring the configured `api_url`, authentication and retry handling uniformly +All API calls are now routed through the PyGithub client, honoring the configured {ghconf}`api_url`, authentication and retry handling uniformly diff --git a/changelog/+structured_changes.added.md b/changelog/+structured_changes.added.md index c44831e3..0d372bac 100644 --- a/changelog/+structured_changes.added.md +++ b/changelog/+structured_changes.added.md @@ -1 +1 @@ -The new ``github_org`` and ``github_repo`` states report machine-readable ``changes`` dictionaries (``created``/``deleted``/``updated`` entity names, ``old``/``new`` setting mappings and ``*_added``/``*_removed`` name lists) instead of prose +The new {py:mod}`github_org ` and {py:mod}`github_repo ` states report machine-readable `changes` dictionaries (`created`/`deleted`/`updated` entity names, `old`/`new` setting mappings and `*_added`/`*_removed` name lists) instead of prose diff --git a/changelog/+team_present_members.fixed.md b/changelog/+team_present_members.fixed.md index 500e6401..63fd0532 100644 --- a/changelog/+team_present_members.fixed.md +++ b/changelog/+team_present_members.fixed.md @@ -1 +1 @@ -Fixed `team_present` crashing when managing team members +Fixed {py:func}`github.team_present ` crashing when managing team members diff --git a/changelog/+testmode_projected_changes.fixed.md b/changelog/+testmode_projected_changes.fixed.md index 4ce05fee..054c69ac 100644 --- a/changelog/+testmode_projected_changes.fixed.md +++ b/changelog/+testmode_projected_changes.fixed.md @@ -1 +1 @@ -All states now report the projected changes in test mode, matching the changes reported on actual application. Exception: ``team_present`` does not project member/repo changes for teams that do not exist yet. +All states now report the projected changes in test mode, matching the changes reported on actual application. diff --git a/changelog/+user_owned_repos.added.md b/changelog/+user_owned_repos.added.md index 1d53297d..c419238f 100644 --- a/changelog/+user_owned_repos.added.md +++ b/changelog/+user_owned_repos.added.md @@ -1 +1 @@ -The new modules support repositories of any owner. Profiles take an ``owner`` (organization or user; preferred over the legacy ``org_name`` alias), which defaults to the token's user when unset. Repository listing and creation live on the ``github_repo`` module (``list``/``create``), and all of its functions and states as well as all functions of the (now strictly organization-scoped) ``github_org`` module accept a per-call ``owner`` override, so repositories, teams, members and rulesets of foreign organizations can be managed as well. Due to GitHub API limitations, repository creation is only supported for organizations and the authenticated user, private repositories of other users are not listable, tokens without an associated user account (e.g. GitHub App installation tokens) require an explicit ``owner`` and teams always belong to an organization +The new modules support repositories of any owner. Profiles take an {ghconf}`owner` (organization or user; preferred over the legacy `org_name` alias), which defaults to the token's user when unset. Repository listing and creation live on the `github_repo` module (`list`/`create`), and all of its functions and states as well as all functions of the (now strictly organization-scoped) `github_org` module accept a per-call `owner` override, so repositories, teams, members and rulesets of foreign organizations can be managed as well. Due to GitHub API limitations, repository creation is only supported for organizations and the authenticated user, private repositories of other users are not listable, tokens without an associated user account (e.g. GitHub App installation tokens) require an explicit `owner` and teams always belong to an organization diff --git a/changelog/+utils-refactor.changed.md b/changelog/+utils-refactor.changed.md index dac91d1d..5c6fa693 100644 --- a/changelog/+utils-refactor.changed.md +++ b/changelog/+utils-refactor.changed.md @@ -1 +1 @@ -Refactored the GitHub API interaction layer into reusable per-entity modules below `saltext.github.utils.gh`. These raise `GitHubApiError`/`GitHubNotFoundError` (subclasses of `CommandExecutionError`) instead of returning error indicators. Existing execution module functions behave as before, except that unhandled API errors now surface as the above exceptions instead of raw PyGithub ones. +Refactored the GitHub API interaction layer into reusable per-entity modules below {py:mod}`saltext.github.utils.gh`. These raise {py:class}`GitHubApiError `/{py:class}`GitHubNotFoundError ` (subclasses of {py:class}`CommandExecutionError `) instead of returning error indicators. Existing execution module functions behave as before, except that unhandled API errors now surface as the above exceptions instead of raw PyGithub ones. diff --git a/changelog/+workflow_permissions.added.md b/changelog/+workflow_permissions.added.md index e5695e6b..3edd2318 100644 --- a/changelog/+workflow_permissions.added.md +++ b/changelog/+workflow_permissions.added.md @@ -1 +1 @@ -Added management of default workflow permissions for the ``GITHUB_TOKEN`` (``github_repo.get_workflow_permissions``/``set_workflow_permissions`` and the ``github_repo.workflow_permissions_managed`` state) +Added management of default workflow permissions for the `GITHUB_TOKEN` ({py:func}`github_repo.get_workflow_permissions `/{py:func}`set_workflow_permissions ` and the {py:func}`github_repo.workflow_permissions_managed ` state) diff --git a/docs/_ext/ghdomain.py b/docs/_ext/ghdomain.py new file mode 100644 index 00000000..07e66027 --- /dev/null +++ b/docs/_ext/ghdomain.py @@ -0,0 +1,12 @@ +""" +Allows to link configuration values using :ghconf:`config_val`. +""" + + +def setup(app): + app.add_crossref_type( + directivename="ghconf", + rolename="ghconf", + indextemplate="single: configuration; %s", + ) + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs/conf.py b/docs/conf.py index 20845c29..9a34f37d 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -93,6 +93,7 @@ "sphinx_copybutton", "sphinxcontrib.spelling", "saltdomain", + "ghdomain", "sphinxcontrib.towncrier.ext", "myst_parser", "sphinx_inline_tabs", diff --git a/docs/index.rst b/docs/index.rst index 9408903b..30e341f4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,19 @@ ``saltext-github``: Integrate Salt with GitHub ============================================== -Salt Extension for interacting with GitHub +Salt Extension for interacting with GitHub: + +* Manage organization members, teams and organization rulesets via the + :py:mod:`github_org ` execution and + state modules. +* List, create and manage organization- or user-owned repositories – + including their settings, teams, rulesets, deploy keys, Actions secrets + and workflow permissions, environments, labels and Pages sites – and + query their issues, milestones and pull requests via the + :py:mod:`github_repo ` execution + and state modules. +* Works against GitHub Enterprise Server instances via the + :ghconf:`api_url` profile parameter. .. toctree:: :maxdepth: 2 @@ -9,6 +21,7 @@ Salt Extension for interacting with GitHub :hidden: topics/installation + topics/configuration .. toctree:: :maxdepth: 2 diff --git a/docs/topics/configuration.md b/docs/topics/configuration.md new file mode 100644 index 00000000..d6c1868e --- /dev/null +++ b/docs/topics/configuration.md @@ -0,0 +1,80 @@ +(github-configuration)= +# Configuration + +All modules provided by this extension read their configuration from +*profiles*. A profile is a mapping under a top-level key in the minion +configuration, minion pillar or master configuration. Functions and +states use the profile named `github` by default and accept a +`profile` argument to select a different one. + +## Profile parameters + +:::{ghconf} token +::: +`token` +: Required. Access token to authenticate with. + +:::{ghconf} owner +::: +`owner` +: Default owner (organization or user) whose resources are managed. + Defaults to the token's user, so organization management and tokens + without an associated user account (e.g. GitHub App installation + tokens) require it to be set. `org_name` is supported as a legacy + alias. + + :::{versionadded} 1.1.0 + Renamed from `org_name`. The new {py:mod}`github_repo ` + modules can manage user repositories in addition to organization ones. + ::: + +:::{ghconf} repo_name +::: +`repo_name` +: The default repository the `github_repo` functions and states operate + on when no `repo_name` is passed. + +:::{ghconf} allow_repo_privacy_changes +::: +`allow_repo_privacy_changes` +: Allow automated repository privacy modifications. It can be dangerous + to change the privacy of a repository in an automated way, hence this + defaults to false. + +:::{ghconf} api_url +::: +`api_url` +: Base URL of the GitHub API. Set this when targeting a GitHub + Enterprise Server instance, e.g. `https://github.example.com/api/v3`. + Defaults to `https://api.github.com`. + +## Multiple profiles + +Any number of profiles can be defined and selected per call: + +```yaml +github: + token: abc1234 + owner: my_organization + +my_user: + token: def5678 +``` + +```bash +salt-call github_repo.list profile=my_user +``` + +## Secret management + +Profile values containing [SDB URIs](https://docs.saltproject.io/en/latest/topics/sdb/index.html) +are rendered on access, which also works in pillar-sourced profiles: + +```yaml +# Requires a corresponding SDB profile in the minion configuration, e.g. +# osenv: +# driver: env +github: + token: sdb://osenv/GITHUB_TOKEN + owner: my_organization +``` diff --git a/src/saltext/github/modules/github.py b/src/saltext/github/modules/github.py index 83b585d9..50b24a04 100644 --- a/src/saltext/github/modules/github.py +++ b/src/saltext/github/modules/github.py @@ -1,9 +1,11 @@ """ -Module for interacting with the GitHub v3 API. +General module for interacting with the GitHub v3 API. + +**Currently only contains deprecated (legacy) functions**. :depends: PyGithub python module -.. note:: +.. important:: All organization- and repository-related functions in this module are deprecated in favor of the :py:mod:`github_org @@ -16,52 +18,9 @@ Configuration ------------- -Configure this module by specifying the name of a configuration -profile in the minion config, minion pillar, or master config. The module -will use the ``github`` key by default, if defined. - -.. versionadded:: 1.1.0 - - Added the ``api_url`` configuration option to override the API endpoint - we run against. Can be used for GitHub Enterprise instances. - -.. versionadded:: 1.1.0 - - Profile configuration values containing SDB URIs are now rendered on - access. Salt itself only renders them inside the minion/master - configuration during startup, so this makes them work in pillar-sourced - profiles as well: - - .. code-block:: yaml - - # Requires a corresponding SDB profile in the minion configuration, - # e.g. - # osenv: - # driver: env - github: - token: sdb://osenv/GITHUB_TOKEN - org_name: my_organization - -For example: - -.. code-block:: yaml - - github: - token: abc1234 - org_name: my_organization - - # optional: some functions require a repo_name, which - # can be set in the config file, or passed in at the CLI. - repo_name: my_repo - - # optional: it can be dangerous to change the privacy of a repository - # in an automated way. set this to True to allow privacy modifications - allow_repo_privacy_changes: False - - # optional: the base URL of the GitHub API. Set this when targeting - # a GitHub Enterprise Server instance, e.g. - # https://github.example.com/api/v3 - api_url: https://api.github.com +This module reads its configuration from profiles, which are described +in the :ref:`configuration guide `. All functions +accept a ``profile`` argument, defaulting to the ``github`` profile. """ import logging @@ -168,6 +127,14 @@ def _get_config_value(profile, config_name): ) +def _get_owner(profile): + """ + Helper function that returns a profile's default repository owner + (``owner``, legacy alias ``org_name``), or None when unset. + """ + return gh_profile.get_owner(profile, __salt__=__salt__, __context__=__context__) + + def list_users(profile="github", ignore_cache=False): """ List all users within the organization. @@ -259,7 +226,7 @@ def get_issue(issue_number, repo_name=None, profile="github", output="min"): The name of the repository from which to get the issue. This argument is required, either passed via the CLI, or defined in the configured profile. A ``repo_name`` passed as a CLI argument will override the - repo_name defined in the configured profile, if provided. + :ghconf:`repo_name` defined in the configured profile, if provided. profile The name of the profile configuration to use. Defaults to ``github``. @@ -309,7 +276,7 @@ def get_issue_comments(issue_number, repo_name=None, profile="github", since=Non The name of the repository to which the issue belongs. This argument is required, either passed via the CLI, or defined in the configured profile. A ``repo_name`` passed as a CLI argument will override the - repo_name defined in the configured profile, if provided. + :ghconf:`repo_name` defined in the configured profile, if provided. profile The name of the profile configuration to use. Defaults to ``github``. @@ -374,7 +341,7 @@ def get_issues( The name of the repository for which to list issues. This argument is required, either passed via the CLI, or defined in the configured profile. A ``repo_name`` passed as a CLI argument will override the - repo_name defined in the configured profile, if provided. + :ghconf:`repo_name` defined in the configured profile, if provided. profile The name of the profile configuration to use. Defaults to ``github``. @@ -483,7 +450,7 @@ def get_milestones( The name of the repository for which to list issues. This argument is required, either passed via the CLI, or defined in the configured profile. A ``repo_name`` passed as a CLI argument will override the - repo_name defined in the configured profile, if provided. + :ghconf:`repo_name` defined in the configured profile, if provided. profile The name of the profile configuration to use. Defaults to ``github``. @@ -555,7 +522,7 @@ def get_milestone(number=None, name=None, repo_name=None, profile="github", outp The name of the repository for which to list issues. This argument is required, either passed via the CLI, or defined in the configured profile. A ``repo_name`` passed as a CLI argument will override the - repo_name defined in the configured profile, if provided. + :ghconf:`repo_name` defined in the configured profile, if provided. profile The name of the profile configuration to use. Defaults to ``github``. @@ -1478,11 +1445,13 @@ def _resolve_ruleset_params( if ruleset_type not in ("repo", "org"): raise CommandExecutionError(f"Invalid ruleset type: {ruleset_type}") + # Unset owners fall back to the connection default (the profile's + # owner or the token's user) in the ruleset utils. if ruleset_type == "repo": - owner = owner or _get_config_value(profile, "owner") + owner = owner or _get_owner(profile) repo_name = repo_name or _get_config_value(profile, "repo_name") else: - org_name = org_name or _get_config_value(profile, "org_name") + org_name = org_name or _get_owner(profile) # check for optional header_dict try: diff --git a/src/saltext/github/modules/github_org.py b/src/saltext/github/modules/github_org.py index bdf697bc..97f0ecb1 100644 --- a/src/saltext/github/modules/github_org.py +++ b/src/saltext/github/modules/github_org.py @@ -3,9 +3,10 @@ .. versionadded:: 1.1.0 -Unlike the legacy :py:mod:`github ` module, +Unlike the legacy functions in the :py:mod:`github ` module, the functions in this module raise errors (usually subclasses of -``CommandExecutionError``) instead of logging them and returning ``False``. +:py:class:`CommandExecutionError `) +instead of logging them and returning ``False``. Collections with a unique natural key (teams, team repositories) are returned as mappings keyed by it, id-addressed collections (rulesets) @@ -14,7 +15,7 @@ .. note:: This module is organization-scoped: all functions target an - organization, defaulting to the profile's ``owner`` (which hence + organization, defaulting to the profile's :ghconf:`owner` (which hence must be an organization) and accepting an ``owner`` argument to manage another organization. Repository listing and creation live in the :py:mod:`github_repo ` @@ -25,27 +26,9 @@ Configuration ------------- -This module uses similar configuration profiles as the -:py:mod:`github ` module: - -.. code-block:: yaml - - github: - token: abc1234 - - # Optional: The default owner (organization or user) whose - # resources are managed. org_name is supported as a legacy alias. - # Defaults to the token's user, so organization management and - # tokens without an associated user account (e.g. GitHub App - # installation tokens) require it to be set. - owner: my_organization - - # Optional: Allow automated repository privacy modifications. - allow_repo_privacy_changes: False - - # Optional: Base URL of the GitHub API. Set this when targeting - # a GitHub Enterprise Server instance, e.g. https://github.example.com/api/v3 - api_url: https://api.github.com +This module reads its configuration from profiles, which are described +in the :ref:`configuration guide `. The profile's +:ghconf:`owner` must be set to the managed organization. """ import logging @@ -198,7 +181,7 @@ def list_teams(owner=None, fields=None, ignore_cache=False, profile="github"): salt-call github_org.list_teams owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. fields Only include these fields in the output. A single field name @@ -228,7 +211,7 @@ def get_team(name, owner=None, profile="github"): Name of the team. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -272,7 +255,7 @@ def add_team( ``pull``, ``push`` or ``admin``. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -315,7 +298,7 @@ def edit_team(name, description=None, privacy=None, permission=None, owner=None, ``pull``, ``push`` or ``admin``. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -347,7 +330,7 @@ def remove_team(name, owner=None, profile="github"): Name of the team to remove. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -372,7 +355,7 @@ def list_team_repos(team_name, owner=None, fields=None, ignore_cache=False, prof Name of the team from which to list repos. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. fields Only include these fields in the output. A single field name @@ -413,7 +396,7 @@ def add_team_repo(repo_name, team_name, permission=None, owner=None, profile="gi ``push`` or ``admin``. If not specified, the team's default permission is used. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -445,7 +428,7 @@ def remove_team_repo(repo_name, team_name, owner=None, profile="github"): Name of the team to remove the repository from. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -469,7 +452,7 @@ def list_team_members(team_name, owner=None, ignore_cache=False, profile="github Name of the team from which to list members. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. ignore_cache Bypass the use of cached team members. @@ -500,7 +483,7 @@ def is_team_member(name, team_name, owner=None, profile="github"): Name of the team to check membership in. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -525,7 +508,7 @@ def add_team_member(name, team_name, owner=None, profile="github"): Name of the team to add the user to. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -552,7 +535,7 @@ def remove_team_member(name, team_name, owner=None, profile="github"): Name of the team to remove the user from. owner - Organization owning the team. Defaults to the profile's ``owner``. + Organization owning the team. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. diff --git a/src/saltext/github/modules/github_repo.py b/src/saltext/github/modules/github_repo.py index 57430406..4f0b0110 100644 --- a/src/saltext/github/modules/github_repo.py +++ b/src/saltext/github/modules/github_repo.py @@ -6,9 +6,10 @@ .. versionadded:: 1.1.0 -Unlike the legacy :py:mod:`github ` module, +Unlike the legacy functions in the :py:mod:`github ` module, the functions in this module raise errors (usually subclasses of -``CommandExecutionError``) instead of logging them and returning ``False``. +:py:class:`CommandExecutionError `) +instead of logging them and returning ``False``. Collections with a unique natural key (repositories, teams, Actions secrets, environments, labels) are returned as mappings keyed by it, @@ -19,7 +20,7 @@ This module works on repositories of any owner (organization or user): all functions accept an ``owner`` argument that defaults to - the profile's ``owner``. Teams always belong to the owning + the profile's :ghconf:`owner`. Teams always belong to the owning organization, and repository creation (:py:func:`create `, the creating branch of the ``github_repo.present`` state) is only supported for @@ -30,33 +31,11 @@ Configuration ------------- -This module uses similar configuration profiles as the -:py:mod:`github ` module. All functions -operate on the repository named in ``repo_name``, which is required -unless a default is set in the profile: - -.. code-block:: yaml - - github: - token: abc1234 - - # Optional: The default owner (organization or user) whose - # repositories are managed. org_name is supported as a legacy - # alias. Defaults to the token's user, so tokens without an - # associated user account (e.g. GitHub App installation tokens) - # require it to be set. - owner: my_organization - - # optional: the default repository to operate on when no - # repo_name is passed. - repo_name: my_repo - - # Optional: Allow automated repository privacy modifications. - allow_repo_privacy_changes: False - - # Optional: Base URL of the GitHub API. Set this when targeting - # a GitHub Enterprise Server instance, e.g. https://github.example.com/api/v3 - api_url: https://api.github.com +This module reads its configuration from profiles, which are described +in the :ref:`configuration guide `. The +repository-scoped functions operate on the repository named in +``repo_name``, which is required unless a default is set in the +profile (:ghconf:`repo_name`). """ import logging @@ -111,7 +90,7 @@ def list_(private=None, owner=None, fields=None, ignore_cache=False, profile="gi owner Owner (organization or user) whose repositories to list. - Defaults to the profile's ``owner``. Private repositories of + Defaults to the profile's :ghconf:`owner`. Private repositories of users other than the authenticated one are not visible. fields @@ -227,10 +206,10 @@ def get_info(repo_name=None, owner=None, ignore_cache=False, profile="github"): salt-call github_repo.get_info my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. ignore_cache Bypass the use of cached repository information. @@ -254,10 +233,10 @@ def get_teams(repo_name=None, owner=None, fields=None, profile="github"): salt-call github_repo.get_teams my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. fields Only include these fields in the output. A single field name @@ -294,10 +273,10 @@ def update( salt-call github_repo.update my-repo description='Managed by Salt' repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. description Repository description. @@ -306,7 +285,7 @@ def update( URL with more information about the repository, e.g. rendered docs. private - Repository visibility. Changing this requires ``allow_repo_privacy_changes`` + Repository visibility. Changing this requires :ghconf:`allow_repo_privacy_changes` to be enabled in the profile. has_issues @@ -355,10 +334,10 @@ def delete(repo_name=None, owner=None, profile="github"): salt-call github_repo.delete my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -395,10 +374,10 @@ def list_issues( salt-call github_repo.list_issues my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. milestone Number of a GitHub milestone, or a string of either ``*`` or ``none``. @@ -475,10 +454,10 @@ def get_issue(number, repo_name=None, owner=None, profile="github"): Issue number to retrieve. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -501,10 +480,10 @@ def get_issue_comments(number, repo_name=None, owner=None, since=None, profile=" Issue number for which to retrieve comments. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. since Only return comments updated at or after this time. This is a @@ -537,10 +516,10 @@ def list_milestones( salt-call github_repo.list_milestones my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. state Filter milestones by their state. Either ``open``, ``closed``, or ``all``. @@ -594,10 +573,10 @@ def get_milestone(number=None, name=None, repo_name=None, owner=None, profile="g Title of the milestone to retrieve. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -638,10 +617,10 @@ def list_pulls( salt-call github_repo.list_pulls my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. state Filter returned pull requests by their state. Can be either @@ -698,10 +677,10 @@ def list_rulesets(repo_name=None, owner=None, per_page=None, fields=None, profil salt-call github_repo.list_rulesets my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. per_page Pagination size, maximum 100. @@ -739,10 +718,10 @@ def get_ruleset(ruleset_id, repo_name=None, owner=None, profile="github"): ID of the ruleset. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -767,10 +746,10 @@ def add_ruleset(ruleset_params, repo_name=None, owner=None, profile="github"): Ruleset definition. Must contain at least ``name`` and ``enforcement``. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -798,10 +777,10 @@ def update_ruleset(ruleset_id, ruleset_params, repo_name=None, owner=None, profi Updated ruleset definition. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -832,10 +811,10 @@ def delete_ruleset(ruleset_id, repo_name=None, owner=None, profile="github"): ID of the ruleset. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -866,10 +845,10 @@ def list_deploy_keys(repo_name=None, owner=None, fields=None, profile="github"): salt-call github_repo.list_deploy_keys my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. fields Only include these fields in the output. A single field name @@ -898,10 +877,10 @@ def get_deploy_key(key_id, repo_name=None, owner=None, profile="github"): ID of the deploy key. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -933,10 +912,10 @@ def add_deploy_key(title, key, read_only=False, repo_name=None, owner=None, prof Deny write access to the repository for this key. Defaults to false. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -961,10 +940,10 @@ def remove_deploy_key(key_id, repo_name=None, owner=None, profile="github"): ID of the deploy key. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -987,10 +966,10 @@ def list_actions_secrets(repo_name=None, owner=None, fields=None, profile="githu salt-call github_repo.list_actions_secrets my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. fields Only include these fields in the output. A single field name @@ -1019,10 +998,10 @@ def get_actions_secret(name, repo_name=None, owner=None, profile="github"): Name of the secret. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1049,10 +1028,10 @@ def set_actions_secret(name, value, repo_name=None, owner=None, profile="github" Plaintext secret value. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1076,10 +1055,10 @@ def remove_actions_secret(name, repo_name=None, owner=None, profile="github"): Name of the secret. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1102,10 +1081,10 @@ def get_workflow_permissions(repo_name=None, owner=None, profile="github"): salt-call github_repo.get_workflow_permissions my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1139,10 +1118,10 @@ def set_workflow_permissions( Allow GitHub Actions to approve pull requests. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1170,10 +1149,10 @@ def list_environments(repo_name=None, owner=None, fields=None, profile="github") salt-call github_repo.list_environments my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. fields Only include these fields in the output. A single field name @@ -1201,10 +1180,10 @@ def get_environment(name, repo_name=None, owner=None, profile="github"): Name of the environment. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1259,10 +1238,10 @@ def set_environment( `. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1294,10 +1273,10 @@ def remove_environment(name, repo_name=None, owner=None, profile="github"): Name of the environment. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1323,10 +1302,10 @@ def list_deployment_branch_policies( Name of the environment. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. fields Only include these fields in the output. A single field name @@ -1371,10 +1350,10 @@ def add_deployment_branch_policy( Defaults to ``branch``. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1404,10 +1383,10 @@ def remove_deployment_branch_policy( ID of the branch policy. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1431,10 +1410,10 @@ def list_labels(repo_name=None, owner=None, fields=None, profile="github"): salt-call github_repo.list_labels my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. fields Only include these fields in the output. A single field name @@ -1462,10 +1441,10 @@ def get_label(name, repo_name=None, owner=None, profile="github"): Name of the label. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1495,10 +1474,10 @@ def add_label(name, color=None, description=None, repo_name=None, owner=None, pr Short description of the label. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1535,10 +1514,10 @@ def edit_label( Short description of the label. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1569,10 +1548,10 @@ def remove_label(name, repo_name=None, owner=None, profile="github"): Name of the label. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1593,10 +1572,10 @@ def get_pages(repo_name=None, owner=None, profile="github"): salt-call github_repo.get_pages my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1635,10 +1614,10 @@ def setup_pages( either ``/`` or ``/docs``. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1691,10 +1670,10 @@ def update_pages( Enforce HTTPS for the site. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1723,10 +1702,10 @@ def remove_pages(repo_name=None, owner=None, profile="github"): salt-call github_repo.remove_pages my-repo repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. diff --git a/src/saltext/github/states/github.py b/src/saltext/github/states/github.py index c382d920..8a623daf 100644 --- a/src/saltext/github/states/github.py +++ b/src/saltext/github/states/github.py @@ -1,5 +1,5 @@ """ -Manage organization teams/members, repositories and rulesets. +**Deprecated**: Manage organization teams/members, repositories and rulesets. .. deprecated:: 1.1.0 diff --git a/src/saltext/github/states/github_repo.py b/src/saltext/github/states/github_repo.py index 689bf9ce..f00c7659 100644 --- a/src/saltext/github/states/github_repo.py +++ b/src/saltext/github/states/github_repo.py @@ -131,7 +131,7 @@ def present( private Repository visibility. Changing this on an existing repository - requires ``allow_repo_privacy_changes`` to be enabled in the profile. + requires :ghconf:`allow_repo_privacy_changes` to be enabled in the profile. has_issues Enable issues for this repository. @@ -166,7 +166,7 @@ def present( only supported for organization-owned repositories. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. Repository creation is only supported for organizations and the authenticated user. @@ -276,7 +276,7 @@ def absent(name, owner=None, profile="github"): Name of the repository. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -332,10 +332,10 @@ def ruleset_present(name, ruleset_params=None, repo_name=None, owner=None, profi this state's ``name``. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -401,10 +401,10 @@ def ruleset_absent(name, repo_name=None, owner=None, profile="github"): Ruleset name to manage. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -474,10 +474,10 @@ def deploy_key_present(name, key, read_only=False, repo_name=None, owner=None, p Deny write access to the repository for this key. Defaults to false. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -530,10 +530,10 @@ def deploy_key_absent(name, repo_name=None, owner=None, profile="github"): Display title of the deploy key. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -591,10 +591,10 @@ def actions_secret_present( Defaults to false. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -649,10 +649,10 @@ def actions_secret_absent(name, repo_name=None, owner=None, profile="github"): case-insensitively. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -711,7 +711,7 @@ def workflow_permissions_managed( Name of the repository, if it differs from ``name``. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -829,10 +829,10 @@ def environment_present( of branch name patterns. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -931,10 +931,10 @@ def environment_absent(name, repo_name=None, owner=None, profile="github"): Name of the environment. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -992,10 +992,10 @@ def label_present(name, color=None, description=None, repo_name=None, owner=None Short description of the label. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1063,10 +1063,10 @@ def label_absent(name, repo_name=None, owner=None, profile="github"): Name of the label. repo_name - Name of the repository. Defaults to the profile's ``repo_name``. + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1138,7 +1138,7 @@ def pages_present( Name of the repository, if it differs from ``name``. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. @@ -1236,7 +1236,7 @@ def pages_absent(name, repo_name=None, owner=None, profile="github"): Name of the repository, if it differs from ``name``. owner - Owner of the repository. Defaults to the profile's ``owner``. + Owner of the repository. Defaults to the profile's :ghconf:`owner`. profile Profile configuration name to use. Defaults to ``github``. diff --git a/src/saltext/github/utils/gh/repos.py b/src/saltext/github/utils/gh/repos.py index c3d850a7..c404c320 100644 --- a/src/saltext/github/utils/gh/repos.py +++ b/src/saltext/github/utils/gh/repos.py @@ -286,7 +286,7 @@ def update( URL with more information about the repository, e.g. rendered docs. private - Repository visibility. Changing this requires ``allow_repo_privacy_changes`` + Repository visibility. Changing this requires :ghconf:`allow_repo_privacy_changes` to be enabled in the profile. has_issues diff --git a/tests/functional/modules/github/test_rulesets.py b/tests/functional/modules/github/test_rulesets.py index 6d5ef910..af14e29e 100644 --- a/tests/functional/modules/github/test_rulesets.py +++ b/tests/functional/modules/github/test_rulesets.py @@ -2,11 +2,35 @@ from salt.exceptions import CommandExecutionError +@pytest.fixture(scope="module") +def minion_config_defaults(github_profile): + # A profile relying on the shared owner parameter instead of the + # legacy org_name/owner ruleset profile parameters + owner_profile = { + "token": github_profile["token"], + "owner": github_profile["org_name"], + "api_url": github_profile["api_url"], + } + return {"github": github_profile, "github_owner": owner_profile} + + def test_list_rulesets_repo(github): ret = github.list_rulesets(ruleset_type="repo", owner="testorg", repo_name="repo-a") assert [ruleset["name"] for ruleset in ret["rulesets"]] == ["repo-rules"] +def test_list_rulesets_repo_owner_fallback(github): + # Without an explicit owner, the profile's owner/org_name is used + ret = github.list_rulesets(ruleset_type="repo", repo_name="repo-a") + assert [ruleset["name"] for ruleset in ret["rulesets"]] == ["repo-rules"] + + +def test_list_rulesets_org_owner_profile(github): + # Profiles keyed with owner instead of org_name work as well + ret = github.list_rulesets(ruleset_type="org", profile="github_owner") + assert [ruleset["name"] for ruleset in ret["rulesets"]] == ["org-rules"] + + def test_list_rulesets_org(github): ret = github.list_rulesets(ruleset_type="org") assert [ruleset["name"] for ruleset in ret["rulesets"]] == ["org-rules"]