asyncgh 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- asyncgh-0.1.0/.gitignore +4 -0
- asyncgh-0.1.0/LICENSE +21 -0
- asyncgh-0.1.0/PKG-INFO +84 -0
- asyncgh-0.1.0/README.md +59 -0
- asyncgh-0.1.0/pyproject.toml +44 -0
- asyncgh-0.1.0/src/asyncgh/__init__.py +42 -0
- asyncgh-0.1.0/src/asyncgh/client.py +242 -0
- asyncgh-0.1.0/src/asyncgh/endpoints.py +62 -0
- asyncgh-0.1.0/src/asyncgh/py.typed +0 -0
- asyncgh-0.1.0/tests/conftest.py +21 -0
- asyncgh-0.1.0/tests/test_client.py +271 -0
- asyncgh-0.1.0/tests/test_endpoints.py +130 -0
asyncgh-0.1.0/.gitignore
ADDED
asyncgh-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hugo Haas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
asyncgh-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: asyncgh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal async GitHub REST + GraphQL transport: auth, retry, pagination -- no generated models
|
|
5
|
+
Project-URL: Homepage, https://github.com/hugoh/gh-workflows/tree/main/asyncgh
|
|
6
|
+
Project-URL: Issues, https://github.com/hugoh/gh-workflows/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/hugoh/gh-workflows
|
|
8
|
+
Author: Hugo Haas
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: httpx2>=2.10
|
|
22
|
+
Requires-Dist: pynacl>=1.5
|
|
23
|
+
Requires-Dist: stamina>=25.2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# asyncgh
|
|
27
|
+
|
|
28
|
+
A minimal async GitHub API transport — the plumbing an account-wide config
|
|
29
|
+
tool needs before any of its domain logic: authentication, a shared
|
|
30
|
+
connection, retrying error handling, REST pagination, and GraphQL.
|
|
31
|
+
|
|
32
|
+
There is no client object to construct and no config. It reads the token
|
|
33
|
+
`gh auth login` already stored, keeps one process-wide `httpx2.AsyncClient`,
|
|
34
|
+
and exposes plain functions.
|
|
35
|
+
|
|
36
|
+
## Why not PyGithub / githubkit / gidgethub / fast.ai's `ghapi`?
|
|
37
|
+
|
|
38
|
+
| Library | Async | Shape | Trade-off vs `asyncgh` |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| [PyGithub](https://github.com/PyGithub/PyGithub) | no | typed objects | sync-only — a poor fit for anything doing concurrent, fan-out API calls |
|
|
41
|
+
| [githubkit](https://github.com/yanyongyu/githubkit) | yes | Pydantic models, generated from GitHub's OpenAPI spec | full, typed surface, but pulls in codegen'd models and `pydantic`; heavier than most scripts need |
|
|
42
|
+
| [gidgethub](https://github.com/gidgethub/gidgethub) | yes | sans-I/O, bring-your-own HTTP client | closest in spirit, but leaves auth, retry, and the client itself to the caller |
|
|
43
|
+
| [ghapi](https://ghapi.fast.ai) (fast.ai) | no | OpenAPI-generated, dynamic attribute access | lightweight like this package, but sync-only |
|
|
44
|
+
| **asyncgh** | yes | plain `dict`s, ~200 lines | no generated models, no schema — you read GitHub's own docs and index the JSON; retry and GraphQL are built in, not bolted on |
|
|
45
|
+
|
|
46
|
+
If you want typed responses and full API coverage, use `githubkit`. If you
|
|
47
|
+
want an async client with almost no code between you and the wire — a script
|
|
48
|
+
firing dozens of concurrent calls that reads a handful of fields per
|
|
49
|
+
response — that's what this is for.
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
Full API reference, generated from the docstrings:
|
|
54
|
+
[hugoh.github.io/gh-workflows/asyncgh](https://hugoh.github.io/gh-workflows/asyncgh/)
|
|
55
|
+
(rebuilt on every push that touches this package -- see
|
|
56
|
+
`.github/workflows/docs.yml`).
|
|
57
|
+
|
|
58
|
+
## Auth
|
|
59
|
+
|
|
60
|
+
`Authorization: Bearer $(gh auth token)`, resolved lazily on the first
|
|
61
|
+
request and cached for the process. `gh` must be installed and logged in.
|
|
62
|
+
|
|
63
|
+
## Retries
|
|
64
|
+
|
|
65
|
+
`api_request` retries transport errors (DNS, timeout, reset) and transient
|
|
66
|
+
statuses — 429, 500, 502, 503, 504 — via [`stamina`](https://stamina.hynek.me).
|
|
67
|
+
A `Retry-After` header sets the exact wait; otherwise exponential backoff with
|
|
68
|
+
jitter. `GH_MAX_RETRIES` (default 3) caps the retries; there is no wall-clock
|
|
69
|
+
timeout, so a minute-long `Retry-After` is honoured in full. When the retries
|
|
70
|
+
are spent the last response is returned unchanged for the caller to judge.
|
|
71
|
+
|
|
72
|
+
`graphql()` reuses that same retry for transport errors and 429/5xx, plus one
|
|
73
|
+
addition: GitHub answers GraphQL rate limiting with HTTP 200 and an
|
|
74
|
+
`errors[].type == "RATE_LIMITED"` body, which `api_request`'s status-based
|
|
75
|
+
retry never sees -- `graphql()` retries that case itself, on the same
|
|
76
|
+
backoff. Any other GraphQL error (bad query, not found) is not transient and
|
|
77
|
+
raises immediately.
|
|
78
|
+
|
|
79
|
+
## Consumers
|
|
80
|
+
|
|
81
|
+
`repo-admin/` (in this repo) uses it for every account-wide GitHub command --
|
|
82
|
+
`repo_admin.py`'s `sync` subcommands and `activity.py` via `api_request`/
|
|
83
|
+
`api_json`/pagination; `digest.py` (wrapped by `../digest-action/`) is the
|
|
84
|
+
one driving `graphql()`.
|
asyncgh-0.1.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# asyncgh
|
|
2
|
+
|
|
3
|
+
A minimal async GitHub API transport — the plumbing an account-wide config
|
|
4
|
+
tool needs before any of its domain logic: authentication, a shared
|
|
5
|
+
connection, retrying error handling, REST pagination, and GraphQL.
|
|
6
|
+
|
|
7
|
+
There is no client object to construct and no config. It reads the token
|
|
8
|
+
`gh auth login` already stored, keeps one process-wide `httpx2.AsyncClient`,
|
|
9
|
+
and exposes plain functions.
|
|
10
|
+
|
|
11
|
+
## Why not PyGithub / githubkit / gidgethub / fast.ai's `ghapi`?
|
|
12
|
+
|
|
13
|
+
| Library | Async | Shape | Trade-off vs `asyncgh` |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| [PyGithub](https://github.com/PyGithub/PyGithub) | no | typed objects | sync-only — a poor fit for anything doing concurrent, fan-out API calls |
|
|
16
|
+
| [githubkit](https://github.com/yanyongyu/githubkit) | yes | Pydantic models, generated from GitHub's OpenAPI spec | full, typed surface, but pulls in codegen'd models and `pydantic`; heavier than most scripts need |
|
|
17
|
+
| [gidgethub](https://github.com/gidgethub/gidgethub) | yes | sans-I/O, bring-your-own HTTP client | closest in spirit, but leaves auth, retry, and the client itself to the caller |
|
|
18
|
+
| [ghapi](https://ghapi.fast.ai) (fast.ai) | no | OpenAPI-generated, dynamic attribute access | lightweight like this package, but sync-only |
|
|
19
|
+
| **asyncgh** | yes | plain `dict`s, ~200 lines | no generated models, no schema — you read GitHub's own docs and index the JSON; retry and GraphQL are built in, not bolted on |
|
|
20
|
+
|
|
21
|
+
If you want typed responses and full API coverage, use `githubkit`. If you
|
|
22
|
+
want an async client with almost no code between you and the wire — a script
|
|
23
|
+
firing dozens of concurrent calls that reads a handful of fields per
|
|
24
|
+
response — that's what this is for.
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
Full API reference, generated from the docstrings:
|
|
29
|
+
[hugoh.github.io/gh-workflows/asyncgh](https://hugoh.github.io/gh-workflows/asyncgh/)
|
|
30
|
+
(rebuilt on every push that touches this package -- see
|
|
31
|
+
`.github/workflows/docs.yml`).
|
|
32
|
+
|
|
33
|
+
## Auth
|
|
34
|
+
|
|
35
|
+
`Authorization: Bearer $(gh auth token)`, resolved lazily on the first
|
|
36
|
+
request and cached for the process. `gh` must be installed and logged in.
|
|
37
|
+
|
|
38
|
+
## Retries
|
|
39
|
+
|
|
40
|
+
`api_request` retries transport errors (DNS, timeout, reset) and transient
|
|
41
|
+
statuses — 429, 500, 502, 503, 504 — via [`stamina`](https://stamina.hynek.me).
|
|
42
|
+
A `Retry-After` header sets the exact wait; otherwise exponential backoff with
|
|
43
|
+
jitter. `GH_MAX_RETRIES` (default 3) caps the retries; there is no wall-clock
|
|
44
|
+
timeout, so a minute-long `Retry-After` is honoured in full. When the retries
|
|
45
|
+
are spent the last response is returned unchanged for the caller to judge.
|
|
46
|
+
|
|
47
|
+
`graphql()` reuses that same retry for transport errors and 429/5xx, plus one
|
|
48
|
+
addition: GitHub answers GraphQL rate limiting with HTTP 200 and an
|
|
49
|
+
`errors[].type == "RATE_LIMITED"` body, which `api_request`'s status-based
|
|
50
|
+
retry never sees -- `graphql()` retries that case itself, on the same
|
|
51
|
+
backoff. Any other GraphQL error (bad query, not found) is not transient and
|
|
52
|
+
raises immediately.
|
|
53
|
+
|
|
54
|
+
## Consumers
|
|
55
|
+
|
|
56
|
+
`repo-admin/` (in this repo) uses it for every account-wide GitHub command --
|
|
57
|
+
`repo_admin.py`'s `sync` subcommands and `activity.py` via `api_request`/
|
|
58
|
+
`api_json`/pagination; `digest.py` (wrapped by `../digest-action/`) is the
|
|
59
|
+
one driving `graphql()`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "asyncgh"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Minimal async GitHub REST + GraphQL transport: auth, retry, pagination -- no generated models"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Hugo Haas" },
|
|
11
|
+
]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
dependencies = ["httpx2>=2.10", "pynacl>=1.5", "stamina>=25.2"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/hugoh/gh-workflows/tree/main/asyncgh"
|
|
28
|
+
Issues = "https://github.com/hugoh/gh-workflows/issues"
|
|
29
|
+
Repository = "https://github.com/hugoh/gh-workflows"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "pytest-httpx2>=0.1", "respx>=0.21"]
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/asyncgh"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
addopts = ["-p", "pytest_httpx2"]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""asyncgh -- the GitHub REST API transport shared by repo-admin's scripts.
|
|
2
|
+
|
|
3
|
+
Auth via the token `gh` already holds, one shared async client, raw and
|
|
4
|
+
raise-on-error request helpers, `Link`-header pagination, and thin wrappers
|
|
5
|
+
over the few endpoints more than one script needs (account-wide repo
|
|
6
|
+
listing, Actions secrets). No config-file loading, no repo filtering -- those
|
|
7
|
+
stay with the caller.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .client import (
|
|
13
|
+
API_BASE,
|
|
14
|
+
GhError,
|
|
15
|
+
aclose_client,
|
|
16
|
+
api_json,
|
|
17
|
+
api_request,
|
|
18
|
+
error_message,
|
|
19
|
+
graphql,
|
|
20
|
+
paginated,
|
|
21
|
+
)
|
|
22
|
+
from .endpoints import (
|
|
23
|
+
encrypt_secret_value,
|
|
24
|
+
fetch_repos_json,
|
|
25
|
+
public_repos_json,
|
|
26
|
+
set_repo_secret,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"API_BASE",
|
|
31
|
+
"GhError",
|
|
32
|
+
"aclose_client",
|
|
33
|
+
"api_json",
|
|
34
|
+
"api_request",
|
|
35
|
+
"encrypt_secret_value",
|
|
36
|
+
"error_message",
|
|
37
|
+
"fetch_repos_json",
|
|
38
|
+
"graphql",
|
|
39
|
+
"paginated",
|
|
40
|
+
"public_repos_json",
|
|
41
|
+
"set_repo_secret",
|
|
42
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""GitHub REST API transport: auth, the shared async client, and the
|
|
2
|
+
request / pagination helpers every endpoint wrapper is built on.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx2
|
|
13
|
+
import stamina
|
|
14
|
+
|
|
15
|
+
API_BASE = "https://api.github.com"
|
|
16
|
+
|
|
17
|
+
# Retry transport failures and transient statuses (429 + 5xx). `attempts` is
|
|
18
|
+
# total tries, so MAX_RETRIES=3 means one call plus three retries. There is no
|
|
19
|
+
# wall-clock timeout: GitHub's Retry-After on a secondary rate limit is often
|
|
20
|
+
# a minute or more, and honouring it is the whole point.
|
|
21
|
+
MAX_RETRIES = int(os.environ.get("GH_MAX_RETRIES", "3"))
|
|
22
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
23
|
+
RETRY_WAIT_INITIAL = 1.0
|
|
24
|
+
RETRY_WAIT_MAX = 60.0
|
|
25
|
+
RETRY_WAIT_JITTER = 1.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GhError(RuntimeError):
|
|
29
|
+
"""A GitHub API call -- or a caller's worker function -- failed
|
|
30
|
+
unexpectedly.
|
|
31
|
+
|
|
32
|
+
status_code is set for HTTP errors raised by api_json(), so callers can
|
|
33
|
+
branch on the real status code (e.g. 403 vs 404) instead of
|
|
34
|
+
string-matching an error message. Plain RuntimeError rather than a
|
|
35
|
+
reconcilekit.ReconcileError subclass -- asyncgh has no dependency on
|
|
36
|
+
reconcilekit, so it stays installable standalone; reconcilekit's own
|
|
37
|
+
run_parallel(..., error_cls=) only requires type[Exception], so GhError
|
|
38
|
+
still works there unchanged.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, *, status_code: int | None = None):
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _auth_token() -> str:
|
|
47
|
+
"""Reads the token `gh` already has -- keychain storage, SSO, and 2FA are
|
|
48
|
+
already solved by `gh auth login`, so this reuses that instead of
|
|
49
|
+
managing a separate credential.
|
|
50
|
+
"""
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["gh", "auth", "token"], capture_output=True, text=True, check=False
|
|
53
|
+
)
|
|
54
|
+
if result.returncode != 0:
|
|
55
|
+
raise GhError(f"gh auth token failed: {result.stderr.strip()}")
|
|
56
|
+
return result.stdout.strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_client: httpx2.AsyncClient | None = None
|
|
60
|
+
_client_lock = asyncio.Lock()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _get_client() -> httpx2.AsyncClient:
|
|
64
|
+
# One shared AsyncClient rather than one per thread: there's no thread
|
|
65
|
+
# pool, and gh auth token only needs to be paid once per process.
|
|
66
|
+
global _client
|
|
67
|
+
if _client is None:
|
|
68
|
+
async with _client_lock:
|
|
69
|
+
if _client is None:
|
|
70
|
+
_client = httpx2.AsyncClient(
|
|
71
|
+
headers={
|
|
72
|
+
"Authorization": f"Bearer {_auth_token()}",
|
|
73
|
+
"Accept": "application/vnd.github+json",
|
|
74
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
75
|
+
},
|
|
76
|
+
timeout=30,
|
|
77
|
+
)
|
|
78
|
+
return _client
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def aclose_client() -> None:
|
|
82
|
+
global _client
|
|
83
|
+
if _client is not None:
|
|
84
|
+
await _client.aclose()
|
|
85
|
+
_client = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _should_retry(exc: Exception) -> bool | float:
|
|
89
|
+
"""stamina backoff hook. A `Retry-After` header (GitHub sends one on
|
|
90
|
+
secondary rate limits) sets the exact wait; otherwise transport errors
|
|
91
|
+
and RETRY_STATUSES responses retry on stamina's default backoff, and
|
|
92
|
+
everything else propagates.
|
|
93
|
+
"""
|
|
94
|
+
response = getattr(exc, "response", None)
|
|
95
|
+
if response is not None:
|
|
96
|
+
header = response.headers.get("retry-after")
|
|
97
|
+
if header:
|
|
98
|
+
try:
|
|
99
|
+
return float(header)
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
return response.status_code in RETRY_STATUSES
|
|
103
|
+
return isinstance(exc, httpx2.TransportError)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def api_request(
|
|
107
|
+
method: str, path: str, *, json: Any = None, params: dict | None = None
|
|
108
|
+
) -> httpx2.Response:
|
|
109
|
+
"""Makes one GitHub REST API call and returns the raw Response --
|
|
110
|
+
callers decide what a given status means for their endpoint (e.g. a 404
|
|
111
|
+
means "feature disabled" for vulnerability-alerts but "not found"
|
|
112
|
+
everywhere else). Raises GhError only for genuine transport failures
|
|
113
|
+
(DNS, timeout, connection reset); HTTP error statuses are returned, not
|
|
114
|
+
raised.
|
|
115
|
+
|
|
116
|
+
Transport errors and transient statuses (429, 5xx) are retried up to
|
|
117
|
+
MAX_RETRIES times, honouring `Retry-After`. GitHub's writes here are
|
|
118
|
+
idempotent (secret PUTs replace), so retrying any method is safe.
|
|
119
|
+
"""
|
|
120
|
+
url = path if path.startswith("http") else f"{API_BASE}{path}"
|
|
121
|
+
http = await _get_client()
|
|
122
|
+
try:
|
|
123
|
+
async for attempt in stamina.retry_context(
|
|
124
|
+
on=_should_retry,
|
|
125
|
+
attempts=MAX_RETRIES + 1,
|
|
126
|
+
timeout=None,
|
|
127
|
+
wait_initial=RETRY_WAIT_INITIAL,
|
|
128
|
+
wait_max=RETRY_WAIT_MAX,
|
|
129
|
+
wait_jitter=RETRY_WAIT_JITTER,
|
|
130
|
+
):
|
|
131
|
+
with attempt:
|
|
132
|
+
try:
|
|
133
|
+
response = await http.request(method, url, json=json, params=params)
|
|
134
|
+
except httpx2.TransportError:
|
|
135
|
+
raise
|
|
136
|
+
except httpx2.HTTPError as exc:
|
|
137
|
+
raise GhError(str(exc)) from exc
|
|
138
|
+
if response.status_code in RETRY_STATUSES:
|
|
139
|
+
response.raise_for_status()
|
|
140
|
+
return response
|
|
141
|
+
except httpx2.HTTPStatusError as exc:
|
|
142
|
+
return exc.response # retryable status, retries spent -- let the caller judge
|
|
143
|
+
except httpx2.TransportError as exc:
|
|
144
|
+
raise GhError(str(exc)) from exc
|
|
145
|
+
raise GhError("api_request retry loop exited without a response") # unreachable
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def error_message(response: httpx2.Response) -> str:
|
|
149
|
+
"""Extracts GitHub's own `message` field from an error response body,
|
|
150
|
+
falling back to the raw response text if the body isn't JSON.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
return response.json().get("message", response.text)
|
|
154
|
+
except ValueError:
|
|
155
|
+
return response.text
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def api_json(
|
|
159
|
+
method: str, path: str, *, json: Any = None, params: dict | None = None
|
|
160
|
+
) -> dict:
|
|
161
|
+
"""Like api_request, but raises GhError (with status_code and GitHub's
|
|
162
|
+
own error message) on any non-2xx response, and returns the parsed JSON
|
|
163
|
+
body -- or {} for a body-less response like 204 No Content -- on success.
|
|
164
|
+
"""
|
|
165
|
+
response = await api_request(method, path, json=json, params=params)
|
|
166
|
+
if not response.is_success:
|
|
167
|
+
raise GhError(error_message(response), status_code=response.status_code)
|
|
168
|
+
return response.json() if response.content else {}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _GraphQLRateLimited(Exception):
|
|
172
|
+
"""Internal marker: GraphQL answered 200 with errors[].type ==
|
|
173
|
+
RATE_LIMITED, which api_request's transport/5xx retry never sees since
|
|
174
|
+
it only looks at the HTTP status. Retried at this layer instead.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def graphql(query: str, variables: dict | None = None) -> dict:
|
|
179
|
+
"""Runs one GraphQL query and returns its `data` object.
|
|
180
|
+
|
|
181
|
+
GraphQL always answers HTTP 200, even for query errors, so success lives
|
|
182
|
+
in the body: `errors` present (with `data` null or partial) raises
|
|
183
|
+
GhError with the joined messages -- callers have no partial-data story,
|
|
184
|
+
so partial loss is worse than a loud failure. RATE_LIMITED errors retry
|
|
185
|
+
(stamina, same backoff as api_request) since they're the one transient
|
|
186
|
+
GraphQL failure mode; other GraphQL errors (bad query, not found) are
|
|
187
|
+
not transient and raise immediately.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
async for attempt in stamina.retry_context(
|
|
191
|
+
on=lambda exc: isinstance(exc, _GraphQLRateLimited),
|
|
192
|
+
attempts=MAX_RETRIES + 1,
|
|
193
|
+
timeout=None,
|
|
194
|
+
wait_initial=RETRY_WAIT_INITIAL,
|
|
195
|
+
wait_max=RETRY_WAIT_MAX,
|
|
196
|
+
wait_jitter=RETRY_WAIT_JITTER,
|
|
197
|
+
):
|
|
198
|
+
with attempt:
|
|
199
|
+
response = await api_request(
|
|
200
|
+
"POST",
|
|
201
|
+
"/graphql",
|
|
202
|
+
json={"query": query, "variables": variables or {}},
|
|
203
|
+
)
|
|
204
|
+
if not response.is_success:
|
|
205
|
+
raise GhError(
|
|
206
|
+
error_message(response), status_code=response.status_code
|
|
207
|
+
)
|
|
208
|
+
body = response.json()
|
|
209
|
+
errors = body.get("errors")
|
|
210
|
+
if errors:
|
|
211
|
+
if any(error.get("type") == "RATE_LIMITED" for error in errors):
|
|
212
|
+
raise _GraphQLRateLimited(
|
|
213
|
+
"; ".join(
|
|
214
|
+
error.get("message", str(error)) for error in errors
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
raise GhError(
|
|
218
|
+
"; ".join(error.get("message", str(error)) for error in errors)
|
|
219
|
+
)
|
|
220
|
+
return body["data"]
|
|
221
|
+
except _GraphQLRateLimited as exc:
|
|
222
|
+
raise GhError(str(exc)) from exc
|
|
223
|
+
raise GhError("graphql retry loop exited without a response") # unreachable
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def paginated(
|
|
227
|
+
method: str, path: str, *, params: dict | None = None
|
|
228
|
+
) -> list[dict]:
|
|
229
|
+
"""Follows GitHub's `Link: rel="next"` header, concatenating every page's
|
|
230
|
+
JSON array into one list.
|
|
231
|
+
"""
|
|
232
|
+
items = []
|
|
233
|
+
url = path
|
|
234
|
+
query = params
|
|
235
|
+
while url:
|
|
236
|
+
response = await api_request(method, url, params=query)
|
|
237
|
+
if not response.is_success:
|
|
238
|
+
raise GhError(error_message(response), status_code=response.status_code)
|
|
239
|
+
items.extend(response.json())
|
|
240
|
+
url = response.links.get("next", {}).get("url")
|
|
241
|
+
query = None # the "next" link already carries the full query string
|
|
242
|
+
return items
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Thin wrappers over the handful of GitHub REST endpoints shared across
|
|
2
|
+
repo-admin's scripts: account-wide repo listing and Actions secrets.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
|
|
9
|
+
from nacl import encoding, public
|
|
10
|
+
|
|
11
|
+
from .client import api_json, paginated
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def fetch_repos_json(owner: str) -> list[dict]:
|
|
15
|
+
"""Lists every repo for `owner`. When `owner` is the authenticated `gh`
|
|
16
|
+
user, uses /user/repos so private repos are included; otherwise falls
|
|
17
|
+
back to /users/{owner}/repos, which only ever returns public repos.
|
|
18
|
+
"""
|
|
19
|
+
viewer = (await api_json("GET", "/user")).get("login")
|
|
20
|
+
if owner == viewer:
|
|
21
|
+
return await paginated(
|
|
22
|
+
"GET", "/user/repos", params={"affiliation": "owner", "per_page": "100"}
|
|
23
|
+
)
|
|
24
|
+
return await paginated("GET", f"/users/{owner}/repos", params={"per_page": "100"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def public_repos_json(owner: str) -> list[dict]:
|
|
28
|
+
"""Lists `owner`'s public repos only, via /users/{owner}/repos -- unlike
|
|
29
|
+
fetch_repos_json, this always excludes private repos even when `owner`
|
|
30
|
+
is the authenticated user, so no /user call is needed to branch on it.
|
|
31
|
+
"""
|
|
32
|
+
return await paginated("GET", f"/users/{owner}/repos", params={"per_page": "100"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def encrypt_secret_value(public_key_b64: str, value: str) -> str:
|
|
36
|
+
"""Encrypts `value` for GitHub's Actions secrets API using a repo's
|
|
37
|
+
public key, per GitHub's documented libsodium sealed-box scheme.
|
|
38
|
+
"""
|
|
39
|
+
public_key = public.PublicKey(
|
|
40
|
+
public_key_b64.encode("utf-8"), encoding.Base64Encoder
|
|
41
|
+
)
|
|
42
|
+
encrypted = public.SealedBox(public_key).encrypt(value.encode("utf-8"))
|
|
43
|
+
return base64.b64encode(encrypted).decode("utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def set_repo_secret(
|
|
47
|
+
owner: str, repo_name: str, secret_name: str, value: str
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Sets one repo's Actions secret via GitHub's REST API: fetches the
|
|
50
|
+
repo's current public key, encrypts `value` for it, and PUTs the
|
|
51
|
+
result. The plaintext value never leaves this process -- it's encrypted
|
|
52
|
+
in memory before the request body is built.
|
|
53
|
+
"""
|
|
54
|
+
key_data = await api_json(
|
|
55
|
+
"GET", f"/repos/{owner}/{repo_name}/actions/secrets/public-key"
|
|
56
|
+
)
|
|
57
|
+
encrypted_value = encrypt_secret_value(key_data["key"], value)
|
|
58
|
+
await api_json(
|
|
59
|
+
"PUT",
|
|
60
|
+
f"/repos/{owner}/{repo_name}/actions/secrets/{secret_name}",
|
|
61
|
+
json={"encrypted_value": encrypted_value, "key_id": key_data["key_id"]},
|
|
62
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from asyncgh import client
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture(autouse=True)
|
|
7
|
+
def _reset_http_client():
|
|
8
|
+
# httpx2.AsyncClient is bound to the event loop it was created under, and
|
|
9
|
+
# pytest-asyncio's default function-scoped loop means a client surviving
|
|
10
|
+
# across tests would raise "attached to a different loop" errors.
|
|
11
|
+
client._client = None
|
|
12
|
+
yield
|
|
13
|
+
client._client = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def fake_auth_token(monkeypatch):
|
|
18
|
+
# Avoids every test shelling out to the real `gh auth token` -- client
|
|
19
|
+
# creation is lazy, so this just needs to be in place before the first
|
|
20
|
+
# api_request/api_json call.
|
|
21
|
+
monkeypatch.setattr(client, "_auth_token", lambda: "fake-token")
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import httpx2
|
|
5
|
+
import pytest
|
|
6
|
+
import respx
|
|
7
|
+
from asyncgh.client import _should_retry
|
|
8
|
+
|
|
9
|
+
from asyncgh import (
|
|
10
|
+
API_BASE,
|
|
11
|
+
GhError,
|
|
12
|
+
api_json,
|
|
13
|
+
api_request,
|
|
14
|
+
client,
|
|
15
|
+
error_message,
|
|
16
|
+
graphql,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(autouse=True)
|
|
21
|
+
def _instant_backoff(monkeypatch):
|
|
22
|
+
monkeypatch.setattr(client, "RETRY_WAIT_INITIAL", 0.0)
|
|
23
|
+
monkeypatch.setattr(client, "RETRY_WAIT_MAX", 0.0)
|
|
24
|
+
monkeypatch.setattr(client, "RETRY_WAIT_JITTER", 0.0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def test_error_message_prefers_json_message_field(httpx2_mock: respx.Router):
|
|
28
|
+
httpx2_mock.get(f"{API_BASE}/x").mock(
|
|
29
|
+
return_value=httpx.Response(404, json={"message": "not found"})
|
|
30
|
+
)
|
|
31
|
+
response = await api_request("GET", "/x")
|
|
32
|
+
assert error_message(response) == "not found"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def test_error_message_falls_back_to_raw_text_for_non_json_body(
|
|
36
|
+
httpx2_mock: respx.Router,
|
|
37
|
+
):
|
|
38
|
+
httpx2_mock.get(f"{API_BASE}/x").mock(
|
|
39
|
+
return_value=httpx.Response(
|
|
40
|
+
500, text="plain text error", headers={"Content-Type": "text/plain"}
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
response = await api_request("GET", "/x")
|
|
44
|
+
assert error_message(response) == "plain text error"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_api_json_returns_parsed_body_on_success(httpx2_mock: respx.Router):
|
|
48
|
+
httpx2_mock.get(f"{API_BASE}/repos/hugoh/gh-workflows").mock(
|
|
49
|
+
return_value=httpx.Response(200, json={"name": "gh-workflows"})
|
|
50
|
+
)
|
|
51
|
+
assert await api_json("GET", "/repos/hugoh/gh-workflows") == {
|
|
52
|
+
"name": "gh-workflows"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_api_json_raises_gh_error_with_status_code_on_failure(
|
|
57
|
+
httpx2_mock: respx.Router,
|
|
58
|
+
):
|
|
59
|
+
httpx2_mock.get(f"{API_BASE}/repos/hugoh/nope").mock(
|
|
60
|
+
return_value=httpx.Response(404, json={"message": "Not Found"})
|
|
61
|
+
)
|
|
62
|
+
with pytest.raises(GhError) as exc_info:
|
|
63
|
+
await api_json("GET", "/repos/hugoh/nope")
|
|
64
|
+
assert exc_info.value.status_code == 404
|
|
65
|
+
assert "Not Found" in str(exc_info.value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def test_api_json_handles_empty_204_response(httpx2_mock: respx.Router):
|
|
69
|
+
httpx2_mock.put(f"{API_BASE}/repos/hugoh/gh-workflows/vulnerability-alerts").mock(
|
|
70
|
+
return_value=httpx.Response(204)
|
|
71
|
+
)
|
|
72
|
+
assert await api_json("PUT", "/repos/hugoh/gh-workflows/vulnerability-alerts") == {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_api_request_does_not_raise_on_http_error_status(
|
|
76
|
+
httpx2_mock: respx.Router,
|
|
77
|
+
):
|
|
78
|
+
httpx2_mock.get(
|
|
79
|
+
f"{API_BASE}/repos/hugoh/private-repo/private-vulnerability-reporting"
|
|
80
|
+
).mock(return_value=httpx.Response(404))
|
|
81
|
+
response = await api_request(
|
|
82
|
+
"GET", "/repos/hugoh/private-repo/private-vulnerability-reporting"
|
|
83
|
+
)
|
|
84
|
+
assert response.status_code == 404
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_api_request_retries_transport_error_then_succeeds(
|
|
88
|
+
httpx2_mock: respx.Router,
|
|
89
|
+
):
|
|
90
|
+
route = httpx2_mock.get(f"{API_BASE}/x").mock(
|
|
91
|
+
side_effect=[httpx2.ConnectError("boom"), httpx.Response(200, json={})]
|
|
92
|
+
)
|
|
93
|
+
response = await api_request("GET", "/x")
|
|
94
|
+
assert response.status_code == 200
|
|
95
|
+
assert route.call_count == 2
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_api_request_raises_gh_error_after_exhausting_transport_retries(
|
|
99
|
+
httpx2_mock: respx.Router,
|
|
100
|
+
):
|
|
101
|
+
route = httpx2_mock.get(f"{API_BASE}/x").mock(
|
|
102
|
+
side_effect=httpx2.ConnectError("boom")
|
|
103
|
+
)
|
|
104
|
+
with pytest.raises(GhError, match="boom"):
|
|
105
|
+
await api_request("GET", "/x")
|
|
106
|
+
assert route.call_count == client.MAX_RETRIES + 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def test_api_request_retries_retryable_status_then_returns_success(
|
|
110
|
+
httpx2_mock: respx.Router,
|
|
111
|
+
):
|
|
112
|
+
route = httpx2_mock.get(f"{API_BASE}/x").mock(
|
|
113
|
+
side_effect=[httpx.Response(503), httpx.Response(200, json={})]
|
|
114
|
+
)
|
|
115
|
+
response = await api_request("GET", "/x")
|
|
116
|
+
assert response.status_code == 200
|
|
117
|
+
assert route.call_count == 2
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def test_api_request_returns_last_response_when_retries_exhausted(
|
|
121
|
+
httpx2_mock: respx.Router,
|
|
122
|
+
):
|
|
123
|
+
route = httpx2_mock.get(f"{API_BASE}/x").mock(return_value=httpx.Response(503))
|
|
124
|
+
response = await api_request("GET", "/x")
|
|
125
|
+
assert response.status_code == 503
|
|
126
|
+
assert route.call_count == client.MAX_RETRIES + 1
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def test_api_request_does_not_retry_non_retryable_status(
|
|
130
|
+
httpx2_mock: respx.Router,
|
|
131
|
+
):
|
|
132
|
+
route = httpx2_mock.get(f"{API_BASE}/x").mock(return_value=httpx.Response(404))
|
|
133
|
+
await api_request("GET", "/x")
|
|
134
|
+
assert route.call_count == 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def test_api_request_does_not_retry_non_transport_request_error(
|
|
138
|
+
httpx2_mock: respx.Router,
|
|
139
|
+
):
|
|
140
|
+
route = httpx2_mock.get(f"{API_BASE}/x").mock(
|
|
141
|
+
side_effect=httpx2.DecodingError("bad body")
|
|
142
|
+
)
|
|
143
|
+
with pytest.raises(GhError, match="bad body"):
|
|
144
|
+
await api_request("GET", "/x")
|
|
145
|
+
assert route.call_count == 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def test_max_retries_zero_disables_retrying(
|
|
149
|
+
httpx2_mock: respx.Router, monkeypatch
|
|
150
|
+
):
|
|
151
|
+
monkeypatch.setattr(client, "MAX_RETRIES", 0)
|
|
152
|
+
route = httpx2_mock.get(f"{API_BASE}/x").mock(return_value=httpx.Response(503))
|
|
153
|
+
await api_request("GET", "/x")
|
|
154
|
+
assert route.call_count == 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _status_error(status: int, **headers: str) -> httpx2.HTTPStatusError:
|
|
158
|
+
request = httpx2.Request("GET", f"{API_BASE}/x")
|
|
159
|
+
response = httpx2.Response(status, headers=headers, request=request)
|
|
160
|
+
return httpx2.HTTPStatusError(str(status), request=request, response=response)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_should_retry_reads_retry_after_header_as_exact_wait():
|
|
164
|
+
assert _should_retry(_status_error(429, **{"Retry-After": "42"})) == 42.0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_should_retry_true_for_5xx_without_retry_after():
|
|
168
|
+
assert _should_retry(_status_error(503)) is True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_should_retry_false_for_4xx():
|
|
172
|
+
assert _should_retry(_status_error(404)) is False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_should_retry_true_for_transport_error():
|
|
176
|
+
assert _should_retry(httpx2.ConnectError("boom")) is True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_should_retry_false_for_other_exception():
|
|
180
|
+
assert _should_retry(ValueError("nope")) is False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def test_graphql_posts_query_and_variables_and_returns_data(
|
|
184
|
+
httpx2_mock: respx.Router,
|
|
185
|
+
):
|
|
186
|
+
route = httpx2_mock.post(f"{API_BASE}/graphql").mock(
|
|
187
|
+
return_value=httpx.Response(200, json={"data": {"viewer": {"login": "hugoh"}}})
|
|
188
|
+
)
|
|
189
|
+
data = await graphql("query { viewer { login } }", {"x": 1})
|
|
190
|
+
assert data == {"viewer": {"login": "hugoh"}}
|
|
191
|
+
body = json.loads(route.calls[0].request.content)
|
|
192
|
+
assert body == {"query": "query { viewer { login } }", "variables": {"x": 1}}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def test_graphql_raises_gh_error_when_errors_present_and_data_null(
|
|
196
|
+
httpx2_mock: respx.Router,
|
|
197
|
+
):
|
|
198
|
+
httpx2_mock.post(f"{API_BASE}/graphql").mock(
|
|
199
|
+
return_value=httpx.Response(
|
|
200
|
+
200, json={"data": None, "errors": [{"message": "Could not resolve"}]}
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
with pytest.raises(GhError, match="Could not resolve"):
|
|
204
|
+
await graphql("query { nope }")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def test_graphql_raises_gh_error_when_errors_present_alongside_partial_data(
|
|
208
|
+
httpx2_mock: respx.Router,
|
|
209
|
+
):
|
|
210
|
+
httpx2_mock.post(f"{API_BASE}/graphql").mock(
|
|
211
|
+
return_value=httpx.Response(
|
|
212
|
+
200,
|
|
213
|
+
json={
|
|
214
|
+
"data": {"r0": {"name": "a"}, "r1": None},
|
|
215
|
+
"errors": [{"message": "r1 not found"}],
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
with pytest.raises(GhError, match="r1 not found"):
|
|
220
|
+
await graphql("query { r0 r1 }")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def test_graphql_retries_on_transport_error_via_api_request(
|
|
224
|
+
httpx2_mock: respx.Router,
|
|
225
|
+
):
|
|
226
|
+
route = httpx2_mock.post(f"{API_BASE}/graphql").mock(
|
|
227
|
+
side_effect=[
|
|
228
|
+
httpx2.ConnectError("boom"),
|
|
229
|
+
httpx.Response(200, json={"data": {"ok": True}}),
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
data = await graphql("query { ok }")
|
|
233
|
+
assert data == {"ok": True}
|
|
234
|
+
assert route.call_count == 2
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def test_graphql_retries_rate_limited_error_then_succeeds(
|
|
238
|
+
httpx2_mock: respx.Router,
|
|
239
|
+
):
|
|
240
|
+
route = httpx2_mock.post(f"{API_BASE}/graphql").mock(
|
|
241
|
+
side_effect=[
|
|
242
|
+
httpx.Response(
|
|
243
|
+
200,
|
|
244
|
+
json={
|
|
245
|
+
"data": None,
|
|
246
|
+
"errors": [{"type": "RATE_LIMITED", "message": "rate limited"}],
|
|
247
|
+
},
|
|
248
|
+
),
|
|
249
|
+
httpx.Response(200, json={"data": {"ok": True}}),
|
|
250
|
+
]
|
|
251
|
+
)
|
|
252
|
+
data = await graphql("query { ok }")
|
|
253
|
+
assert data == {"ok": True}
|
|
254
|
+
assert route.call_count == 2
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def test_graphql_raises_gh_error_after_exhausting_rate_limited_retries(
|
|
258
|
+
httpx2_mock: respx.Router,
|
|
259
|
+
):
|
|
260
|
+
route = httpx2_mock.post(f"{API_BASE}/graphql").mock(
|
|
261
|
+
return_value=httpx.Response(
|
|
262
|
+
200,
|
|
263
|
+
json={
|
|
264
|
+
"data": None,
|
|
265
|
+
"errors": [{"type": "RATE_LIMITED", "message": "rate limited"}],
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
with pytest.raises(GhError, match="rate limited"):
|
|
270
|
+
await graphql("query { ok }")
|
|
271
|
+
assert route.call_count == client.MAX_RETRIES + 1
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import respx
|
|
5
|
+
from nacl import encoding, public
|
|
6
|
+
|
|
7
|
+
from asyncgh import (
|
|
8
|
+
API_BASE,
|
|
9
|
+
encrypt_secret_value,
|
|
10
|
+
endpoints,
|
|
11
|
+
fetch_repos_json,
|
|
12
|
+
public_repos_json,
|
|
13
|
+
set_repo_secret,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def test_fetch_repos_json_uses_authenticated_user_repos_when_owner_matches(
|
|
18
|
+
httpx2_mock: respx.Router,
|
|
19
|
+
):
|
|
20
|
+
httpx2_mock.get(f"{API_BASE}/user").mock(
|
|
21
|
+
return_value=httpx.Response(200, json={"login": "hugoh"})
|
|
22
|
+
)
|
|
23
|
+
httpx2_mock.get(f"{API_BASE}/user/repos").mock(
|
|
24
|
+
return_value=httpx.Response(200, json=[{"name": "a"}])
|
|
25
|
+
)
|
|
26
|
+
assert await fetch_repos_json("hugoh") == [{"name": "a"}]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def test_fetch_repos_json_falls_back_to_public_repos_for_other_owners(
|
|
30
|
+
httpx2_mock: respx.Router,
|
|
31
|
+
):
|
|
32
|
+
httpx2_mock.get(f"{API_BASE}/user").mock(
|
|
33
|
+
return_value=httpx.Response(200, json={"login": "hugoh"})
|
|
34
|
+
)
|
|
35
|
+
httpx2_mock.get(f"{API_BASE}/users/someorg/repos").mock(
|
|
36
|
+
return_value=httpx.Response(200, json=[{"name": "b"}])
|
|
37
|
+
)
|
|
38
|
+
assert await fetch_repos_json("someorg") == [{"name": "b"}]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def test_fetch_repos_json_follows_pagination_link_header(
|
|
42
|
+
httpx2_mock: respx.Router,
|
|
43
|
+
):
|
|
44
|
+
httpx2_mock.get(f"{API_BASE}/user").mock(
|
|
45
|
+
return_value=httpx.Response(200, json={"login": "hugoh"})
|
|
46
|
+
)
|
|
47
|
+
# respx routes are tried in registration order and a route with no
|
|
48
|
+
# `params` constraint matches any query string -- the page=2 route must
|
|
49
|
+
# be registered first, or the unconstrained page-1 route below would
|
|
50
|
+
# swallow it too and paginated would loop on page1 forever.
|
|
51
|
+
httpx2_mock.get(f"{API_BASE}/user/repos", params={"page": "2"}).mock(
|
|
52
|
+
return_value=httpx.Response(200, json=[{"name": "page2"}])
|
|
53
|
+
)
|
|
54
|
+
httpx2_mock.get(f"{API_BASE}/user/repos").mock(
|
|
55
|
+
return_value=httpx.Response(
|
|
56
|
+
200,
|
|
57
|
+
json=[{"name": "page1"}],
|
|
58
|
+
headers={"Link": f'<{API_BASE}/user/repos?page=2>; rel="next"'},
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
assert await fetch_repos_json("hugoh") == [{"name": "page1"}, {"name": "page2"}]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def test_public_repos_json_uses_public_users_endpoint_even_for_self(
|
|
65
|
+
httpx2_mock: respx.Router,
|
|
66
|
+
):
|
|
67
|
+
# /users/{owner}/repos only ever returns public repos, even when owner is
|
|
68
|
+
# the authenticated user -- unlike fetch_repos_json, no /user call is
|
|
69
|
+
# needed to check whether owner is the viewer.
|
|
70
|
+
httpx2_mock.get(f"{API_BASE}/users/hugoh/repos").mock(
|
|
71
|
+
return_value=httpx.Response(200, json=[{"name": "public-repo"}])
|
|
72
|
+
)
|
|
73
|
+
assert await public_repos_json("hugoh") == [{"name": "public-repo"}]
|
|
74
|
+
assert not any(call.request.url.path == "/user" for call in httpx2_mock.calls)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_public_repos_json_follows_pagination_link_header(
|
|
78
|
+
httpx2_mock: respx.Router,
|
|
79
|
+
):
|
|
80
|
+
httpx2_mock.get(f"{API_BASE}/users/hugoh/repos", params={"page": "2"}).mock(
|
|
81
|
+
return_value=httpx.Response(200, json=[{"name": "page2"}])
|
|
82
|
+
)
|
|
83
|
+
httpx2_mock.get(f"{API_BASE}/users/hugoh/repos").mock(
|
|
84
|
+
return_value=httpx.Response(
|
|
85
|
+
200,
|
|
86
|
+
json=[{"name": "page1"}],
|
|
87
|
+
headers={"Link": f'<{API_BASE}/users/hugoh/repos?page=2>; rel="next"'},
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
assert await public_repos_json("hugoh") == [{"name": "page1"}, {"name": "page2"}]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_encrypt_secret_value_round_trips_through_sealed_box():
|
|
94
|
+
private_key = public.PrivateKey.generate()
|
|
95
|
+
public_key_b64 = private_key.public_key.encode(encoding.Base64Encoder).decode(
|
|
96
|
+
"utf-8"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
ciphertext_b64 = encrypt_secret_value(public_key_b64, "super-secret-value")
|
|
100
|
+
|
|
101
|
+
decrypted = public.SealedBox(private_key).decrypt(base64.b64decode(ciphertext_b64))
|
|
102
|
+
assert decrypted == b"super-secret-value"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def test_set_repo_secret_encrypts_and_puts_with_key_id(monkeypatch):
|
|
106
|
+
private_key = public.PrivateKey.generate()
|
|
107
|
+
public_key_b64 = private_key.public_key.encode(encoding.Base64Encoder).decode(
|
|
108
|
+
"utf-8"
|
|
109
|
+
)
|
|
110
|
+
calls = []
|
|
111
|
+
|
|
112
|
+
async def fake_api_json(method, path, **kwargs):
|
|
113
|
+
if method == "GET":
|
|
114
|
+
return {"key": public_key_b64, "key_id": "key-id-123"}
|
|
115
|
+
calls.append((method, path, kwargs.get("json")))
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
monkeypatch.setattr(endpoints, "api_json", fake_api_json)
|
|
119
|
+
await set_repo_secret("hugoh", "repo", "NAME", "the-value")
|
|
120
|
+
|
|
121
|
+
assert len(calls) == 1
|
|
122
|
+
method, path, body = calls[0]
|
|
123
|
+
assert method == "PUT"
|
|
124
|
+
assert path == "/repos/hugoh/repo/actions/secrets/NAME"
|
|
125
|
+
assert body["key_id"] == "key-id-123"
|
|
126
|
+
|
|
127
|
+
decrypted = public.SealedBox(private_key).decrypt(
|
|
128
|
+
base64.b64decode(body["encrypted_value"])
|
|
129
|
+
)
|
|
130
|
+
assert decrypted == b"the-value"
|