From 185b9178a6461136391888124f94fbee22ab6e9a Mon Sep 17 00:00:00 2001 From: jeanluc Date: Wed, 14 Oct 2026 20:58:03 +0200 Subject: [PATCH] Add generic API functions, split org/user info --- changelog/+account_info.added.md | 1 + changelog/+org_info.added.md | 1 + changelog/+query.added.md | 1 + changelog/+rate_limit.added.md | 1 + changelog/+user_info.added.md | 1 + changelog/+user_keys.added.md | 1 + docs/ref/modules/index.rst | 1 + .../saltext.github.modules.github_user.rst | 5 + docs/ref/utils/index.rst | 1 + .../utils/saltext.github.utils.gh.users.rst | 5 + src/saltext/github/modules/github.py | 124 ++++++++++++++++-- src/saltext/github/modules/github_org.py | 25 ++++ src/saltext/github/modules/github_repo.py | 4 +- src/saltext/github/modules/github_user.py | 75 +++++++++++ src/saltext/github/utils/gh/client.py | 12 ++ src/saltext/github/utils/gh/users.py | 87 ++++++++++++ tests/functional/conftest.py | 4 +- .../modules/github/test_accounts.py | 41 ++++++ tests/functional/modules/github/test_meta.py | 9 ++ tests/functional/modules/github/test_query.py | 36 +++++ .../functional/modules/github_org/test_org.py | 22 ++++ .../modules/github_repo/test_owner.py | 22 ++-- .../modules/github_repo/test_repo.py | 20 +-- .../modules/github_user/__init__.py | 0 .../modules/github_user/conftest.py | 6 + .../modules/github_user/test_info.py | 26 ++++ .../modules/github_user/test_keys.py | 25 ++++ tests/support/github/__init__.py | 6 + tests/support/github/base.py | 4 + tests/support/github/entities.py | 6 + tests/support/github/routes/__init__.py | 2 + tests/support/github/routes/meta.py | 22 ++++ tests/support/github/routes/orgs.py | 4 + 33 files changed, 566 insertions(+), 34 deletions(-) create mode 100644 changelog/+account_info.added.md create mode 100644 changelog/+org_info.added.md create mode 100644 changelog/+query.added.md create mode 100644 changelog/+rate_limit.added.md create mode 100644 changelog/+user_info.added.md create mode 100644 changelog/+user_keys.added.md create mode 100644 docs/ref/modules/saltext.github.modules.github_user.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.users.rst create mode 100644 src/saltext/github/modules/github_user.py create mode 100644 src/saltext/github/utils/gh/users.py create mode 100644 tests/functional/modules/github/test_accounts.py create mode 100644 tests/functional/modules/github/test_meta.py create mode 100644 tests/functional/modules/github/test_query.py create mode 100644 tests/functional/modules/github_org/test_org.py create mode 100644 tests/functional/modules/github_user/__init__.py create mode 100644 tests/functional/modules/github_user/conftest.py create mode 100644 tests/functional/modules/github_user/test_info.py create mode 100644 tests/functional/modules/github_user/test_keys.py create mode 100644 tests/support/github/routes/meta.py diff --git a/changelog/+account_info.added.md b/changelog/+account_info.added.md new file mode 100644 index 00000000..e7aed34d --- /dev/null +++ b/changelog/+account_info.added.md @@ -0,0 +1 @@ +Added {py:func}`github.account_info ` for looking up the public profile of any user or organization account, defaulting to the profile's owner diff --git a/changelog/+org_info.added.md b/changelog/+org_info.added.md new file mode 100644 index 00000000..5251e3eb --- /dev/null +++ b/changelog/+org_info.added.md @@ -0,0 +1 @@ +Added {py:func}`github_org.info ` for looking up the public profile of an organization account, defaulting to the profile's owner and failing on users diff --git a/changelog/+query.added.md b/changelog/+query.added.md new file mode 100644 index 00000000..ce109560 --- /dev/null +++ b/changelog/+query.added.md @@ -0,0 +1 @@ +Added `github.query` for making direct calls to arbitrary GitHub API endpoints, transparently following pagination diff --git a/changelog/+rate_limit.added.md b/changelog/+rate_limit.added.md new file mode 100644 index 00000000..112e8760 --- /dev/null +++ b/changelog/+rate_limit.added.md @@ -0,0 +1 @@ +Added `github.rate_limit` for querying the API rate limit status of the profile's token diff --git a/changelog/+user_info.added.md b/changelog/+user_info.added.md new file mode 100644 index 00000000..a73a5966 --- /dev/null +++ b/changelog/+user_info.added.md @@ -0,0 +1 @@ +Added {py:func}`github_user.info ` for looking up the public profile of a user account, defaulting to the token's user and failing on organizations diff --git a/changelog/+user_keys.added.md b/changelog/+user_keys.added.md new file mode 100644 index 00000000..39bb36d4 --- /dev/null +++ b/changelog/+user_keys.added.md @@ -0,0 +1 @@ +Added `github_user.list_keys` for listing the verified public SSH keys of any user, defaulting to the token's user, e.g. for use with the `ssh_auth` state module diff --git a/docs/ref/modules/index.rst b/docs/ref/modules/index.rst index 88999e27..8bc1deeb 100644 --- a/docs/ref/modules/index.rst +++ b/docs/ref/modules/index.rst @@ -12,3 +12,4 @@ _________________ github github_org github_repo + github_user diff --git a/docs/ref/modules/saltext.github.modules.github_user.rst b/docs/ref/modules/saltext.github.modules.github_user.rst new file mode 100644 index 00000000..f43e1d77 --- /dev/null +++ b/docs/ref/modules/saltext.github.modules.github_user.rst @@ -0,0 +1,5 @@ +``github_user`` +=============== + +.. automodule:: saltext.github.modules.github_user + :members: diff --git a/docs/ref/utils/index.rst b/docs/ref/utils/index.rst index dbc58f5c..9e34b946 100644 --- a/docs/ref/utils/index.rst +++ b/docs/ref/utils/index.rst @@ -35,3 +35,4 @@ _________ utils.gh.statecores.repo utils.gh.statecores.rulesets utils.gh.teams + utils.gh.users diff --git a/docs/ref/utils/saltext.github.utils.gh.users.rst b/docs/ref/utils/saltext.github.utils.gh.users.rst new file mode 100644 index 00000000..6789a159 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.users.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.users +============================= + +.. automodule:: saltext.github.utils.gh.users + :members: diff --git a/src/saltext/github/modules/github.py b/src/saltext/github/modules/github.py index 97d7082a..ac9723eb 100644 --- a/src/saltext/github/modules/github.py +++ b/src/saltext/github/modules/github.py @@ -1,7 +1,9 @@ """ General module for interacting with the GitHub v3 API. -**Currently only contains deprecated (legacy) functions**. +Contains functionality that is not tied to a single organization or +repository, e.g. generic account lookups, the token's API rate limit +status or direct queries against arbitrary API endpoints. :depends: PyGithub python module @@ -11,9 +13,9 @@ 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. + instead of logging and returning ``False``. The non-deprecated + functions in this module behave like the scoped modules in this + regard. Configuration ------------- @@ -30,6 +32,7 @@ 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 @@ -38,6 +41,7 @@ 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 @@ -59,10 +63,6 @@ def __virtual__(): ) -# 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_conn(profile): """ Return the GitHub connection, cached into __context__ for performance @@ -96,6 +96,108 @@ def _get_owner(profile): return gh_profile.get_owner(profile, opts=__opts__, context=__context__) +def query( + path, params=None, method="GET", headers=None, data=None, per_page=None, profile="github" +): + """ + Make a direct call to the GitHub API, transparently following + pagination. Useful for endpoints without a dedicated wrapper function. + + .. versionadded:: 1.1.0 + + CLI Example: + + .. code-block:: bash + + salt-call github.query repos/saltstack/salt + salt-call github.query orgs/my-org/members per_page=100 + salt-call github.query repos/my-org/my-repo/labels method=POST data='{name: bug}' + + path + URL path to query, either as a string or a list of segments. + + params + Mapping of query parameters. + + method + HTTP method to use. Defaults to ``GET``. + + headers + Mapping of extra headers to send. + + data + Request body, either as a mapping or a JSON string. + + per_page + Pagination size, maximum 100. + + profile + Profile configuration name to use. Defaults to ``github``. + + Returns the decoded response data. List results are merged across pages. + Raises :py:class:`CommandExecutionError ` + subclasses on error responses. + """ + if isinstance(path, str): + path = path.strip("/") + return _get_conn(profile).query( + path, params=params, method=method, headers=headers, data=data, per_page=per_page + ) + + +def rate_limit(profile="github"): + """ + Return the API rate limit status of the profile's token, keyed by + resource (``core``, ``search``, ...). + + .. versionadded:: 1.1.0 + + CLI Example: + + .. code-block:: bash + + salt-call github.rate_limit + + profile + Profile configuration name to use. Defaults to ``github``. + """ + return gh_client.rate_limit(_get_conn(profile)) + + +def account_info(name=None, profile="github"): + """ + Return the public profile of any user or organization account + (distinguished via the returned ``type`` field). + + The scoped variants of this function — :py:func:`github_user.info + ` and :py:func:`github_org.info + ` — **fail** when the account + is not of the corresponding type. + + .. versionadded:: 1.1.0 + + CLI Example: + + .. code-block:: bash + + salt-call github.account_info github-handle + salt-call github.account_info # the profile's owner + + name + Login of the account to look up. Defaults to the profile's + :ghconf:`owner` (which itself defaults to the token's user). + + profile + Profile configuration name to use. Defaults to ``github``. + """ + return gh_users.get_info(_get_conn(profile), name) + + +########################################################################################### +# Anything below here is deprecated. Replacement functions are in github_org/github_repo. # +########################################################################################### + + def get_user(name, profile="github", user_details=False): """ Get a GitHub user by name. @@ -104,7 +206,9 @@ def get_user(name, profile="github", user_details=False): 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. + for the same functionality, or :py:func:`github.account_info + ` for looking up arbitrary + accounts. name The user for which to obtain information. @@ -560,7 +664,7 @@ 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. + Use :py:func:`github_repo.info ` instead. repo_name The name of the repository. diff --git a/src/saltext/github/modules/github_org.py b/src/saltext/github/modules/github_org.py index 78dbca1f..a7039e50 100644 --- a/src/saltext/github/modules/github_org.py +++ b/src/saltext/github/modules/github_org.py @@ -38,6 +38,7 @@ 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 import users as gh_users log = logging.getLogger(__name__) @@ -48,6 +49,30 @@ def _get_conn(profile): return gh_profile.get_conn(profile, opts=__opts__, context=__context__) +def info(name=None, profile="github"): + """ + Return the public profile of an organization account. Fails when the + account is a user. Use :py:func:`github.account_info + ` to look up accounts of + either type. + + CLI Example: + + .. code-block:: bash + + salt-call github_org.info github-org + salt-call github_org.info # the profile's owner + + name + Login of the organization to look up. Defaults to the organization + specified in the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + return gh_users.get_info(_get_conn(profile), name, expect="Organization") + + def list_members(owner=None, ignore_cache=False, profile="github"): """ List the logins of all members of the organization. diff --git a/src/saltext/github/modules/github_repo.py b/src/saltext/github/modules/github_repo.py index 4ba38451..176013fd 100644 --- a/src/saltext/github/modules/github_repo.py +++ b/src/saltext/github/modules/github_repo.py @@ -194,7 +194,7 @@ def create( ) -def get_info(repo_name=None, owner=None, ignore_cache=False, profile="github"): +def info(repo_name=None, owner=None, ignore_cache=False, profile="github"): """ Return information about a repository. @@ -202,7 +202,7 @@ def get_info(repo_name=None, owner=None, ignore_cache=False, profile="github"): .. code-block:: bash - salt-call github_repo.get_info my-repo + salt-call github_repo.info my-repo repo_name Name of the repository. Defaults to the profile's :ghconf:`repo_name`. diff --git a/src/saltext/github/modules/github_user.py b/src/saltext/github/modules/github_user.py new file mode 100644 index 00000000..1e7f23b8 --- /dev/null +++ b/src/saltext/github/modules/github_user.py @@ -0,0 +1,75 @@ +""" +Look up information about GitHub **user** accounts. + +.. versionadded:: 1.1.0 + +:depends: PyGithub python module + +Configuration +------------- + +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 + +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh import users as gh_users + +log = logging.getLogger(__name__) + +__virtualname__ = "github_user" + + +def _get_conn(profile): + return gh_profile.get_conn(profile, opts=__opts__, context=__context__) + + +def info(name=None, profile="github"): + """ + Return the public profile of a user account. Fails when the account + is an organization. Use :py:func:`github.account_info + ` to look up accounts of + either type (note that it defaults to the profile's owner instead of + the token's user). + + CLI Example: + + .. code-block:: bash + + salt-call github_user.info github-handle + salt-call github_user.info # the token's user + + name + Login of the user to look up. Defaults to the user the + profile's token belongs to. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + return gh_users.get_info(_get_conn(profile), name, expect="User") + + +def list_keys(name=None, profile="github"): + """ + Return the verified public SSH keys of any user as a list of + mappings with ``id`` and ``key`` fields, e.g. for use with the + :py:func:`ssh_auth.present ` state. + + CLI Example: + + .. code-block:: bash + + salt-call github_user.list_keys github-handle + salt-call github_user.list_keys # the token's user + + name + Login of the user whose keys to list. Defaults to the user the + profile's token belongs to. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + return gh_users.list_keys(_get_conn(profile), name) diff --git a/src/saltext/github/utils/gh/client.py b/src/saltext/github/utils/gh/client.py index 10a62c36..4ec8f16c 100644 --- a/src/saltext/github/utils/gh/client.py +++ b/src/saltext/github/utils/gh/client.py @@ -240,6 +240,18 @@ def query(self, path, *, params=None, method="GET", headers=None, data=None, per return complete_result +def rate_limit(conn): + """ + Return the API rate limit status of a connection's token, keyed by + resource (``core``, ``search``, ...). The deprecated top-level ``rate`` + alias for ``core`` is excluded. + + conn + :class:`GitHubConnection` to use. + """ + return conn.query("rate_limit")["resources"] + + def _error_message(status, data, fallback=""): message = data.get("message", fallback) if isinstance(data, dict) else (data or fallback) return f"{status} {message}".strip() diff --git a/src/saltext/github/utils/gh/users.py b/src/saltext/github/utils/gh/users.py new file mode 100644 index 00000000..c2540308 --- /dev/null +++ b/src/saltext/github/utils/gh/users.py @@ -0,0 +1,87 @@ +""" +Operations on arbitrary GitHub user accounts, independent of any +organization membership. +""" + +import logging + +from saltext.github.utils.gh.client import api_request +from saltext.github.utils.gh.exceptions import GitHubNotFoundError + +log = logging.getLogger(__name__) + +# Public profile fields returned by get_info. Rendered via .get() since +# organization accounts miss some of them. +INFO_FIELDS = ( + "avatar_url", + "bio", + "blog", + "company", + "created_at", + "email", + "followers", + "following", + "html_url", + "id", + "location", + "login", + "name", + "public_gists", + "public_repos", + "type", + "updated_at", + "url", +) + + +@api_request +def get_info(conn, login=None, *, expect=None): + """ + Return the public profile of any user or organization account + (distinguished via the returned ``type`` field). + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + login + Account handle to look up. Defaults to the connection's owner, + or to the token's user when ``expect`` is ``User``. + + expect + Required account type, either ``User`` or ``Organization``. + By default, both types are allowed. + + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` + if the account does not exist or is not of the ``expect``-ed type. + """ + if expect not in (None, "User", "Organization"): # pragma: no cover + raise ValueError(f"Invalid {expect=}. Valid: None, 'User', 'Organization'") + if login is None and expect == "User": + user = conn.auth_user + else: + # The /users/{login} endpoint resolves both account types + user = conn.client.get_user(login or conn.owner) + if expect is not None and user.type != expect: + raise GitHubNotFoundError(f"{user.login} is not of type {expect}") + data = user.raw_data + return {fld: data.get(fld) for fld in INFO_FIELDS} + + +@api_request +def list_keys(conn, login=None): + """ + Return the verified public SSH keys of any user as a list of + mappings with ``id`` and ``key`` fields. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + login + User handle whose keys to list. Defaults to the user the + connection's token belongs to. + + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` + if the user does not exist. + """ + user = conn.client.get_user(login or conn.auth_user.login) + return [{"id": key.id, "key": key.key} for key in user.get_keys()] diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index d32e67ae..9faf6b3e 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -6,6 +6,8 @@ from saltfactories.utils.functional import Loaders from tests.support.github import DEPLOY_KEY_A +from tests.support.github import USER_KEY_A +from tests.support.github import USER_KEY_B from tests.support.github import FakeGitHubAPI log = logging.getLogger(__name__) @@ -218,7 +220,7 @@ def fake_setup(fake_state): """ Default setup for fake_state. Override this per module, if necessary. """ - user1 = fake_state.add_user("user1") + user1 = fake_state.add_user("user1", ssh_keys=(USER_KEY_A, USER_KEY_B)) user2 = fake_state.add_user("user2", two_factor_authentication=False) fake_state.add_user("newuser") org = fake_state.add_org("testorg") diff --git a/tests/functional/modules/github/test_accounts.py b/tests/functional/modules/github/test_accounts.py new file mode 100644 index 00000000..21646c81 --- /dev/null +++ b/tests/functional/modules/github/test_accounts.py @@ -0,0 +1,41 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +@pytest.fixture(scope="module") +def minion_config_defaults(github_profile): + user_owner = {key: val for key, val in github_profile.items() if key != "org_name"} + user_owner["owner"] = "user2" + return { + "github": github_profile, + "github_userowner": user_owner, + } + + +def test_account_info_user(github): + ret = github.account_info("user2") + assert ret["login"] == "user2" + assert ret["type"] == "User" + + +def test_account_info_org(github): + ret = github.account_info("otherorg") + assert ret["login"] == "otherorg" + assert ret["type"] == "Organization" + + +def test_account_info_defaults_to_owner(github): + ret = github.account_info() + assert ret["login"] == "testorg" + assert ret["type"] == "Organization" + + +def test_account_info_defaults_to_owner_user_account(github): + # The owner default applies even when the owner is a plain user + # account that is not the token's user + assert github.account_info(profile="github_userowner")["login"] == "user2" + + +def test_account_info_unknown(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.account_info("ghost") diff --git a/tests/functional/modules/github/test_meta.py b/tests/functional/modules/github/test_meta.py new file mode 100644 index 00000000..3b471c6e --- /dev/null +++ b/tests/functional/modules/github/test_meta.py @@ -0,0 +1,9 @@ +from tests.support.github.routes.meta import RATE_LIMIT_RESOURCES + + +def test_rate_limit(github): + ret = github.rate_limit() + assert ret == RATE_LIMIT_RESOURCES + # The deprecated top-level "rate" alias is not included + assert "rate" not in ret + assert "resources" not in ret diff --git a/tests/functional/modules/github/test_query.py b/tests/functional/modules/github/test_query.py new file mode 100644 index 00000000..27b68400 --- /dev/null +++ b/tests/functional/modules/github/test_query.py @@ -0,0 +1,36 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_query_single_object(github): + ret = github.query("users/user2") + assert ret["login"] == "user2" + + +def test_query_accepts_leading_slash(github): + assert github.query("/users/user2")["login"] == "user2" + + +def test_query_accepts_path_segments(github): + assert github.query(["users", "user2"])["login"] == "user2" + + +def test_query_merges_pages(github): + ret = github.query("orgs/testorg/members", per_page=1) + assert sorted(user["login"] for user in ret) == ["user1", "user2"] + + +def test_query_params(github): + ret = github.query("orgs/testorg/members", params={"filter": "2fa_disabled"}) + assert sorted(user["login"] for user in ret) == ["user2"] + + +def test_query_method_and_data(github, fake_org): + ret = github.query("repos/testorg/repo-a/labels", method="POST", data='{"name": "new-label"}') + assert ret["name"] == "new-label" + assert "new-label" in fake_org.get_repo("repo-a").labels + + +def test_query_not_found(github): + with pytest.raises(CommandExecutionError, match="Not Found"): + github.query("users/ghost") diff --git a/tests/functional/modules/github_org/test_org.py b/tests/functional/modules/github_org/test_org.py new file mode 100644 index 00000000..d9fcad4e --- /dev/null +++ b/tests/functional/modules/github_org/test_org.py @@ -0,0 +1,22 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_info(github_org): + ret = github_org.info("otherorg") + assert ret["login"] == "otherorg" + assert ret["type"] == "Organization" + + +def test_info_defaults_to_owner(github_org): + assert github_org.info()["login"] == "testorg" + + +def test_info_user_fails(github_org): + with pytest.raises(CommandExecutionError, match="user2 is not of type Organization"): + github_org.info("user2") + + +def test_info_unknown(github_org): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_org.info("ghost") diff --git a/tests/functional/modules/github_repo/test_owner.py b/tests/functional/modules/github_repo/test_owner.py index 9bdbc836..e37045e3 100644 --- a/tests/functional/modules/github_repo/test_owner.py +++ b/tests/functional/modules/github_repo/test_owner.py @@ -30,35 +30,35 @@ def minion_config_defaults(github_profile): } -def test_get_info(github_repo): - ret = github_repo.get_info(repo_name="user-repo", owner="user1") +def test_info(github_repo): + ret = github_repo.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): +def test_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") + github_repo.info(repo_name="user-repo", owner="ghost-owner") -def test_get_info_cache_is_owner_scoped(github_repo): +def test_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" + assert github_repo.info(repo_name="repo-a")["full_name"] == "testorg/repo-a" + assert github_repo.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" + assert github_repo.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") + ret = github_repo.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") + ret = github_repo.info(repo_name="user-repo", profile="github_noowner") assert ret["full_name"] == "user1/user-repo" @@ -111,7 +111,7 @@ 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" + assert github_repo.info(repo_name="user-repo", owner="user1")["description"] == "Updated" def test_delete(github_repo, fake_user): diff --git a/tests/functional/modules/github_repo/test_repo.py b/tests/functional/modules/github_repo/test_repo.py index 53fa7776..6c30958a 100644 --- a/tests/functional/modules/github_repo/test_repo.py +++ b/tests/functional/modules/github_repo/test_repo.py @@ -2,9 +2,9 @@ from salt.exceptions import CommandExecutionError -def test_get_info(github_repo): +def test_info(github_repo): # repo_name defaults to the profile's repo-a - ret = github_repo.get_info() + ret = github_repo.info() assert ret["name"] == "repo-a" assert ret["full_name"] == "testorg/repo-a" assert ret["description"] == "Test repo repo-a" @@ -12,17 +12,17 @@ def test_get_info(github_repo): assert ret["default_branch"] == "main" -def test_get_info_missing(github_repo): +def test_info_missing(github_repo): with pytest.raises(CommandExecutionError, match="could not be found"): - github_repo.get_info(repo_name="ghost") + github_repo.info(repo_name="ghost") -def test_get_info_uses_cache(github_repo, fake_org): - assert github_repo.get_info()["description"] == "Test repo repo-a" +def test_info_uses_cache(github_repo, fake_org): + assert github_repo.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" + assert github_repo.info()["description"] == "Test repo repo-a" + assert github_repo.info(ignore_cache=True)["description"] == "mutated" def test_get_teams(github_repo): @@ -41,7 +41,7 @@ def test_update(github_repo, fake_org): # 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" + assert github_repo.info()["description"] == "Updated" def test_update_default_branch(github_repo, fake_org): @@ -114,7 +114,7 @@ def test_create_refreshes_caches(github_repo): 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" + assert github_repo.info(repo_name="new-repo")["description"] == "Brand new" def test_create_duplicate(github_repo): diff --git a/tests/functional/modules/github_user/__init__.py b/tests/functional/modules/github_user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/modules/github_user/conftest.py b/tests/functional/modules/github_user/conftest.py new file mode 100644 index 00000000..c65ef255 --- /dev/null +++ b/tests/functional/modules/github_user/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def github_user(modules): + return modules.github_user diff --git a/tests/functional/modules/github_user/test_info.py b/tests/functional/modules/github_user/test_info.py new file mode 100644 index 00000000..03d92f23 --- /dev/null +++ b/tests/functional/modules/github_user/test_info.py @@ -0,0 +1,26 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_info(github_user): + ret = github_user.info("user2") + assert ret["login"] == "user2" + assert ret["type"] == "User" + assert ret["company"] == "Example Corp" + # Organization membership is not part of the generic account lookup + assert "in_org" not in ret + assert "membership_state" not in ret + + +def test_info_defaults_to_token_user(github_user): + assert github_user.info()["login"] == "user1" + + +def test_info_org_fails(github_user): + with pytest.raises(CommandExecutionError, match="testorg is not of type User"): + github_user.info("testorg") + + +def test_info_unknown(github_user): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_user.info("ghost") diff --git a/tests/functional/modules/github_user/test_keys.py b/tests/functional/modules/github_user/test_keys.py new file mode 100644 index 00000000..78d52976 --- /dev/null +++ b/tests/functional/modules/github_user/test_keys.py @@ -0,0 +1,25 @@ +import pytest +from salt.exceptions import CommandExecutionError + +from tests.support.github import USER_KEY_A +from tests.support.github import USER_KEY_B + + +def test_list_keys(github_user): + ret = github_user.list_keys("user1") + assert [key["key"] for key in ret] == [USER_KEY_A, USER_KEY_B] + assert all(isinstance(key["id"], int) for key in ret) + + +def test_list_keys_defaults_to_token_user(github_user): + ret = github_user.list_keys() + assert [key["key"] for key in ret] == [USER_KEY_A, USER_KEY_B] + + +def test_list_keys_none_present(github_user): + assert github_user.list_keys("user2") == [] + + +def test_list_keys_unknown(github_user): + with pytest.raises(CommandExecutionError, match="Not Found"): + github_user.list_keys("ghost") diff --git a/tests/support/github/__init__.py b/tests/support/github/__init__.py index 34ccd264..2a00dc2f 100644 --- a/tests/support/github/__init__.py +++ b/tests/support/github/__init__.py @@ -25,6 +25,8 @@ 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.base import USER_KEY_A +from tests.support.github.base import USER_KEY_B from tests.support.github.entities import BranchPolicy from tests.support.github.entities import DeployKey from tests.support.github.entities import Entity @@ -52,6 +54,7 @@ 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 MetaRoutes from tests.support.github.routes import MilestoneRoutes from tests.support.github.routes import OrgRoutes from tests.support.github.routes import PagesRoutes @@ -66,6 +69,8 @@ "DEPLOY_KEY_B", "ORG", "TOKEN", + "USER_KEY_A", + "USER_KEY_B", "BranchPolicy", "DeployKey", "Entity", @@ -106,6 +111,7 @@ class FakeGitHubAPI( # pylint: disable=too-many-ancestors EnvironmentRoutes, LabelRoutes, PagesRoutes, + MetaRoutes, ): """ Dict-backed implementation of the consumed GitHub API surface. diff --git a/tests/support/github/base.py b/tests/support/github/base.py index 7f447e47..b8889573 100644 --- a/tests/support/github/base.py +++ b/tests/support/github/base.py @@ -34,6 +34,10 @@ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPDeployKeyBForTesting0000000000000000000B replacement" ) +# Sample SSH public keys for user key tests +USER_KEY_A = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPUserKeyAForTesting000000000000000000000A" +USER_KEY_B = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPUserKeyBForTesting000000000000000000000B" + class APIBase: """ diff --git a/tests/support/github/entities.py b/tests/support/github/entities.py index 23833486..427d0475 100644 --- a/tests/support/github/entities.py +++ b/tests/support/github/entities.py @@ -347,6 +347,8 @@ class UserData(EntityData): public_gists: int = 1 followers: int = 42 following: int = 37 + # Public SSH keys (GET /users//keys), not part of the user payload + ssh_keys: tuple[str, ...] = () def __post_init__(self): if not self.login: @@ -362,6 +364,7 @@ class User(Entity): def payload(self, /, **ovrr): rend = asdict(self.data) + rend.pop("ssh_keys") return ( rend | { @@ -371,6 +374,9 @@ def payload(self, /, **ovrr): | ovrr ) + def key_payloads(self): + return [{"id": zlib.crc32(key.encode()), "key": key} for key in sorted(self.data.ssh_keys)] + @property def repos(self) -> "EntityView[RepoName, Repo]": login = self.data.login diff --git a/tests/support/github/routes/__init__.py b/tests/support/github/routes/__init__.py index ecc6c730..28d00e8c 100644 --- a/tests/support/github/routes/__init__.py +++ b/tests/support/github/routes/__init__.py @@ -7,6 +7,7 @@ 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.meta import MetaRoutes from tests.support.github.routes.milestones import MilestoneRoutes from tests.support.github.routes.orgs import OrgRoutes from tests.support.github.routes.pages import PagesRoutes @@ -20,6 +21,7 @@ "EnvironmentRoutes", "IssueRoutes", "LabelRoutes", + "MetaRoutes", "MilestoneRoutes", "OrgRoutes", "PagesRoutes", diff --git a/tests/support/github/routes/meta.py b/tests/support/github/routes/meta.py new file mode 100644 index 00000000..87042989 --- /dev/null +++ b/tests/support/github/routes/meta.py @@ -0,0 +1,22 @@ +""" +Routes: API metadata (rate limit). +""" + +from tests.support.github.base import APIBase +from tests.support.github.routing import GET + +RATE_LIMIT_RESOURCES = { + name: {"limit": limit, "used": 42, "remaining": limit - 42, "reset": 1760000000} + for name, limit in (("core", 5000), ("search", 30), ("graphql", 5000)) +} + + +class MetaRoutes(APIBase): + @GET("rate_limit") + def _rate_limit(self, _match, _query, _body): + # The top-level "rate" key is GitHub's deprecated alias for + # resources.core. + return 200, { + "resources": RATE_LIMIT_RESOURCES, + "rate": RATE_LIMIT_RESOURCES["core"], + } diff --git a/tests/support/github/routes/orgs.py b/tests/support/github/routes/orgs.py index 9938c8d6..ced8645f 100644 --- a/tests/support/github/routes/orgs.py +++ b/tests/support/github/routes/orgs.py @@ -24,6 +24,10 @@ def _get_user(self, match, _query, _body): except NotFound: return 200, self.state.get_org(match["login"]).payload() + @GET("users//keys") + def _list_user_keys(self, match, _query, _body): + return 200, self.state.get_user(match["login"]).key_payloads() + @GET("orgs//members") def _list_members(self, match, query, _body): org = self._resolve_org(match)