diff --git a/changelog/+actions_secrets.added.md b/changelog/+actions_secrets.added.md new file mode 100644 index 00000000..4088157f --- /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 ({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 new file mode 100644 index 00000000..d77c96e0 --- /dev/null +++ b/changelog/+default_branch.added.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..41abce93 --- /dev/null +++ b/changelog/+deploy_keys.added.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..02f9bda1 --- /dev/null +++ b/changelog/+environments.added.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..51780ef7 --- /dev/null +++ b/changelog/+get_user_pubkeys.removed.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..eaeb91ab --- /dev/null +++ b/changelog/+labels.added.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..5730cd6d --- /dev/null +++ b/changelog/+legacy_modules.deprecated.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..d6b8c76a --- /dev/null +++ b/changelog/+pages.added.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..b8a3521e --- /dev/null +++ b/changelog/+scoped_modules.added.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..0d372bac --- /dev/null +++ b/changelog/+structured_changes.added.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..c419238f --- /dev/null +++ b/changelog/+user_owned_repos.added.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..3edd2318 --- /dev/null +++ b/changelog/+workflow_permissions.added.md @@ -0,0 +1 @@ +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/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 da3eef7c..198d2c55 100644 --- a/docs/ref/utils/index.rst +++ b/docs/ref/utils/index.rst @@ -11,12 +11,18 @@ _________ 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.members utils.gh.milestones + utils.gh.pages + utils.gh.profile utils.gh.pulls utils.gh.repos utils.gh.rulesets utils.gh.teams - utils.gh.users 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.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.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/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/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/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/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 8b8ce5cc..50b24a04 100644 --- a/src/saltext/github/modules/github.py +++ b/src/saltext/github/modules/github.py @@ -1,57 +1,26 @@ """ -Module for interacting with the GitHub v3 API. +General module for interacting with the GitHub v3 API. -:depends: PyGithub python module - -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: +**Currently only contains deprecated (legacy) functions**. - .. 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 +:depends: PyGithub python module - github: - token: abc1234 - org_name: my_organization +.. important:: - # 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 + 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, 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. - # 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 +Configuration +------------- - # 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 @@ -61,14 +30,14 @@ from saltext.github.utils import filter_unset try: - from saltext.github.utils.gh import client as gh_client 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 @@ -78,15 +47,10 @@ log = logging.getLogger(__name__) -API_URL_DEFAULT = "https://api.github.com" - __virtualname__ = "github" def __virtual__(): - """ - Only load this module if PyGithub is installed on this minion. - """ if HAS_LIBS: return __virtualname__ return ( # pragma: no cover @@ -95,87 +59,23 @@ 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. - """ - 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 - - 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] - - -def list_users(profile="github", ignore_cache=False): - """ - List all users within the organization. - - 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) + return gh_profile.get_conn(profile, __salt__=__salt__, __context__=__context__) 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. @@ -201,16 +101,70 @@ 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 _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. + + .. 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. + .. deprecated:: 1.1.0 + Use :py:func:`github_org.add_member ` instead. + name The user for which to obtain information. @@ -225,7 +179,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 @@ -235,6 +189,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. @@ -249,7 +206,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 @@ -259,6 +216,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. @@ -266,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``. @@ -306,6 +266,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. @@ -313,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``. @@ -371,11 +334,14 @@ 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 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``. @@ -477,11 +443,14 @@ 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 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``. @@ -539,6 +508,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``. @@ -550,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``. @@ -600,6 +572,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. @@ -613,13 +588,24 @@ 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"): """ 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. @@ -642,6 +628,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``. @@ -660,6 +649,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``. @@ -677,6 +669,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``. @@ -706,6 +701,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. @@ -779,6 +777,9 @@ def edit_repo( """ 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. @@ -838,6 +839,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. @@ -869,6 +873,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. @@ -895,6 +902,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. @@ -939,6 +949,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. @@ -985,6 +998,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. @@ -1015,6 +1031,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. @@ -1047,6 +1066,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. @@ -1091,6 +1113,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. @@ -1122,6 +1147,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. @@ -1154,6 +1182,11 @@ def list_members_without_mfa(profile="github", ignore_cache=False): """ List all members (in lower case) without MFA turned on. + .. deprecated:: 1.1.0 + 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``. @@ -1166,7 +1199,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"): @@ -1174,6 +1207,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. @@ -1197,6 +1233,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. @@ -1232,6 +1271,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. @@ -1263,6 +1305,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``. @@ -1293,6 +1338,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 @@ -1397,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: @@ -1450,6 +1500,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``. @@ -1494,6 +1548,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``. @@ -1538,6 +1596,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``. @@ -1591,6 +1653,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``. @@ -1637,6 +1703,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``. diff --git a/src/saltext/github/modules/github_org.py b/src/saltext/github/modules/github_org.py new file mode 100644 index 00000000..97f0ecb1 --- /dev/null +++ b/src/saltext/github/modules/github_org.py @@ -0,0 +1,672 @@ +""" +Manage a GitHub organization: members, teams and organization rulesets. + +.. versionadded:: 1.1.0 + +Unlike the legacy functions in the :py:mod:`github ` module, +the functions in this module raise errors (usually subclasses of +: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) +as lists. + +.. note:: + + This module is organization-scoped: all functions target an + 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 ` + module since they also apply to user accounts. + +:depends: PyGithub python module + +Configuration +------------- + +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 + +from salt.exceptions import CommandExecutionError + +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 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 _get_conn(profile): + return gh_profile.get_conn(profile, __salt__=__salt__, __context__=__context__) + + +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, owner=owner) + if team is None: + 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 {owner or conn.owner}." + ) + return team + + +def list_members(owner=None, ignore_cache=False, profile="github"): + """ + List the logins of all members of the organization. + + CLI Example: + + .. code-block:: bash + + 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), owner=owner, ignore_cache=ignore_cache) + + +def get_member(name, owner=None, profile="github"): + """ + Return information about a user, including their organization + membership state (``in_org`` and ``membership_state``). + + CLI Example: + + .. code-block:: bash + + salt-call github_org.get_member github-handle + + 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, owner=owner) + + +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 + accepted first). + + CLI Example: + + .. code-block:: bash + + salt-call github_org.add_member github-handle + + 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, owner=owner) + + +def remove_member(name, owner=None, profile="github"): + """ + Remove a user from the organization. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.remove_member github-handle + + name + Login of the user to remove. + + owner + Organization to target. Defaults to the profile's ``owner``. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + gh_members.remove(_get_conn(profile), name, owner=owner) + return True + + +def _strip_team_caches(team_info): + """ + 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")} + + +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. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.list_teams + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + 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``. + """ + 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, owner=None, profile="github"): + """ + Return the details of a single team. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.get_team 'team_name' + + name + Name of the team. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + return _strip_team_caches(_get_team(_get_conn(profile), name, owner=owner)) + + +def add_team( + name, + description=None, + repo_names=None, + privacy=None, + permission=None, + owner=None, + profile="github", +): + """ + 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 + Name of the team to create. + + description + Team description. + + repo_names + Names of repositories to add the team to. + + 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``. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + conn = _get_conn(profile) + gh_teams.create( + conn, + name, + description=description, + repo_names=repo_names, + privacy=privacy, + permission=permission, + owner=owner, + ) + return _strip_team_caches(_get_team(conn, name, owner=owner)) + + +def edit_team(name, description=None, privacy=None, permission=None, owner=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 + Name of the team to edit. + + description + Team description. + + 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``. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + conn = _get_conn(profile) + gh_teams.update( + conn, + _get_team(conn, name, owner=owner), + name, + description=description, + privacy=privacy, + permission=permission, + owner=owner, + ) + return _strip_team_caches(_get_team(conn, name, owner=owner)) + + +def remove_team(name, owner=None, profile="github"): + """ + Remove a team from the organization. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.remove_team 'team_name' + + name + Name of the team to remove. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + conn = _get_conn(profile) + gh_teams.delete(conn, _get_team(conn, name, owner=owner), owner=owner) + return True + + +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``). + + CLI Example: + + .. code-block:: bash + + salt-call github_org.list_team_repos 'team_name' + + team_name + Name of the team from which to list repos. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + 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 team repos. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + conn = _get_conn(profile) + 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, owner=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 + Name of the repository to add. + + team_name + Name of the team to add the repository to. + + permission + 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 :ghconf:`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, 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, owner=None, profile="github"): + """ + Remove a repository from a team. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.remove_team_repo 'my_repo' 'team_name' + + repo_name + Name of the repository to remove. + + team_name + Name of the team to remove the repository from. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + conn = _get_conn(profile) + gh_teams.remove_repo(conn, _get_team(conn, team_name, owner=owner), repo_name, owner=owner) + return True + + +def list_team_members(team_name, owner=None, ignore_cache=False, profile="github"): + """ + Return the logins of a team's members, in lower case. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.list_team_members 'team_name' + + team_name + Name of the team from which to list members. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + ignore_cache + Bypass the use of cached team members. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + conn = _get_conn(profile) + 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, owner=None, profile="github"): + """ + Return whether a user is a member of a team. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.is_team_member 'user_name' 'team_name' + + name + Login of the user whose membership to check. + + team_name + Name of the team to check membership in. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + return name.lower() in list_team_members(team_name, owner=owner, profile=profile) + + +def add_team_member(name, team_name, owner=None, profile="github"): + """ + Add an organization member to a team. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.add_team_member 'user_name' 'team_name' + + name + Login of the user to add. + + team_name + Name of the team to add the user to. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + conn = _get_conn(profile) + gh_teams.add_member(conn, _get_team(conn, team_name, owner=owner), name, owner=owner) + return True + + +def remove_team_member(name, team_name, owner=None, profile="github"): + """ + Remove a member from a team. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.remove_team_member 'user_name' 'team_name' + + name + Login of the user to remove. + + team_name + Name of the team to remove the user from. + + owner + Organization owning the team. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + conn = _get_conn(profile) + gh_teams.remove_member(conn, _get_team(conn, team_name, owner=owner), name, owner=owner) + return True + + +def list_rulesets(owner=None, per_page=None, fields=None, profile="github"): + """ + List all rulesets of the organization. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.list_rulesets + + owner + Organization to target. Defaults to the profile's ``owner``. + + per_page + Pagination size, maximum 100. + + 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``. + """ + conn = _get_conn(profile) + return filter_fields( + gh_rulesets.list_all(conn, "org", org_name=owner, per_page=per_page), fields + ) + + +def get_ruleset(ruleset_id, owner=None, profile="github"): + """ + Return the data of a single organization ruleset. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.get_ruleset 1234 + + 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=owner) + + +def add_ruleset(ruleset_params, owner=None, profile="github"): + """ + Create a new organization ruleset. + Returns the data of the created ruleset. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.add_ruleset '{name: test, enforcement: active}' + + 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=owner) + + +def update_ruleset(ruleset_id, ruleset_params, owner=None, profile="github"): + """ + Update an existing organization ruleset. + Returns the data of the updated ruleset. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.update_ruleset 1234 '{enforcement: disabled}' + + ruleset_id + ID of the ruleset. + + 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=owner) + + +def delete_ruleset(ruleset_id, owner=None, profile="github"): + """ + Delete an organization ruleset. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.delete_ruleset 1234 + + 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=owner) + 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..4f0b0110 --- /dev/null +++ b/src/saltext/github/modules/github_repo.py @@ -0,0 +1,1715 @@ +""" +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 + +Unlike the legacy functions in the :py:mod:`github ` module, +the functions in this module raise errors (usually subclasses of +: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, +id-addressed collections (issues, pull requests, milestones, rulesets, +deploy keys, deployment branch policies) as lists. + +.. note:: + + This module works on repositories of any owner (organization or + user): all functions accept an ``owner`` argument that defaults to + 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 + organizations and the authenticated user, a GitHub API limitation. + +:depends: PyGithub python module + +Configuration +------------- + +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 + +from salt.exceptions import CommandExecutionError + +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" +__func_alias__ = {"list_": "list"} + + +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 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 :ghconf:`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. + + 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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_info my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + ignore_cache + Bypass the use of cached repository information. + + 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, owner=owner, ignore_cache=ignore_cache) + + +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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_teams my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + 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) + 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, + has_issues=None, + has_wiki=None, + has_downloads=None, + default_branch=None, + profile="github", +): + """ + 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 + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + description + Repository description. + + homepage + URL with more information about the repository, e.g. rendered docs. + + private + Repository visibility. Changing this requires :ghconf:`allow_repo_privacy_changes` + to be enabled in the profile. + + has_issues + Enable issues for this repository. + + 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. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + 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, + owner=owner, + 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, owner=None, profile="github"): + """ + Delete a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.delete my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + return True + + +def list_issues( + repo_name=None, + owner=None, + milestone=None, + state="open", + assignee=None, + creator=None, + mentioned=None, + labels=None, + sort="created", + direction="desc", + since=None, + 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 + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + 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``. + + 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. + + 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) + 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, + owner=owner, + ) + return filter_fields(issues, fields) + + +def get_issue(number, repo_name=None, owner=None, profile="github"): + """ + Return information about a single issue in the repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_issue 514 + + number + Issue number to retrieve. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + + +def get_issue_comments(number, repo_name=None, owner=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 + Issue number for which to retrieve comments. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + 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 + timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. + + profile + 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, owner=owner) + + +def list_milestones( + repo_name=None, + owner=None, + state="open", + sort="due_on", + direction="asc", + per_page=None, + fields=None, + profile="github", +): + """ + Return the existing repository milestones. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_milestones my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + 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. + + 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) + milestones = gh_milestones.list_all( + _get_conn(profile), + repo_name, + state=state, + 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, owner=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 + Milestone number to retrieve. If provided, overrides ``name``. + + name + Title of the milestone to retrieve. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + 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: + return gh_milestones.get(_get_conn(profile), repo_name, number, owner=owner) + + 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 + raise GitHubNotFoundError(f"Milestone {name} was not found in repository {repo_name}.") + + +def list_pulls( + repo_name=None, + owner=None, + state="open", + head=None, + base=None, + sort="created", + direction="desc", + per_page=None, + fields=None, + profile="github", +): + """ + 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 + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + 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. + + 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) + pulls = gh_pulls.list_all( + _get_conn(profile), + repo_name, + state=state, + head=head, + base=base, + sort=sort, + direction=direction, + per_page=per_page, + owner=owner, + ) + return filter_fields(pulls, fields) + + +def list_rulesets(repo_name=None, owner=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 + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + per_page + Pagination size, maximum 100. + + 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) + conn = _get_conn(profile) + rulesets = gh_rulesets.list_all( + conn, + "repo", + owner=owner, + repo_name=repo_name, + per_page=per_page, + ) + return filter_fields(rulesets, fields) + + +def get_ruleset(ruleset_id, repo_name=None, owner=None, profile="github"): + """ + Return the data of a single repository ruleset. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_ruleset 1234 repo_name=my-repo + + ruleset_id + ID of the ruleset. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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=owner, repo_name=repo_name) + + +def add_ruleset(ruleset_params, repo_name=None, owner=None, profile="github"): + """ + Create a new repository ruleset. + Returns the data of the created ruleset. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.add_ruleset '{name: test, enforcement: active}' repo_name=my-repo + + ruleset_params + Ruleset definition. Must contain at least ``name`` and ``enforcement``. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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=owner, repo_name=repo_name) + + +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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.update_ruleset 1234 '{enforcement: disabled}' repo_name=my-repo + + ruleset_id + ID of the ruleset. + + ruleset_params + Updated ruleset definition. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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.update( + conn, + ruleset_id, + ruleset_params, + "repo", + owner=owner, + repo_name=repo_name, + ) + + +def delete_ruleset(ruleset_id, repo_name=None, owner=None, profile="github"): + """ + Delete a repository ruleset. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.delete_ruleset 1234 repo_name=my-repo + + ruleset_id + ID of the ruleset. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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=owner, repo_name=repo_name) + 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, owner=None, fields=None, profile="github"): + """ + List the deploy keys of a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_deploy_keys my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + 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 filter_fields( + gh_deploy_keys.list_all(_get_conn(profile), repo_name, owner=owner), fields + ) + + +def get_deploy_key(key_id, repo_name=None, owner=None, profile="github"): + """ + Return the data of a single deploy key of a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_deploy_key 12345 repo_name=my-repo + + key_id + ID of the deploy key. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + + +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. + + .. 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 + Display title of the deploy key. + + key + SSH public key. + + read_only + Deny write access to the repository for this key. Defaults to false. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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), owner=owner + ) + + +def remove_deploy_key(key_id, repo_name=None, owner=None, profile="github"): + """ + Remove a deploy key from a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.remove_deploy_key 12345 repo_name=my-repo + + key_id + ID of the deploy key. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + return True + + +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 + metadata (``created_at``, ``updated_at``). + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_actions_secrets my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + 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) + 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, 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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_actions_secret MY_SECRET repo_name=my-repo + + name + Name of the secret. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + + +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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.set_actions_secret MY_SECRET hunter2 repo_name=my-repo + + name + Name of the secret. + + value + Plaintext secret value. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + return True + + +def remove_actions_secret(name, repo_name=None, owner=None, profile="github"): + """ + Remove an Actions secret from a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.remove_actions_secret MY_SECRET repo_name=my-repo + + name + Name of the secret. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + return True + + +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`` + (``read``/``write``) and ``can_approve_pull_request_reviews``. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_workflow_permissions my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + + +def set_workflow_permissions( + default_workflow_permissions=None, + can_approve_pull_request_reviews=None, + repo_name=None, + owner=None, + profile="github", +): + """ + 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 + Default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Allow GitHub Actions to approve pull requests. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + 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, + owner=owner, + ) + return True + + +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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_environments my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + 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) + 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, owner=None, profile="github"): + """ + Return the data of a single environment of a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_environment release repo_name=my-repo + + name + Name of the environment. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + + +def set_environment( + name, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branch_policy=None, + repo_name=None, + owner=None, + profile="github", +): + """ + 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 + Name of the environment. + + wait_timer + Amount of time to delay a job after it is initially triggered, + in minutes. + + prevent_self_review + Prevent a user who created a job from approving it. + + reviewers + 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 + 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 + `. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + repo_name = _resolve_repo_name(repo_name, profile) + return gh_environments.set_( + _get_conn(profile), + repo_name, + name, + wait_timer=wait_timer, + prevent_self_review=prevent_self_review, + reviewers=reviewers, + deployment_branch_policy=deployment_branch_policy, + owner=owner, + ) + + +def remove_environment(name, repo_name=None, owner=None, profile="github"): + """ + Remove an environment from a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.remove_environment release repo_name=my-repo + + name + Name of the environment. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + return True + + +def list_deployment_branch_policies( + environment, repo_name=None, owner=None, fields=None, profile="github" +): + """ + List the deployment branch policies of an environment. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_deployment_branch_policies release repo_name=my-repo + + environment + Name of the environment. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + 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 filter_fields( + gh_environments.list_branch_policies( + _get_conn(profile), repo_name, environment, owner=owner + ), + fields, + ) + + +def add_deployment_branch_policy( + environment, name, typ="branch", repo_name=None, owner=None, profile="github" +): + """ + 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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.add_deployment_branch_policy release main repo_name=my-repo + + environment + Name of the environment. + + name + Name pattern that branches (or tags) must match in order to + deploy to the environment. + + typ + Target of this rule, either ``branch`` or ``tag``. + Defaults to ``branch``. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, typ=typ, owner=owner + ) + + +def remove_deployment_branch_policy( + environment, policy_id, repo_name=None, owner=None, profile="github" +): + """ + 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 + Name of the environment. + + policy_id + ID of the branch policy. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner + ) + return True + + +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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.list_labels my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + 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) + 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, owner=None, profile="github"): + """ + Return the data of a single label of a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_label bug repo_name=my-repo + + name + Name of the label. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + + +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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.add_label release color=00ff00 repo_name=my-repo + + name + Name of the label. + + color + Hexadecimal color code for the label, without the leading ``#``. + If unset, GitHub assigns a random one. + + description + Short description of the label. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner + ) + + +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. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.edit_label release color=00ff00 repo_name=my-repo + + name + Current name of the label. + + new_name + New name for the label. + + color + Hexadecimal color code for the label, without the leading ``#``. + + description + Short description of the label. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + 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, + owner=owner, + ) + + +def remove_label(name, repo_name=None, owner=None, profile="github"): + """ + Delete a label from a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.remove_label release repo_name=my-repo + + name + Name of the label. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + return True + + +def get_pages(repo_name=None, owner=None, profile="github"): + """ + Return information about the GitHub Pages site of a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.get_pages my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + + +def setup_pages( + build_type=None, + source_branch=None, + source_path=None, + repo_name=None, + owner=None, + profile="github", +): + """ + 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 + 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 + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + 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), + owner=owner, + ) + + +def update_pages( + build_type=None, + source_branch=None, + source_path=None, + cname=..., + https_enforced=None, + repo_name=None, + owner=None, + profile="github", +): + """ + 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 + 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``. + + cname + Custom domain for the site. Pass ``null`` explicitly to remove it, + omit to leave it unchanged. + + https_enforced + Enforce HTTPS for the site. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + 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), + cname=cname, + https_enforced=https_enforced, + owner=owner, + ) + return True + + +def remove_pages(repo_name=None, owner=None, profile="github"): + """ + Delete the GitHub Pages site of a repository. + + CLI Example: + + .. code-block:: bash + + salt-call github_repo.remove_pages my-repo + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner) + return True diff --git a/src/saltext/github/states/github.py b/src/saltext/github/states/github.py index dc4d8c9e..8a623daf 100644 --- a/src/saltext/github/states/github.py +++ b/src/saltext/github/states/github.py @@ -1,5 +1,11 @@ """ -Manage organization teams/members, repositories and rulesets. +**Deprecated**: 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 @@ -26,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: @@ -75,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: @@ -141,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. @@ -410,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 @@ -468,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. @@ -692,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 @@ -744,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 @@ -825,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 diff --git a/src/saltext/github/states/github_org.py b/src/saltext/github/states/github_org.py new file mode 100644 index 00000000..0ee223c1 --- /dev/null +++ b/src/saltext/github/states/github_org.py @@ -0,0 +1,378 @@ +""" +Manage a GitHub organization: members, teams and organization rulesets. + +.. versionadded:: 1.1.0 +""" + +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__) + +__virtualname__ = "github_org" + + +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``. + + name + GitHub login of the user. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + try: + info = __salt__["github_org.get_member"](name, profile=profile) + except GitHubNotFoundError as 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" + 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"] = {} + return ret + + +def member_absent(name, profile="github"): + """ + Ensure a user is not a member of the organization. + + name + GitHub login of the user. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + 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"] = {} + return ret + + +def _case_insensitive_diff(current, desired): + """ + 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 = {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=None, + permission=None, + members=None, + profile="github", +): + """ + 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 + Name of the team in the organization. + + description + Team description. + + repo_names + List of repository names the team should have access to. + Unlisted repositories are removed from the 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``. + + members + Login handles of team members. Unlisted members are removed from the team. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = { + "name": name, + "changes": {}, + "result": True, + "comment": f"Team {name} is present as specified", + } + + try: + 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 + + verb = "created" if target is None else "updated" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Team {name} would have been {verb}" + return ret + + if target is None: + __salt__["github_org.add_team"]( + name, repo_names=repo_names, profile=profile, **settings + ) + 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 named team does not exist. + + name + Name of the team in the organization. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + try: + __salt__["github_org.get_team"](name, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Team {name} does not exist" + return ret + + ret["changes"]["deleted"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Team {name} would have been deleted" + return ret + + __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"] = {} + return ret + + +def ruleset_present(name, ruleset_params=None, profile="github"): + """ + Ensure an organization ruleset exists 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 + Ruleset name to manage. + + ruleset_params + Ruleset definition. Must contain at least ``enforcement`` + when the ruleset needs to be created. ``name`` is pulled from + this state's ``name``. + + profile + Profile configuration name 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) + 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) + + # 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_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"] = {} + return ret + + +def ruleset_absent(name, profile="github"): + """ + Ensure a named organization ruleset does not exist. + + name + Ruleset name to manage. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + rulesets = __salt__["github_org.list_rulesets"](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" + 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"] = {} + 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..f00c7659 --- /dev/null +++ b/src/saltext/github/states/github_repo.py @@ -0,0 +1,1266 @@ +""" +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 + +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. + +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 + +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 + +log = logging.getLogger(__name__) + +__virtualname__ = "github_repo" + + +def _differs(current, desired): + """ + 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``. + """ + return current is not desired and current != desired + + +def _repo_settings_diff(ret, target, managed): + """ + Record the settings of an existing repository that need updating + in ``ret`` and return them. + """ + 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 + + +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, owner=owner, 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, owner=owner, 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, owner, profile): + """ + Create a repository and associate the requested teams. + """ + __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, owner=owner, profile=profile + ) + + +def present( + 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, + owner=None, + profile="github", +): + """ + Ensure a repository is present and configured as specified. + Unspecified settings are left unmanaged. + + name + Name of the repository. + + description + Repository description. + + homepage + URL with more information about the repository, e.g. rendered docs. + + private + Repository visibility. Changing this on an existing repository + requires :ghconf:`allow_repo_privacy_changes` to be enabled in the profile. + + 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 when creating + the repository. + + gitignore_template + Desired language or platform for a .gitignore, e.g. "Haskell". + Only applies when creating the repository. + + license_template + Desired LICENSE template to apply, e.g. "mit" or "mozilla". + Only applies when creating the repository. + + default_branch + Default branch of the repository. The branch must already exist, + so this is not applied when the repository is newly created. + + 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. 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 :ghconf:`owner`. + Repository creation is only supported for organizations and + the authenticated user. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = { + "name": name, + "changes": {}, + "result": True, + "comment": f"Repo {name} is present as specified", + } + + try: + # 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_repo.list"](owner=owner, profile=profile) + + try: + target = __salt__["github_repo.get_info"](repo_name=name, owner=owner, 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: + 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, owner, profile + ) + + if not ret["changes"]: + return ret + + 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, + owner, + profile, + ) + else: + if 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], owner=owner, profile=profile + ) + for team_name in teams_removed: + __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], owner=owner, 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 + + +def absent(name, owner=None, profile="github"): + """ + Ensure a repository is absent. + + name + Name of the repository. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + try: + __salt__["github_repo.get_info"](repo_name=name, owner=owner, profile=profile) + except GitHubNotFoundError: + ret["comment"] = f"Repo {name} does not exist" + return ret + + ret["changes"]["deleted"] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Repo {name} would have been deleted" + return ret + + __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 + ret["comment"] = str(err) + ret["changes"] = {} + return ret + + +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, + 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 + Ruleset name to manage. + + ruleset_params + Ruleset definition. Must contain at least ``enforcement`` + when the ruleset needs to be created. ``name`` is pulled from + this state's ``name``. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name 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, owner=owner, profile=profile + ) + 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_repo.add_ruleset"]( + 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, owner=owner, profile=profile + ) + + # 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, owner=owner, profile=profile + ) + ret["comment"] = f"Ruleset {name} has been updated" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + return ret + + +def ruleset_absent(name, repo_name=None, owner=None, profile="github"): + """ + Ensure a repository ruleset is absent. + + name + Ruleset name to manage. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, 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" + 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, owner=owner, profile=profile + ) + ret["comment"] = f"Ruleset {name} has been deleted" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + 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, owner=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 + Display title of the deploy key. + + key + SSH public key. + + read_only + Deny write access to the repository for this key. Defaults to false. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, owner=owner, profile=profile + ) + 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 + + verb = "added" if current is None else "replaced" + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been {verb}" + return ret + + if current is not None: + __salt__["github_repo.remove_deploy_key"]( + 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, owner=owner, profile=profile + ) + ret["comment"] = f"Deploy key {name} has been {verb}" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + return ret + + +def deploy_key_absent(name, repo_name=None, owner=None, profile="github"): + """ + Ensure no deploy key with this title is present in a repository. + + name + Display title of the deploy key. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, 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" + 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, owner=owner, profile=profile + ) + ret["comment"] = f"Deploy key {name} has been removed" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + return ret + + +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. + + 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 + Name of the secret. GitHub treats secret names + case-insensitively. + + value + 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 + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, 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: + 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: + raise CommandExecutionError(f"A value is required to set secret {name}") + + 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 + + __salt__["github_repo.set_actions_secret"]( + name, value, repo_name=repo_name, owner=owner, profile=profile + ) + ret["comment"] = f"Secret {name} has been {verb}" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + return ret + + +def actions_secret_absent(name, repo_name=None, owner=None, profile="github"): + """ + Ensure an Actions secret is absent from a repository. + + name + Name of the secret. GitHub treats secret names + case-insensitively. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`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, 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" + 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, owner=owner, profile=profile + ) + ret["comment"] = f"Secret {name} has been removed" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + return ret + + +def workflow_permissions_managed( + name, + default_workflow_permissions=None, + can_approve_pull_request_reviews=None, + repo_name=None, + owner=None, + profile="github", +): + """ + Manage the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository. + + name + Name of the repository, unless ``repo_name`` is passed. + + default_workflow_permissions + Default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Allow GitHub Actions to approve pull requests. + + repo_name + Name of the repository, if it differs from ``name``. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + repo_name = repo_name or name + + 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, owner=owner, profile=profile + ) + 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, owner=owner, profile=profile, **diff + ) + ret["comment"] = "Workflow permissions have been updated" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + 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 _environment_settings_diff(ret, current_settings, desired): + """ + Record an existing environment's settings that need updating in + ``ret`` and return whether there are any. + """ + 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 + ) + 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( + name, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branches=None, + repo_name=None, + owner=None, + profile="github", +): + """ + Ensure an environment is present in a repository and configured + as specified. Unspecified settings are left unmanaged. + + name + Name of the environment. + + wait_timer + Amount of time to delay a job after it is initially triggered, + in minutes. + + prevent_self_review + Prevent a user who created a job from approving it. + + reviewers + 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 + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = { + "name": name, + "changes": {}, + "result": True, + "comment": f"Environment {name} is configured as specified", + } + + try: + try: + current = __salt__["github_repo.get_environment"]( + name, repo_name=repo_name, owner=owner, 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) + + 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, owner=owner, 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 + + 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 + + if current is None or settings_diff: + __salt__["github_repo.set_environment"]( + 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, 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, owner=owner, 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 + + +def environment_absent(name, repo_name=None, owner=None, profile="github"): + """ + Ensure an environment is absent from a repository. + + name + Name of the environment. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + try: + __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 + + 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, owner=owner, profile=profile + ) + ret["comment"] = f"Environment {name} has been removed" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + 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, owner=None, profile="github"): + """ + Ensure a label is present in a repository and configured as + specified. Unspecified settings are left unmanaged. + + name + Name of the label. + + color + Hexadecimal color code for the label. A leading ``#`` + is accepted. + + description + Short description of the label. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + try: + current = __salt__["github_repo.get_label"]( + name, repo_name=repo_name, owner=owner, 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, + owner=owner, + profile=profile, + ) + 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 + + __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 + ret["comment"] = str(err) + ret["changes"] = {} + return ret + + +def label_absent(name, repo_name=None, owner=None, profile="github"): + """ + Ensure a label is absent from a repository. + + name + Name of the label. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + try: + __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 + + 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, owner=owner, profile=profile + ) + ret["comment"] = f"Label {name} has been removed" + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + return ret + + +def pages_present( + name, + build_type=None, + source_branch=None, + source_path=None, + cname=None, + https_enforced=None, + repo_name=None, + owner=None, + profile="github", +): + """ + Ensure GitHub Pages are enabled for a repository and configured as + specified. Unspecified settings are left unmanaged. + + name + Name of the repository, unless ``repo_name`` is passed. + + build_type + 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``. + + cname + Custom domain for the site. + + https_enforced + Enforce HTTPS for the site. + + repo_name + Name of the repository, if it differs from ``name``. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + repo_name = repo_name or name + + try: + try: + current = __salt__["github_repo.get_pages"]( + repo_name=repo_name, owner=owner, 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, + 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, owner=owner, profile=profile, **extra + ) + 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, + "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"), + } + 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, owner=owner, 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"] = {} + return ret + + +def pages_absent(name, repo_name=None, owner=None, profile="github"): + """ + Ensure GitHub Pages are disabled for a repository. + + name + Name of the repository, unless ``repo_name`` is passed. + + repo_name + Name of the repository, if it differs from ``name``. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + repo_name = repo_name or name + + try: + try: + __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 + + 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, owner=owner, 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"] = {} + 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 new file mode 100644 index 00000000..a1ee6d91 --- /dev/null +++ b/src/saltext/github/utils/gh/actions.py @@ -0,0 +1,204 @@ +""" +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, owner, repo_name, *suffix): + return ["repos", owner or conn.owner, repo_name, "actions", "secrets", *suffix] + + +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``). + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + return client.query(conn, _secrets_path(conn, owner, repo_name), per_page=100)["secrets"] + + +def get_secret(conn, repo_name, name, *, owner=None): + """ + Return the metadata of a single Actions secret. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository 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, owner, repo_name, 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``. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + return client.query(conn, _secrets_path(conn, owner, 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 + Base64-encoded public key of the repository, as returned + by :func:`get_public_key` in ``key``. + + value + 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, *, owner=None): + """ + Create or update an Actions secret. The value is encrypted to the + repository public key before leaving this host. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + name + Secret name. + + value + Plaintext secret value. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + pubkey = get_public_key(conn, repo_name, owner=owner) + client.query( + conn, + _secrets_path(conn, owner, 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, *, owner=None): + """ + Remove an Actions secret from a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + name + Secret name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + client.query(conn, _secrets_path(conn, owner, repo_name, name), method="DELETE") + + +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`` + (``read``/``write``) and ``can_approve_pull_request_reviews``. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + 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, + owner=None, +): + """ + Update the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + default_workflow_permissions + Default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + 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, + _workflow_permissions_path(conn, owner, repo_name), + 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/client.py b/src/saltext/github/utils/gh/client.py index 3a72fd37..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 - The name of the organization 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 - 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. """ - 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): """ - The PyGithub ``Organization`` object this connection is scoped to (cached). + 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=""): @@ -92,11 +143,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 +201,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 new file mode 100644 index 00000000..aa5c1818 --- /dev/null +++ b/src/saltext/github/utils/gh/deploy_keys.py @@ -0,0 +1,102 @@ +""" +Operations on GitHub repository deploy keys. +""" + +import logging + +from saltext.github.utils.gh import client + +log = logging.getLogger(__name__) + + +def _path(conn, owner, repo_name, *suffix): + return ["repos", owner or conn.owner, repo_name, "keys", *suffix] + + +def list_all(conn, repo_name, *, owner=None): + """ + Return all deploy keys of a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + return client.query(conn, _path(conn, owner, repo_name)) + + +def get(conn, repo_name, key_id, *, owner=None): + """ + Return the data of a single deploy key. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + 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, owner, repo_name, key_id)) + + +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. + + Note that deploy keys are immutable, they can only be replaced. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + title + Display title of the deploy key. + + key + SSH public key. + + 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, owner, repo_name), + method="POST", + data={"title": title, "key": key, "read_only": read_only}, + ) + + +def delete(conn, repo_name, key_id, *, owner=None): + """ + Remove a deploy key from a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + key_id + Deploy key ID. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + 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 new file mode 100644 index 00000000..dcbc83af --- /dev/null +++ b/src/saltext/github/utils/gh/environments.py @@ -0,0 +1,246 @@ +""" +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__) + + +def _path(conn, owner, repo_name, *suffix): + # Environment names can contain characters that are invalid in URL paths + return ["repos", owner or conn.owner, 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 + 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, *, owner=None): + """ + Return all environments of a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + return client.query(conn, _path(conn, owner, repo_name), per_page=100)["environments"] + + +def get(conn, repo_name, name, *, owner=None): + """ + Return the data of a single environment. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository 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, owner, repo_name, name)) + + +def set_( + conn, + repo_name, + name, + *, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branch_policy=None, + owner=None, +): + """ + Create or update an environment, replacing its whole configuration. + Unspecified settings are reset to their defaults. + Returns the data of the environment. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + name + Environment name. + + wait_timer + Amount of time to delay a job after it is initially triggered, + in minutes. + + prevent_self_review + Prevent a user who created a job from approving it. + + reviewers + 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 + 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`. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + data = filter_unset( + { + "wait_timer": wait_timer, + "prevent_self_review": prevent_self_review, + "reviewers": reviewers, + } + ) + data["deployment_branch_policy"] = deployment_branch_policy + return client.query(conn, _path(conn, owner, repo_name, name), method="PUT", data=data) + + +def delete(conn, repo_name, name, *, owner=None): + """ + Delete an environment from a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + name + Environment name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + client.query(conn, _path(conn, owner, repo_name, name), method="DELETE") + + +def list_branch_policies(conn, repo_name, environment, *, owner=None): + """ + Return all deployment branch policies of an environment. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + environment + Environment name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + return client.query( + conn, + _path(conn, owner, repo_name, environment, "deployment-branch-policies"), + per_page=100, + )["branch_policies"] + + +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. + + The environment's ``deployment_branch_policy`` must have + ``custom_branch_policies`` enabled. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + environment + Environment name. + + name + Name pattern that branches (or tags) must match in order to + deploy to the environment. + + 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, owner, repo_name, environment, "deployment-branch-policies"), + method="POST", + data={"name": name, "type": typ}, + ) + + +def delete_branch_policy(conn, repo_name, environment, policy_id, *, owner=None): + """ + Remove a deployment branch policy from an environment. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + environment + Environment name. + + policy_id + Branch policy ID. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + client.query( + conn, + _path(conn, owner, repo_name, environment, "deployment-branch-policies", policy_id), + method="DELETE", + ) 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..e4785c81 100644 --- a/src/saltext/github/utils/gh/issues.py +++ b/src/saltext/github/utils/gh/issues.py @@ -9,44 +9,52 @@ 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. 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. + + 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. 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 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,19 +72,56 @@ 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 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. + + 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``. - Other parameters as described in - :py:func:`github.get_issues `. + 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. + + owner + Owner of the repository. Defaults to the connection's owner. """ params = {} @@ -104,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. @@ -116,8 +161,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 new file mode 100644 index 00000000..6ec3137e --- /dev/null +++ b/src/saltext/github/utils/gh/labels.py @@ -0,0 +1,145 @@ +""" +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, owner, repo_name, *suffix): + # Label names can contain characters that are invalid in URL paths + return ["repos", owner or conn.owner, 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 + Hexadecimal color code to normalize. + """ + if color is None: + return None + return color.lstrip("#").lower() + + +def list_all(conn, repo_name, *, owner=None): + """ + Return all labels of a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + return client.query(conn, _path(conn, owner, repo_name)) + + +def get(conn, repo_name, name, *, owner=None): + """ + Return the data of a single label. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository 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, owner, repo_name, name)) + + +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. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + name + Label name. + + color + Hexadecimal color code for the label, without the leading ``#``. + If unset, GitHub assigns a random one. + + 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, owner, repo_name), method="POST", data=data) + + +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. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + name + Current label name. + + new_name + New name for the label. + + color + Hexadecimal color code for the label, without the leading ``#``. + + 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, owner, repo_name, name), method="PATCH", data=data) + + +def delete(conn, repo_name, name, *, owner=None): + """ + Delete a label from a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + name + Label name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + 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 new file mode 100644 index 00000000..527c6b28 --- /dev/null +++ b/src/saltext/github/utils/gh/members.py @@ -0,0 +1,157 @@ +""" +Operations on GitHub organization members. +""" + +import logging + +from github.GithubException import UnknownObjectException + +from saltext.github.utils.gh.client import api_request + +log = logging.getLogger(__name__) + + +@api_request +def list_all(conn, *, owner=None, ignore_cache=False): + """ + 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. + """ + 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, *, owner=None): + """ + Return information about a user, including their organization + membership state. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + 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. + """ + user = conn.client.get_user(login) + + ret = { + "company": user.company, + "created_at": user.created_at, + "email": user.email, + "html_url": user.html_url, + "id": user.id, + "login": user.login, + "name": user.name, + "type": user.type, + "url": user.url, + } + + org = conn.get_org(owner) + try: + membership = user.get_organization_membership(org) + except UnknownObjectException: + ret["membership_state"] = "nonexistent" + ret["in_org"] = False + return ret + + ret["in_org"] = org.has_in_members(user) + ret["membership_state"] = membership.state + + return ret + + +@api_request +def invite(conn, login, *, owner=None): + """ + Invite a user into the organization. Returns whether the resulting + membership state is ``pending``. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + 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. + """ + user = conn.client.get_user(login) + + # There is no public method that also returns the resulting membership state + _, data = conn.client.requester.requestJsonAndCheck( + "PUT", conn.get_org(owner).url + "/memberships/" + user.login + ) + + return data.get("state") == "pending" + + +@api_request +def remove(conn, login, *, owner=None): + """ + Remove a user from the organization (if they are a member). + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + 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) + + 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, *, owner=None, ignore_cache=False): + """ + Return the logins (lowercase) of all organization members without + MFA turned on (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. + """ + 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[key] diff --git a/src/saltext/github/utils/gh/milestones.py b/src/saltext/github/utils/gh/milestones.py index eeebafdd..52ca12d6 100644 --- a/src/saltext/github/utils/gh/milestones.py +++ b/src/saltext/github/utils/gh/milestones.py @@ -9,20 +9,23 @@ 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. 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. + + 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,19 +36,34 @@ 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 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. + + 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. - Other parameters as described in - :py:func:`github.get_milestones `. + owner + Owner of the repository. Defaults to the connection's owner. """ params = {} @@ -60,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): @@ -68,7 +88,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 new file mode 100644 index 00000000..0554bbbd --- /dev/null +++ b/src/saltext/github/utils/gh/pages.py @@ -0,0 +1,132 @@ +""" +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, owner, repo_name): + return ["repos", owner or conn.owner, 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 + Repository branch to publish from. + + path + Repository directory to publish from, either ``/`` or ``/docs``. + """ + source = filter_unset({"branch": branch, "path": path}) + return source or None + + +def get(conn, repo_name, *, owner=None): + """ + Return information about the GitHub Pages site of a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + 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, owner, repo_name)) + + +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. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + build_type + 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``. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + return client.query( + conn, + _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, owner=None +): + """ + Update the GitHub Pages site configuration of a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + build_type + 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 + Custom domain for the site. Pass ``None`` explicitly + to remove it. + + 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, owner, repo_name), method="PUT", data=data) + + +def delete(conn, repo_name, *, owner=None): + """ + Delete the GitHub Pages site of a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + repo_name + Repository name. + + owner + Owner of the repository. Defaults to the connection's owner. + """ + 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 new file mode 100644 index 00000000..437c14a3 --- /dev/null +++ b/src/saltext/github/utils/gh/profile.py @@ -0,0 +1,126 @@ +""" +Resolution of Salt profile configuration into GitHub connections, +shared between the ``github``, ``github_org`` and ``github_repo`` +execution modules. +""" + +import hashlib +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 + Profile name that contains configuration information. + + config_name + Configuration item name to look up. + + __salt__ + Pass ``__salt__``. + + __context__ + Pass ``__context__``. + """ + 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 + Profile name that contains configuration information. + + __salt__ + Pass ``__salt__``. + + __context__ + Pass ``__context__``. + """ + try: + return get_config_value( + profile, "api_url", __salt__=__salt__, __context__=__context__ + ).rstrip("/") + except CommandExecutionError: + return API_URL_DEFAULT + + +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. + + profile + Profile name that contains configuration information. + + salt + Pass ``__salt__``. + + context + Pass ``__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}:{owner}:{api_url}" + + 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 47d87d4c..35997e76 100644 --- a/src/saltext/github/utils/gh/pulls.py +++ b/src/saltext/github/utils/gh/pulls.py @@ -19,19 +19,43 @@ 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 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. + + owner + Owner of the repository. Defaults to the connection's owner. """ params = {} @@ -51,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_): @@ -59,8 +83,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 d44a2b5a..c404c320 100644 --- a/src/saltext/github/utils/gh/repos.py +++ b/src/saltext/github/utils/gh/repos.py @@ -1,8 +1,10 @@ """ -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 +import time from github.GithubException import UnknownObjectException from salt.exceptions import CommandExecutionError @@ -16,7 +18,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 +34,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 @@ -44,37 +45,55 @@ 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 - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :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). 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. + + owner + Owner of the repository. Defaults to the connection's owner. ignore_cache Bypass the cached repository information. @@ -82,18 +101,18 @@ def get_info(conn, name, ignore_cache=False): 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,27 +120,30 @@ 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. 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. + + 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,16 +166,45 @@ def create( license_template=None, ): """ - Create a new repository in the organization. + Create a new repository 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. + + 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. + + homepage + URL with more information about the repository, e.g. rendered docs. - Other parameters as described in - :py:func:`github.add_repo `. + 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( { @@ -167,7 +219,37 @@ def create( "license_template": license_template, } ) - 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. 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: + probe(conn, name, owner=owner) + 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", owner.lower(), repo.name.lower())] = info + if ("repos", owner.lower()) in conn.cache: + conn.cache[("repos", owner.lower())].append(repo) + return info @api_request @@ -175,36 +257,60 @@ def update( conn, name, *, + owner=None, description=None, homepage=None, private=None, has_issues=None, has_wiki=None, has_downloads=None, + default_branch=None, allow_privacy_change=False, ): """ - Update an existing repository in the organization. + Update an existing repository. 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. + owner + Owner of the repository. Defaults to the connection's owner. + + description + Repository description. + + homepage + URL with more information about the repository, e.g. rendered docs. - Other parameters as described in - :py:func:`github.edit_repo `. + private + Repository visibility. Changing this requires :ghconf:`allow_repo_privacy_changes` + to be enabled in the profile. + + has_issues + Enable issues for this repository. + + 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) + owner = owner or conn.owner + repo = conn.client.get_repo(f"{owner}/{name}") parameters = filter_unset( { "description": description, @@ -213,23 +319,30 @@ def update( "has_issues": has_issues, "has_wiki": has_wiki, "has_downloads": has_downloads, + "default_branch": default_branch, } ) repo.edit(name, **parameters) - 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 - 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. + + owner + Owner of the repository. Defaults to the connection's owner. """ - repo = conn.org.get_repo(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 07a27373..da440b12 100644 --- a/src/saltext/github/utils/gh/rulesets.py +++ b/src/saltext/github/utils/gh/rulesets.py @@ -16,19 +16,21 @@ 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 + (the calling functions default it to the connection's owner). 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 (the calling functions + default it to the connection's owner). 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 +49,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,12 +69,17 @@ 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. """ - 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) @@ -81,16 +88,20 @@ 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. """ 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) @@ -102,17 +113,21 @@ 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. """ 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) @@ -131,20 +146,24 @@ 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. """ 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) @@ -156,15 +175,19 @@ 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. """ 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 757569d1..f3a40c47 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 @@ -17,61 +15,85 @@ @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 - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + :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. teams[team.name] = { "id": team.id, + "name": team.name, "slug": team.slug, "description": team.description, "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. 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. + + 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. 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. + + privacy + Privacy level for the team, either ``secret`` or ``closed``. - Other parameters as described in - :py:func:`github.add_team `. + 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( { @@ -80,34 +102,44 @@ 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. 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``. + + 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( @@ -118,35 +150,42 @@ def update(conn, team_info, name, *, description=None, privacy=None, permission= } ), ) + 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. 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`. + + 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). 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`. + + owner + Organization owning the team. Defaults to the connection's owner. ignore_cache Bypass the cached team repositories. @@ -154,7 +193,7 @@ def list_repos(conn, team_info, ignore_cache=False): 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" @@ -163,72 +202,83 @@ 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 @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. 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. + + 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. 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. + + 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) - 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 -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). 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`. + + owner + Organization owning the team. Defaults to the connection's owner. ignore_cache Bypass the cached team members. @@ -236,54 +286,56 @@ def list_members(conn, team_info, ignore_cache=False): 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. 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. + + 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. 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. + + 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) - 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/src/saltext/github/utils/gh/users.py b/src/saltext/github/utils/gh/users.py deleted file mode 100644 index c1e79ef2..00000000 --- a/src/saltext/github/utils/gh/users.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Operations on GitHub organization members. -""" - -import logging - -from github.GithubException import UnknownObjectException - -from saltext.github.utils.gh.client import api_request - -log = logging.getLogger(__name__) - - -@api_request -def list_members(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. - - 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"] - - -@api_request -def get_user_info(conn, login): - """ - Return information about a user, including their organization - membership state. - - conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. - - login - The login of the user to look up. - - Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` - if the user does not exist. - """ - user = conn.client.get_user(login) - - ret = { - "company": user.company, - "created_at": user.created_at, - "email": user.email, - "html_url": user.html_url, - "id": user.id, - "login": user.login, - "name": user.name, - "type": user.type, - "url": user.url, - } - - try: - membership = user.get_organization_membership(conn.org) - except UnknownObjectException: - ret["membership_state"] = "nonexistent" - ret["in_org"] = False - return ret - - ret["in_org"] = conn.org.has_in_members(user) - ret["membership_state"] = membership.state - - return ret - - -@api_request -def invite_member(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. - - login - The login of the user to invite. - - Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` - if the user does not exist. - """ - user = conn.client.get_user(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 - ) - - return data.get("state") == "pending" - - -@api_request -def remove_member(conn, login): - """ - Remove a user from the organization. Returns whether the user is no - longer a member afterwards. - - conn - The :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. - - login - The login of the user to remove. - - 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) - - return not conn.org.has_in_members(user) - - -@api_request -def list_members_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. - - 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") - ] - return conn.cache["members_without_mfa"] diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 8c07249d..5cd947cb 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__) @@ -191,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): """ @@ -212,7 +222,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 +230,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" ) @@ -227,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/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"] 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/__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..5fa1713c --- /dev/null +++ b/tests/functional/modules/github_org/test_members.py @@ -0,0 +1,62 @@ +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") 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_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..f62cd537 --- /dev/null +++ b/tests/functional/modules/github_org/test_teams.py @@ -0,0 +1,189 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_teams(github_org): + ret = github_org.list_teams() + assert set(ret) == {"Team A"} + assert ret["Team A"]["slug"] == "team-a" + + +def test_list_teams_uses_cache(github_org, fake_org): + 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() + 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["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): + 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"])["slug"] == "team-b" + 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): + 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): + 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 list(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 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 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" + 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 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_repo/test_actions_secrets.py b/tests/functional/modules/github_repo/test_actions_secrets.py new file mode 100644 index 00000000..604be2b3 --- /dev/null +++ b/tests/functional/modules/github_repo/test_actions_secrets.py @@ -0,0 +1,50 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_actions_secrets(github_repo): + ret = github_repo.list_actions_secrets() + assert list(ret) == ["EXISTING_SECRET"] + # Secret values are write-only + assert "value" not in ret["EXISTING_SECRET"] + + +def test_get_actions_secret(github_repo): + # Secret names are case-insensitive + 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_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.get_actions_secret("GHOST") + + +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_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_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_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_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.remove_actions_secret("GHOST") diff --git a/tests/functional/modules/github_repo/test_deploy_keys.py b/tests/functional/modules/github_repo/test_deploy_keys.py new file mode 100644 index 00000000..5215385f --- /dev/null +++ b/tests/functional/modules/github_repo/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_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_repo): + assert github_repo.list_deploy_keys(repo_name="repo-b") == [] + + +def test_get_deploy_key(github_repo, fake_org): + existing = next(iter(fake_org.get_repo("repo-a").deploy_keys)) + 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_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.get_deploy_key(1234) + + +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") + 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_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_repo, fake_org): + existing = next(iter(fake_org.get_repo("repo-a").deploy_keys)) + 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_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.remove_deploy_key(1234) diff --git a/tests/functional/modules/github_repo/test_environments.py b/tests/functional/modules/github_repo/test_environments.py new file mode 100644 index 00000000..82c5532d --- /dev/null +++ b/tests/functional/modules/github_repo/test_environments.py @@ -0,0 +1,71 @@ +import pytest +from salt.exceptions import CommandExecutionError + +CUSTOM_POLICY = {"protected_branches": False, "custom_branch_policies": True} + + +def test_list_environments(github_repo): + ret = github_repo.list_environments() + assert list(ret) == ["release"] + assert ret["release"]["deployment_branch_policy"] == CUSTOM_POLICY + + +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_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.get_environment("ghost") + + +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_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 is None + + +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_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_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.remove_environment("ghost") + + +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_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_repo, fake_org): + existing = next(iter(fake_org.get_repo("repo-a").get_environment("release").branch_policies)) + 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..ae35cc97 --- /dev/null +++ b/tests/functional/modules/github_repo/test_issues.py @@ -0,0 +1,53 @@ +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]["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): + ret = github_repo.list_issues(state="all") + assert {issue["title"] for issue in ret} == {"Found a bug", "Stale issue"} + + +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["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 {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 new file mode 100644 index 00000000..851cca6c --- /dev/null +++ b/tests/functional/modules/github_repo/test_labels.py @@ -0,0 +1,63 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_list_labels(github_repo): + ret = github_repo.list_labels() + 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): + ret = github_repo.get_label("bug") + assert ret["color"] == "d73a4a" + assert ret["description"] == "Something is broken" + + +def test_get_label_missing(github_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_repo.get_label("ghost") + + +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_repo.get_label("prio: high") + assert ret["name"] == "prio: high" + + +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 + assert label.color == "00ff00" + assert label.description == "Release automation" + + +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_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_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_repo): + with pytest.raises(CommandExecutionError, match="Not Found"): + 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..c0273c2d --- /dev/null +++ b/tests/functional/modules/github_repo/test_milestones.py @@ -0,0 +1,33 @@ +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"] + 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_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_pages.py b/tests/functional/modules/github_repo/test_pages.py new file mode 100644 index 00000000..bbda08c8 --- /dev/null +++ b/tests/functional/modules/github_repo/test_pages.py @@ -0,0 +1,63 @@ +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_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") + + +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..c32c3de4 --- /dev/null +++ b/tests/functional/modules/github_repo/test_pulls.py @@ -0,0 +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"]["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 new file mode 100644 index 00000000..53fa7776 --- /dev/null +++ b/tests/functional/modules/github_repo/test_repo.py @@ -0,0 +1,123 @@ +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 list(ret) == ["Team A"] + assert ret["Team A"]["permission"] == "pull" + + +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") + + +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/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_repo/test_workflow_permissions.py b/tests/functional/modules/github_repo/test_workflow_permissions.py new file mode 100644 index 00000000..1c5b6cba --- /dev/null +++ b/tests/functional/modules/github_repo/test_workflow_permissions.py @@ -0,0 +1,26 @@ +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_repo, fake_org): + assert ( + github_repo.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_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" + assert repo.data.can_approve_pull_request_reviews is True 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..b75758a7 --- /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 == {"invited": "newuser"} + 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 == {"removed": "user2"} + 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..44b8840f --- /dev/null +++ b/tests/functional/states/github_org/test_ruleset.py @@ -0,0 +1,97 @@ +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"}, "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 == {"created": "new-rules"} + 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 "422" 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 "422" 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 == {"deleted": "org-rules"} + 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..c89776bc --- /dev/null +++ b/tests/functional/states/github_org/test_team.py @@ -0,0 +1,167 @@ +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 ret.changes["created"] == "team-b" + # member additions are projected even for new teams + assert ret.changes["members_added"] == ["user1"] + if testmode: + assert "would have been created" in ret.comment + assert "team-b" not in fake_org.teams + else: + 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_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( + 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 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 "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", 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 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" + assert team.privacy == "closed" + else: + assert team.permission == "push" + assert team.privacy == "secret" + + +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", 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 ret.changes["repos_added"] == ["repo-b"] + assert ret.changes["repos_removed"] == ["repo-a"] + team = fake_org.get_team("team-a") + if testmode: + assert "would have been updated" 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_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", repo_names=["ghost"]) + assert ret.result is False + 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", members=["user1", "ghost"]) + assert ret.result is False + assert not ret.changes + assert "Not Found" in ret.comment + + +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 == {"deleted": "Team A"} + 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_repo/test_actions_secret.py b/tests/functional/states/github_repo/test_actions_secret.py new file mode 100644 index 00000000..162429a4 --- /dev/null +++ b/tests/functional/states/github_repo/test_actions_secret.py @@ -0,0 +1,75 @@ +def test_actions_secret_present_create(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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 == {"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 + 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_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_repo, fake_github, fake_org, testmode): + # Secret values are write-only, so an existing secret counts as present + 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 + 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_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.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 == {"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" + assert fake_org.get_repo("repo-a").get_secret("EXISTING_SECRET").value == expected + + +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_repo): + ret = github_repo.actions_secret_present(name="NEW_SECRET", value="s3cr3t", repo_name="ghost") + assert ret.result is False + assert "Not Found" in ret.comment + + +def test_actions_secret_absent(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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 == {"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: + 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_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_repo/test_deploy_key.py b/tests/functional/states/github_repo/test_deploy_key.py new file mode 100644 index 00000000..78cf5c5d --- /dev/null +++ b/tests/functional/states/github_repo/test_deploy_key.py @@ -0,0 +1,99 @@ +from tests.support.github import DEPLOY_KEY_A +from tests.support.github import DEPLOY_KEY_B + + +def test_deploy_key_present_create(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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 == {"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"] + assert bool(added) is not testmode + if not testmode: + assert added[0].key == DEPLOY_KEY_B + + +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 + assert not ret.changes + assert not fake_github.mutations() + + +def test_deploy_key_present_ignores_key_comment(github_repo, fake_github, testmode): + key = " ".join(DEPLOY_KEY_A.split()[:2]) + " another-comment" + 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_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.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_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_repo, fake_github, fake_org, testmode +): + before = len(fake_github.mutations()) + 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 + 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_repo): + ret = github_repo.deploy_key_present(name="autorelease", key=DEPLOY_KEY_B, repo_name="ghost") + assert ret.result is False + assert "Not Found" in ret.comment + + +def test_deploy_key_absent(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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 == {"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: + 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_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_repo/test_environment.py b/tests/functional/states/github_repo/test_environment.py new file mode 100644 index 00000000..ceedb7c9 --- /dev/null +++ b/tests/functional/states/github_repo/test_environment.py @@ -0,0 +1,150 @@ +CUSTOM_POLICY = {"protected_branches": False, "custom_branch_policies": True} +PROTECTED_POLICY = {"protected_branches": True, "custom_branch_policies": False} + + +def test_environment_present_create(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.environment_present(name="staging", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + 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 + + +def test_environment_present_create_with_branches(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.environment_present( + name="staging", deployment_branches=["main", "staging/*"], test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + 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 + 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_repo.environment_present( + name="staging", deployment_branches=["main", "staging/*"] + ) + assert rerun.result is True + assert not rerun.changes + + +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_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.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": {"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/*"} + assert {policy.name for policy in env.branch_policies} == expected + + +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 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 + + +def test_environment_present_wait_timer_drift_keeps_policy( + github_repo, fake_github, fake_org, testmode +): + before = len(fake_github.mutations()) + 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 == {"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: + 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_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["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 + + +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["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 + + +def test_environment_present_reviewers(github_repo, fake_org, testmode): + reviewers = [{"type": "User", "id": 42}] + 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["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) + assert rerun.result is True + assert not rerun.changes + + +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_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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 == {"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: + rerun = github_repo.environment_absent(name="release") + assert rerun.result is True + assert not rerun.changes + + +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_repo/test_label.py b/tests/functional/states/github_repo/test_label.py new file mode 100644 index 00000000..94b7ccbc --- /dev/null +++ b/tests/functional/states/github_repo/test_label.py @@ -0,0 +1,91 @@ +def test_label_present_create(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.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 == {"created": "release"} + 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_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_repo, fake_github, testmode): + # Color comparison ignores case and a leading # + ret = github_repo.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_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_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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"}} + 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_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"} + 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_repo): + # A missing repo is indistinguishable from a missing label (404), + # so the failure surfaces during the creation attempt + ret = github_repo.label_present(name="bug", repo_name="ghost") + assert ret.result is False + assert "Not Found" in ret.comment + + +def test_label_absent(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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 == {"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: + rerun = github_repo.label_absent(name="bug") + assert rerun.result is True + assert not rerun.changes + + +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_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/functional/states/github_repo/test_pages.py b/tests/functional/states/github_repo/test_pages.py new file mode 100644 index 00000000..c3657759 --- /dev/null +++ b/tests/functional/states/github_repo/test_pages.py @@ -0,0 +1,96 @@ +def test_pages_present_enable(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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 == {"created": "repo-a"} + 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_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_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 + if not testmode: + assert fake_org.get_repo("repo-a").pages.https_enforced is True + + +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 + assert not ret.changes + assert not fake_github.mutations() + + +def test_pages_present_build_type_drift(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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 == { + "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_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"} + expected = "gh-pages" if testmode else "docs" + 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 "Not Found" in ret.comment + + +def test_pages_absent(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + 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 == {"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: + rerun = github_repo.pages_absent(name="repo-b") + assert rerun.result is True + assert not rerun.changes + + +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..1e5138ee --- /dev/null +++ b/tests/functional/states/github_repo/test_repo.py @@ -0,0 +1,155 @@ +import pytest + +from tests.support.github import NotFound + + +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 ret.changes == {"created": "new-repo"} + 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 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: + 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 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 + + +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, 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 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, 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 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 + 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 ret.changes == {"teams_added": ["Team A"]} + 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 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 + + +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 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 + + +def test_present_missing_team(github_repo): + ret = github_repo.present(name="repo-b", teams={"Ghost": "pull"}) + assert ret.result is False + assert not ret.changes + assert "does not exist" 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 == {"deleted": "repo-a"} + 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..36dc6c68 --- /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 == {"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 + + +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 "Not Found" 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 "422" 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 == {"deleted": "repo-rules"} + 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 "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 new file mode 100644 index 00000000..1332fc82 --- /dev/null +++ b/tests/functional/states/github_repo/test_workflow_permissions.py @@ -0,0 +1,76 @@ +def test_workflow_permissions_managed(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.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_repo.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_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 + 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_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 + assert not ret.changes + assert not fake_github.mutations() + + +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", + 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_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_repo): + ret = github_repo.workflow_permissions_managed( + name="ghost", default_workflow_permissions="read" + ) + assert ret.result is False + assert "Not Found" 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..7f447e47 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: """ @@ -31,12 +42,18 @@ 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 = [] 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): @@ -77,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 @@ -211,7 +228,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..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,12 +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) + 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 @@ -375,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): @@ -468,9 +487,14 @@ 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__() + # 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}" @@ -512,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) @@ -533,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, @@ -601,6 +619,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"] = { @@ -621,10 +743,13 @@ def payload(self, /, **ovrr): "watchers_count": 1, "watchers": 1, "forks": 0, + "forks_count": 0, "open_issues": 0, "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 +1057,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..502c6675 --- /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..d6f1d10b --- /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..7b390686 --- /dev/null +++ b/tests/support/github/routes/environments.py @@ -0,0 +1,70 @@ +""" +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/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 new file mode 100644 index 00000000..db6a2ddf --- /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/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 new file mode 100644 index 00000000..70d09fce --- /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/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 71b970e8..b024f6ab 100644 --- a/tests/support/github/state.py +++ b/tests/support/github/state.py @@ -9,11 +9,18 @@ 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 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 +30,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 +39,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,55 +70,34 @@ 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() + 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._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._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) + 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) + 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. @@ -152,90 +142,87 @@ 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): - if isinstance(entity, Entity): - data = entity.data + 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, 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, 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"), +} 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()