commitguardian 0.1.0__py3-none-any.whl
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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
"""A small GitHub REST API client for the CommitGuard GitHub App.
|
|
2
|
+
|
|
3
|
+
Only the operations CommitGuard needs exist; there is no generic "call any
|
|
4
|
+
endpoint" method. Commit metadata is read from Git objects (see
|
|
5
|
+
:mod:`commitguard.github.repositories`), not from the API, so the App analyses
|
|
6
|
+
exactly the bytes the GitHub Action and the Git hooks analyse.
|
|
7
|
+
|
|
8
|
+
Security properties:
|
|
9
|
+
|
|
10
|
+
* HTTPS only, to a fixed API base URL; URLs are built from constant route
|
|
11
|
+
templates and validated, percent-encoded path segments - webhook data can
|
|
12
|
+
never choose a host (no SSRF);
|
|
13
|
+
* redirects are never followed (a redirect could forward the ``Authorization``
|
|
14
|
+
header elsewhere); only the HTTPS handler is installed (no ``file:``/``ftp:``);
|
|
15
|
+
* pagination links must point back to the API base URL;
|
|
16
|
+
* responses are size-limited and parsed as JSON data only;
|
|
17
|
+
* credentials are :class:`~commitguard.security.secrets.Secret` values, sent only
|
|
18
|
+
in the ``Authorization`` header and never included in errors or logs;
|
|
19
|
+
* bounded retries: rate limits wait for ``Retry-After``/``X-RateLimit-Reset``
|
|
20
|
+
up to a cap, 5xx and network errors back off exponentially, and only
|
|
21
|
+
idempotent requests are retried after a server or network error.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import json
|
|
26
|
+
import random
|
|
27
|
+
import ssl
|
|
28
|
+
import time
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.request
|
|
31
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from http.client import HTTPException
|
|
35
|
+
from typing import Any, Protocol
|
|
36
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
|
|
39
|
+
|
|
40
|
+
from commitguard import __version__
|
|
41
|
+
from commitguard.github.errors import (
|
|
42
|
+
GitHubAPIError,
|
|
43
|
+
GitHubConflictError,
|
|
44
|
+
GitHubForbiddenError,
|
|
45
|
+
GitHubNotFoundError,
|
|
46
|
+
GitHubRateLimitError,
|
|
47
|
+
GitHubServerError,
|
|
48
|
+
GitHubUnauthorizedError,
|
|
49
|
+
GitHubUnavailableError,
|
|
50
|
+
GitHubValidationError,
|
|
51
|
+
)
|
|
52
|
+
from commitguard.github.identifiers import (
|
|
53
|
+
MAX_GITHUB_ID,
|
|
54
|
+
AccountType,
|
|
55
|
+
RepositoryRef,
|
|
56
|
+
validate_login,
|
|
57
|
+
validate_repository_name,
|
|
58
|
+
)
|
|
59
|
+
from commitguard.observability.logging import get_logger
|
|
60
|
+
from commitguard.observability.metrics import (
|
|
61
|
+
GITHUB_API_ERRORS,
|
|
62
|
+
GITHUB_RATE_LIMITS,
|
|
63
|
+
Metrics,
|
|
64
|
+
NullMetrics,
|
|
65
|
+
)
|
|
66
|
+
from commitguard.security.secrets import Secret, register_secret
|
|
67
|
+
from commitguard.security.validation import validate_git_sha
|
|
68
|
+
|
|
69
|
+
API_URL = "https://api.github.com"
|
|
70
|
+
WEB_URL = "https://github.com"
|
|
71
|
+
API_VERSION = "2022-11-28"
|
|
72
|
+
MAX_RESPONSE_BYTES = 10 * 1024 * 1024
|
|
73
|
+
DEFAULT_TIMEOUT_SECONDS = 20.0
|
|
74
|
+
MAX_PAGES = 100
|
|
75
|
+
PER_PAGE = 100
|
|
76
|
+
MAX_CONTENT_BYTES = 512 * 1024
|
|
77
|
+
|
|
78
|
+
log = get_logger(__name__)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
# Transport
|
|
83
|
+
# --------------------------------------------------------------------------- #
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class HttpRequest:
|
|
86
|
+
method: str
|
|
87
|
+
url: str
|
|
88
|
+
headers: Mapping[str, str]
|
|
89
|
+
body: bytes | None = None
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str: # headers carry the Authorization credential
|
|
92
|
+
return f"HttpRequest({self.method} {self.url})"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class HttpResponse:
|
|
97
|
+
status: int
|
|
98
|
+
headers: Mapping[str, str] = field(default_factory=dict) # lower-case names
|
|
99
|
+
body: bytes = b""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TransportError(Exception):
|
|
103
|
+
"""No HTTP response was received (network failure, TLS error, timeout)."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, reason: str, *, timeout: bool = False) -> None:
|
|
106
|
+
self.timeout = timeout
|
|
107
|
+
super().__init__(reason)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Transport(Protocol):
|
|
111
|
+
def send(self, request: HttpRequest, *, timeout: float) -> HttpResponse: ...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
|
|
115
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
116
|
+
return None # urllib then raises HTTPError carrying the 3xx status
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class UrllibTransport:
|
|
120
|
+
"""Standard-library HTTPS transport (certificate verification on)."""
|
|
121
|
+
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
context = ssl.create_default_context()
|
|
124
|
+
opener = urllib.request.OpenerDirector()
|
|
125
|
+
for handler in (
|
|
126
|
+
urllib.request.ProxyHandler(), # honours HTTPS_PROXY set by the operator
|
|
127
|
+
urllib.request.UnknownHandler(),
|
|
128
|
+
urllib.request.HTTPSHandler(context=context),
|
|
129
|
+
urllib.request.HTTPDefaultErrorHandler(),
|
|
130
|
+
_RefuseRedirects(),
|
|
131
|
+
urllib.request.HTTPErrorProcessor(),
|
|
132
|
+
):
|
|
133
|
+
opener.add_handler(handler)
|
|
134
|
+
self._opener = opener
|
|
135
|
+
|
|
136
|
+
def send(self, request: HttpRequest, *, timeout: float) -> HttpResponse:
|
|
137
|
+
if urlsplit(request.url).scheme != "https":
|
|
138
|
+
raise TransportError("refusing non-HTTPS request")
|
|
139
|
+
req = urllib.request.Request( # noqa: S310 - scheme checked above, https handler only
|
|
140
|
+
request.url, data=request.body, method=request.method, headers=dict(request.headers)
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
with self._opener.open(req, timeout=timeout) as response:
|
|
144
|
+
body = response.read(MAX_RESPONSE_BYTES + 1)
|
|
145
|
+
status = int(response.status)
|
|
146
|
+
headers = {k.lower(): v for k, v in response.headers.items()}
|
|
147
|
+
except urllib.error.HTTPError as exc:
|
|
148
|
+
body = exc.read(MAX_RESPONSE_BYTES + 1) if exc.fp is not None else b""
|
|
149
|
+
status = int(exc.code)
|
|
150
|
+
headers = {k.lower(): v for k, v in (exc.headers or {}).items()}
|
|
151
|
+
except TimeoutError:
|
|
152
|
+
raise TransportError("request timed out", timeout=True) from None
|
|
153
|
+
except (urllib.error.URLError, ssl.SSLError, HTTPException, OSError) as exc:
|
|
154
|
+
raise TransportError(f"network error ({type(exc).__name__})") from None
|
|
155
|
+
if len(body) > MAX_RESPONSE_BYTES:
|
|
156
|
+
raise TransportError("response too large")
|
|
157
|
+
return HttpResponse(status=status, headers=headers, body=body)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------- #
|
|
161
|
+
# Response models (only the fields CommitGuard uses)
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
class _Loose(BaseModel):
|
|
164
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _permissions(value: object) -> dict[str, str]:
|
|
168
|
+
if not isinstance(value, Mapping):
|
|
169
|
+
return {}
|
|
170
|
+
return {
|
|
171
|
+
str(k)[:64]: str(v)[:16]
|
|
172
|
+
for k, v in value.items()
|
|
173
|
+
if isinstance(k, str) and isinstance(v, str)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class AccountInfo(_Loose):
|
|
178
|
+
id: int
|
|
179
|
+
login: str
|
|
180
|
+
type: AccountType
|
|
181
|
+
|
|
182
|
+
@field_validator("login")
|
|
183
|
+
@classmethod
|
|
184
|
+
def _login(cls, value: str) -> str:
|
|
185
|
+
return validate_login(value)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class AppInfo(_Loose):
|
|
189
|
+
id: int
|
|
190
|
+
slug: str
|
|
191
|
+
name: str
|
|
192
|
+
permissions: dict[str, str] = {}
|
|
193
|
+
events: tuple[str, ...] = ()
|
|
194
|
+
|
|
195
|
+
@field_validator("permissions", mode="before")
|
|
196
|
+
@classmethod
|
|
197
|
+
def _perms(cls, value: object) -> dict[str, str]:
|
|
198
|
+
return _permissions(value)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class InstallationInfo(_Loose):
|
|
202
|
+
id: int
|
|
203
|
+
account: AccountInfo
|
|
204
|
+
repository_selection: str = "selected"
|
|
205
|
+
permissions: dict[str, str] = {}
|
|
206
|
+
suspended_at: str | None = None
|
|
207
|
+
|
|
208
|
+
@field_validator("permissions", mode="before")
|
|
209
|
+
@classmethod
|
|
210
|
+
def _perms(cls, value: object) -> dict[str, str]:
|
|
211
|
+
return _permissions(value)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class RepositoryInfo(_Loose):
|
|
215
|
+
id: int
|
|
216
|
+
name: str
|
|
217
|
+
owner: AccountInfo
|
|
218
|
+
default_branch: str | None = None
|
|
219
|
+
private: bool = True
|
|
220
|
+
archived: bool = False
|
|
221
|
+
|
|
222
|
+
@field_validator("name")
|
|
223
|
+
@classmethod
|
|
224
|
+
def _name(cls, value: str) -> str:
|
|
225
|
+
return validate_repository_name(value)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def ref(self) -> RepositoryRef:
|
|
229
|
+
return RepositoryRef(id=self.id, owner=self.owner.login, name=self.name)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class _PRRepo(_Loose):
|
|
233
|
+
id: int
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class _PRSide(_Loose):
|
|
237
|
+
sha: str
|
|
238
|
+
ref: str | None = None
|
|
239
|
+
repo: _PRRepo | None = None
|
|
240
|
+
|
|
241
|
+
@field_validator("sha")
|
|
242
|
+
@classmethod
|
|
243
|
+
def _sha(cls, value: str) -> str:
|
|
244
|
+
return validate_git_sha(value)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class PullRequestInfo(_Loose):
|
|
248
|
+
number: int
|
|
249
|
+
state: str
|
|
250
|
+
merged: bool | None = None
|
|
251
|
+
head: _PRSide
|
|
252
|
+
base: _PRSide
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class CheckRunInfo(_Loose):
|
|
256
|
+
id: int
|
|
257
|
+
head_sha: str
|
|
258
|
+
status: str
|
|
259
|
+
|
|
260
|
+
@field_validator("id")
|
|
261
|
+
@classmethod
|
|
262
|
+
def _id(cls, value: int) -> int:
|
|
263
|
+
if not 0 < value < MAX_GITHUB_ID:
|
|
264
|
+
raise ValueError("invalid check run id")
|
|
265
|
+
return value
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class UserInfo(_Loose):
|
|
269
|
+
id: int
|
|
270
|
+
login: str
|
|
271
|
+
|
|
272
|
+
@field_validator("id")
|
|
273
|
+
@classmethod
|
|
274
|
+
def _id(cls, value: int) -> int:
|
|
275
|
+
if not 0 < value < MAX_GITHUB_ID:
|
|
276
|
+
raise ValueError("invalid user id")
|
|
277
|
+
return value
|
|
278
|
+
|
|
279
|
+
@field_validator("login")
|
|
280
|
+
@classmethod
|
|
281
|
+
def _login(cls, value: str) -> str:
|
|
282
|
+
return validate_login(value)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class _StatusChecks(_Loose):
|
|
286
|
+
contexts: tuple[str, ...] = ()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class _BranchProtectionSummary(_Loose):
|
|
290
|
+
enabled: bool | None = None
|
|
291
|
+
required_status_checks: _StatusChecks | None = None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class _BranchCommit(_Loose):
|
|
295
|
+
sha: str
|
|
296
|
+
|
|
297
|
+
@field_validator("sha")
|
|
298
|
+
@classmethod
|
|
299
|
+
def _sha(cls, value: str) -> str:
|
|
300
|
+
return validate_git_sha(value)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class BranchInfo(_Loose):
|
|
304
|
+
name: str
|
|
305
|
+
protected: bool
|
|
306
|
+
protection: _BranchProtectionSummary | None = None
|
|
307
|
+
commit: _BranchCommit | None = None # the branch head, from GitHub
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def required_contexts(self) -> tuple[str, ...]:
|
|
311
|
+
checks = self.protection.required_status_checks if self.protection else None
|
|
312
|
+
return checks.contexts if checks else ()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class BranchRule(_Loose):
|
|
316
|
+
type: str
|
|
317
|
+
parameters: dict[str, Any] | None = None
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def required_contexts(self) -> tuple[str, ...]:
|
|
321
|
+
if self.type != "required_status_checks" or not self.parameters:
|
|
322
|
+
return ()
|
|
323
|
+
checks = self.parameters.get("required_status_checks")
|
|
324
|
+
if not isinstance(checks, list):
|
|
325
|
+
return ()
|
|
326
|
+
return tuple(
|
|
327
|
+
str(c["context"])[:200]
|
|
328
|
+
for c in checks
|
|
329
|
+
if isinstance(c, Mapping) and isinstance(c.get("context"), str)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class ContentEntry(_Loose):
|
|
334
|
+
name: str
|
|
335
|
+
path: str
|
|
336
|
+
type: str
|
|
337
|
+
size: int = 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@dataclass(frozen=True, slots=True)
|
|
341
|
+
class OAuthGrant:
|
|
342
|
+
access_token: Secret
|
|
343
|
+
expires_in: int | None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass(frozen=True, slots=True)
|
|
347
|
+
class InstallationTokenGrant:
|
|
348
|
+
token: Secret
|
|
349
|
+
expires_at: datetime
|
|
350
|
+
permissions: Mapping[str, str]
|
|
351
|
+
repository_ids: tuple[int, ...]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class _TokenResponse(_Loose):
|
|
355
|
+
token: str
|
|
356
|
+
expires_at: datetime
|
|
357
|
+
permissions: dict[str, str] = {}
|
|
358
|
+
repositories: tuple[_PRRepo, ...] = ()
|
|
359
|
+
|
|
360
|
+
@field_validator("permissions", mode="before")
|
|
361
|
+
@classmethod
|
|
362
|
+
def _perms(cls, value: object) -> dict[str, str]:
|
|
363
|
+
return _permissions(value)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# --------------------------------------------------------------------------- #
|
|
367
|
+
# Client
|
|
368
|
+
# --------------------------------------------------------------------------- #
|
|
369
|
+
@dataclass(frozen=True, slots=True)
|
|
370
|
+
class RetryPolicy:
|
|
371
|
+
max_attempts: int = 3
|
|
372
|
+
base_delay: float = 1.0
|
|
373
|
+
max_delay: float = 16.0
|
|
374
|
+
max_rate_limit_wait: float = 60.0 # longer waits fail instead of holding a worker
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
_ERRORS: dict[int, type[GitHubAPIError]] = {
|
|
378
|
+
401: GitHubUnauthorizedError,
|
|
379
|
+
403: GitHubForbiddenError,
|
|
380
|
+
404: GitHubNotFoundError,
|
|
381
|
+
409: GitHubConflictError,
|
|
382
|
+
422: GitHubValidationError,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _validate_api_url(url: str) -> str:
|
|
387
|
+
parts = urlsplit(url)
|
|
388
|
+
if (
|
|
389
|
+
parts.scheme != "https"
|
|
390
|
+
or not parts.hostname
|
|
391
|
+
or parts.username
|
|
392
|
+
or parts.password
|
|
393
|
+
or parts.query
|
|
394
|
+
or parts.fragment
|
|
395
|
+
):
|
|
396
|
+
raise ValueError("GitHub API URL must be an https URL without credentials or query")
|
|
397
|
+
return url.rstrip("/")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _segment(value: str) -> str:
|
|
401
|
+
return quote(value, safe="")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class GitHubClient:
|
|
405
|
+
def __init__(
|
|
406
|
+
self,
|
|
407
|
+
transport: Transport | None = None,
|
|
408
|
+
*,
|
|
409
|
+
api_url: str = API_URL,
|
|
410
|
+
web_url: str = WEB_URL,
|
|
411
|
+
retry: RetryPolicy | None = None,
|
|
412
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
413
|
+
clock: Callable[[], float] = time.time,
|
|
414
|
+
jitter: Callable[[float], float] | None = None,
|
|
415
|
+
metrics: Metrics | None = None,
|
|
416
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
417
|
+
) -> None:
|
|
418
|
+
self._transport = transport or UrllibTransport()
|
|
419
|
+
self._api_url = _validate_api_url(api_url)
|
|
420
|
+
self._web_url = _validate_api_url(web_url)
|
|
421
|
+
self._retry = retry or RetryPolicy()
|
|
422
|
+
self._sleep = sleep
|
|
423
|
+
self._clock = clock
|
|
424
|
+
self._jitter = jitter or (lambda delay: delay * random.uniform(0.5, 1.0)) # noqa: S311
|
|
425
|
+
self._metrics = metrics or NullMetrics()
|
|
426
|
+
self._timeout = timeout
|
|
427
|
+
|
|
428
|
+
# -- plumbing ------------------------------------------------------- #
|
|
429
|
+
def _headers(self, credential: Secret, has_body: bool) -> dict[str, str]:
|
|
430
|
+
headers = {
|
|
431
|
+
"Accept": "application/vnd.github+json",
|
|
432
|
+
"Authorization": f"Bearer {credential.reveal()}",
|
|
433
|
+
"User-Agent": f"CommitGuard-App/{__version__}",
|
|
434
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
435
|
+
}
|
|
436
|
+
if has_body:
|
|
437
|
+
headers["Content-Type"] = "application/json"
|
|
438
|
+
return headers
|
|
439
|
+
|
|
440
|
+
def _backoff(self, attempt: int) -> float:
|
|
441
|
+
delay = min(self._retry.max_delay, self._retry.base_delay * (2 ** (attempt - 1)))
|
|
442
|
+
return max(0.0, self._jitter(delay))
|
|
443
|
+
|
|
444
|
+
def _rate_limit_wait(self, response: HttpResponse) -> float | None:
|
|
445
|
+
headers = response.headers
|
|
446
|
+
limited = response.status == 429 or (
|
|
447
|
+
response.status == 403
|
|
448
|
+
and (
|
|
449
|
+
headers.get("x-ratelimit-remaining") == "0"
|
|
450
|
+
or "retry-after" in headers
|
|
451
|
+
or b"rate limit" in response.body[:2000].lower()
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
if not limited:
|
|
455
|
+
return None
|
|
456
|
+
retry_after = headers.get("retry-after", "")
|
|
457
|
+
if retry_after.isascii() and retry_after.isdigit():
|
|
458
|
+
return float(retry_after)
|
|
459
|
+
reset = headers.get("x-ratelimit-reset", "")
|
|
460
|
+
if headers.get("x-ratelimit-remaining") == "0" and reset.isascii() and reset.isdigit():
|
|
461
|
+
return max(0.0, float(reset) - self._clock())
|
|
462
|
+
return 60.0 # secondary rate limit without guidance: GitHub asks for at least a minute
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def _detail(response: HttpResponse) -> str:
|
|
466
|
+
try:
|
|
467
|
+
document = json.loads(response.body[:65536].decode("utf-8"))
|
|
468
|
+
except (UnicodeDecodeError, ValueError):
|
|
469
|
+
return ""
|
|
470
|
+
message = document.get("message") if isinstance(document, dict) else None
|
|
471
|
+
return message if isinstance(message, str) else ""
|
|
472
|
+
|
|
473
|
+
def _send(
|
|
474
|
+
self,
|
|
475
|
+
operation: str,
|
|
476
|
+
method: str,
|
|
477
|
+
url: str,
|
|
478
|
+
credential: Secret,
|
|
479
|
+
*,
|
|
480
|
+
payload: Mapping[str, Any] | None = None,
|
|
481
|
+
idempotent: bool = True,
|
|
482
|
+
) -> HttpResponse:
|
|
483
|
+
body = (
|
|
484
|
+
json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode("ascii")
|
|
485
|
+
if payload is not None
|
|
486
|
+
else None
|
|
487
|
+
)
|
|
488
|
+
request = HttpRequest(method, url, self._headers(credential, body is not None), body)
|
|
489
|
+
attempts = max(1, self._retry.max_attempts)
|
|
490
|
+
for attempt in range(1, attempts + 1):
|
|
491
|
+
try:
|
|
492
|
+
response = self._transport.send(request, timeout=self._timeout)
|
|
493
|
+
except TransportError as exc:
|
|
494
|
+
self._metrics.increment(GITHUB_API_ERRORS, category="unavailable")
|
|
495
|
+
log.warning("github_api_unavailable", operation=operation, attempt=attempt)
|
|
496
|
+
if idempotent and attempt < attempts:
|
|
497
|
+
self._sleep(self._backoff(attempt))
|
|
498
|
+
continue
|
|
499
|
+
raise GitHubUnavailableError(
|
|
500
|
+
operation, detail="request timed out" if exc.timeout else "network error"
|
|
501
|
+
) from None
|
|
502
|
+
if 200 <= response.status < 300:
|
|
503
|
+
return response
|
|
504
|
+
request_id = response.headers.get("x-github-request-id")
|
|
505
|
+
wait = self._rate_limit_wait(response)
|
|
506
|
+
if wait is not None:
|
|
507
|
+
self._metrics.increment(GITHUB_RATE_LIMITS)
|
|
508
|
+
log.warning(
|
|
509
|
+
"github_rate_limited", operation=operation, attempt=attempt, wait_seconds=wait
|
|
510
|
+
)
|
|
511
|
+
if attempt < attempts and wait <= self._retry.max_rate_limit_wait:
|
|
512
|
+
self._sleep(max(wait, 1.0))
|
|
513
|
+
continue
|
|
514
|
+
raise GitHubRateLimitError(
|
|
515
|
+
operation, retry_after=wait, status=response.status, request_id=request_id
|
|
516
|
+
)
|
|
517
|
+
self._metrics.increment(GITHUB_API_ERRORS, category=str(response.status))
|
|
518
|
+
if response.status >= 500:
|
|
519
|
+
log.warning(
|
|
520
|
+
"github_api_server_error",
|
|
521
|
+
operation=operation,
|
|
522
|
+
attempt=attempt,
|
|
523
|
+
status=response.status,
|
|
524
|
+
)
|
|
525
|
+
if idempotent and attempt < attempts:
|
|
526
|
+
self._sleep(self._backoff(attempt))
|
|
527
|
+
continue
|
|
528
|
+
raise GitHubServerError(
|
|
529
|
+
operation,
|
|
530
|
+
status=response.status,
|
|
531
|
+
detail=self._detail(response),
|
|
532
|
+
request_id=request_id,
|
|
533
|
+
)
|
|
534
|
+
error = _ERRORS.get(response.status, GitHubAPIError)
|
|
535
|
+
raise error(
|
|
536
|
+
operation,
|
|
537
|
+
status=response.status,
|
|
538
|
+
detail=self._detail(response)
|
|
539
|
+
or ("unexpected redirect" if 300 <= response.status < 400 else ""),
|
|
540
|
+
request_id=request_id,
|
|
541
|
+
)
|
|
542
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
543
|
+
|
|
544
|
+
@staticmethod
|
|
545
|
+
def _json(operation: str, response: HttpResponse) -> Any:
|
|
546
|
+
try:
|
|
547
|
+
return json.loads(response.body.decode("utf-8"))
|
|
548
|
+
except (UnicodeDecodeError, ValueError, RecursionError):
|
|
549
|
+
raise GitHubAPIError(
|
|
550
|
+
operation, status=response.status, detail="malformed JSON response"
|
|
551
|
+
) from None
|
|
552
|
+
|
|
553
|
+
def _model[M: BaseModel](self, operation: str, response: HttpResponse, model: type[M]) -> M:
|
|
554
|
+
document = self._json(operation, response)
|
|
555
|
+
try:
|
|
556
|
+
return model.model_validate(document)
|
|
557
|
+
except (ValidationError, ValueError):
|
|
558
|
+
raise GitHubAPIError(
|
|
559
|
+
operation, status=response.status, detail="unexpected response shape"
|
|
560
|
+
) from None
|
|
561
|
+
|
|
562
|
+
def _url(self, path: str, query: Mapping[str, str | int] | None = None) -> str:
|
|
563
|
+
return self._api_url + path + (f"?{urlencode(query)}" if query else "")
|
|
564
|
+
|
|
565
|
+
def _paginate(
|
|
566
|
+
self,
|
|
567
|
+
operation: str,
|
|
568
|
+
path: str,
|
|
569
|
+
credential: Secret,
|
|
570
|
+
*,
|
|
571
|
+
key: str | None = None,
|
|
572
|
+
max_pages: int | None = None,
|
|
573
|
+
) -> Iterator[Any]:
|
|
574
|
+
limit = MAX_PAGES if max_pages is None else max_pages
|
|
575
|
+
url: str | None = self._url(path, {"per_page": PER_PAGE})
|
|
576
|
+
pages = 0
|
|
577
|
+
while url is not None:
|
|
578
|
+
pages += 1
|
|
579
|
+
if pages > limit:
|
|
580
|
+
raise GitHubAPIError(operation, detail=f"more than {limit} pages")
|
|
581
|
+
response = self._send(operation, "GET", url, credential)
|
|
582
|
+
document = self._json(operation, response)
|
|
583
|
+
items = document.get(key) if key and isinstance(document, dict) else document
|
|
584
|
+
if not isinstance(items, list):
|
|
585
|
+
raise GitHubAPIError(operation, detail="unexpected response shape")
|
|
586
|
+
yield from items
|
|
587
|
+
url = self._next_link(operation, response.headers.get("link", ""))
|
|
588
|
+
|
|
589
|
+
def _next_link(self, operation: str, header: str) -> str | None:
|
|
590
|
+
for part in header.split(","):
|
|
591
|
+
section = part.strip()
|
|
592
|
+
if not section.endswith('rel="next"'):
|
|
593
|
+
continue
|
|
594
|
+
target = section.split(";", 1)[0].strip()
|
|
595
|
+
if not (target.startswith("<") and target.endswith(">")):
|
|
596
|
+
raise GitHubAPIError(operation, detail="malformed pagination link")
|
|
597
|
+
link = target[1:-1]
|
|
598
|
+
if not link.startswith(self._api_url + "/") or urlsplit(link).fragment:
|
|
599
|
+
raise GitHubAPIError(operation, detail="pagination link to an unexpected location")
|
|
600
|
+
return link
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
# -- App (JWT) operations ------------------------------------------- #
|
|
604
|
+
def get_app(self, jwt: Secret) -> AppInfo:
|
|
605
|
+
op = "GET /app"
|
|
606
|
+
return self._model(op, self._send(op, "GET", self._url("/app"), jwt), AppInfo)
|
|
607
|
+
|
|
608
|
+
def list_app_installations(self, jwt: Secret) -> list[InstallationInfo]:
|
|
609
|
+
op = "GET /app/installations"
|
|
610
|
+
items = self._paginate(op, "/app/installations", jwt)
|
|
611
|
+
try:
|
|
612
|
+
return [InstallationInfo.model_validate(item) for item in items]
|
|
613
|
+
except ValidationError:
|
|
614
|
+
raise GitHubAPIError(op, detail="unexpected response shape") from None
|
|
615
|
+
|
|
616
|
+
def get_installation(self, jwt: Secret, installation_id: int) -> InstallationInfo:
|
|
617
|
+
op = "GET /app/installations/{installation_id}"
|
|
618
|
+
url = self._url(f"/app/installations/{int(installation_id)}")
|
|
619
|
+
return self._model(op, self._send(op, "GET", url, jwt), InstallationInfo)
|
|
620
|
+
|
|
621
|
+
def create_installation_token(
|
|
622
|
+
self,
|
|
623
|
+
jwt: Secret,
|
|
624
|
+
installation_id: int,
|
|
625
|
+
*,
|
|
626
|
+
repository_ids: Sequence[int] | None,
|
|
627
|
+
permissions: Mapping[str, str],
|
|
628
|
+
) -> InstallationTokenGrant:
|
|
629
|
+
op = "POST /app/installations/{installation_id}/access_tokens"
|
|
630
|
+
payload: dict[str, Any] = {"permissions": dict(permissions)}
|
|
631
|
+
if repository_ids is not None:
|
|
632
|
+
payload["repository_ids"] = [int(r) for r in repository_ids]
|
|
633
|
+
url = self._url(f"/app/installations/{int(installation_id)}/access_tokens")
|
|
634
|
+
# Minting a token has no side effect beyond the token itself: safe to retry.
|
|
635
|
+
response = self._send(op, "POST", url, jwt, payload=payload, idempotent=True)
|
|
636
|
+
parsed = self._model(op, response, _TokenResponse)
|
|
637
|
+
if not parsed.token or len(parsed.token) > 1024:
|
|
638
|
+
raise GitHubAPIError(op, status=response.status, detail="invalid token response")
|
|
639
|
+
token = Secret(parsed.token)
|
|
640
|
+
register_secret(token)
|
|
641
|
+
return InstallationTokenGrant(
|
|
642
|
+
token=token,
|
|
643
|
+
expires_at=parsed.expires_at,
|
|
644
|
+
permissions=parsed.permissions,
|
|
645
|
+
repository_ids=tuple(r.id for r in parsed.repositories),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# -- installation token operations ---------------------------------- #
|
|
649
|
+
def list_installation_repositories(self, token: Secret) -> list[RepositoryInfo]:
|
|
650
|
+
op = "GET /installation/repositories"
|
|
651
|
+
items = self._paginate(op, "/installation/repositories", token, key="repositories")
|
|
652
|
+
try:
|
|
653
|
+
return [RepositoryInfo.model_validate(item) for item in items]
|
|
654
|
+
except (ValidationError, ValueError):
|
|
655
|
+
raise GitHubAPIError(op, detail="unexpected response shape") from None
|
|
656
|
+
|
|
657
|
+
def get_repository(self, token: Secret, repository_id: int) -> RepositoryInfo:
|
|
658
|
+
"""Look a repository up by immutable ID (renames cannot redirect the lookup)."""
|
|
659
|
+
op = "GET /repositories/{repository_id}"
|
|
660
|
+
url = self._url(f"/repositories/{int(repository_id)}")
|
|
661
|
+
return self._model(op, self._send(op, "GET", url, token), RepositoryInfo)
|
|
662
|
+
|
|
663
|
+
def _repo_path(self, repository: RepositoryRef) -> str:
|
|
664
|
+
return f"/repos/{_segment(repository.owner)}/{_segment(repository.name)}"
|
|
665
|
+
|
|
666
|
+
def get_pull_request(
|
|
667
|
+
self, token: Secret, repository: RepositoryRef, number: int
|
|
668
|
+
) -> PullRequestInfo:
|
|
669
|
+
op = "GET /repos/{owner}/{repo}/pulls/{pull_number}"
|
|
670
|
+
url = self._url(f"{self._repo_path(repository)}/pulls/{int(number)}")
|
|
671
|
+
return self._model(op, self._send(op, "GET", url, token), PullRequestInfo)
|
|
672
|
+
|
|
673
|
+
def create_check_run(
|
|
674
|
+
self, token: Secret, repository: RepositoryRef, payload: Mapping[str, Any]
|
|
675
|
+
) -> CheckRunInfo:
|
|
676
|
+
op = "POST /repos/{owner}/{repo}/check-runs"
|
|
677
|
+
url = self._url(f"{self._repo_path(repository)}/check-runs")
|
|
678
|
+
# Not retried after a 5xx/network error: a retry could create a duplicate run.
|
|
679
|
+
response = self._send(op, "POST", url, token, payload=payload, idempotent=False)
|
|
680
|
+
return self._model(op, response, CheckRunInfo)
|
|
681
|
+
|
|
682
|
+
def update_check_run(
|
|
683
|
+
self,
|
|
684
|
+
token: Secret,
|
|
685
|
+
repository: RepositoryRef,
|
|
686
|
+
check_run_id: int,
|
|
687
|
+
payload: Mapping[str, Any],
|
|
688
|
+
) -> CheckRunInfo:
|
|
689
|
+
op = "PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}"
|
|
690
|
+
url = self._url(f"{self._repo_path(repository)}/check-runs/{int(check_run_id)}")
|
|
691
|
+
response = self._send(op, "PATCH", url, token, payload=payload, idempotent=True)
|
|
692
|
+
return self._model(op, response, CheckRunInfo)
|
|
693
|
+
|
|
694
|
+
# -- enforcement evidence (installation token) ----------------------- #
|
|
695
|
+
def get_branch(self, token: Secret, repository: RepositoryRef, branch: str) -> BranchInfo:
|
|
696
|
+
op = "GET /repos/{owner}/{repo}/branches/{branch}"
|
|
697
|
+
url = self._url(f"{self._repo_path(repository)}/branches/{_segment(branch)}")
|
|
698
|
+
return self._model(op, self._send(op, "GET", url, token), BranchInfo)
|
|
699
|
+
|
|
700
|
+
def get_branch_rules(
|
|
701
|
+
self, token: Secret, repository: RepositoryRef, branch: str
|
|
702
|
+
) -> list[BranchRule]:
|
|
703
|
+
"""Active ruleset rules for a branch (``GET /repos/{o}/{r}/rules/branches/{b}``)."""
|
|
704
|
+
op = "GET /repos/{owner}/{repo}/rules/branches/{branch}"
|
|
705
|
+
items = self._paginate(
|
|
706
|
+
op, f"{self._repo_path(repository)}/rules/branches/{_segment(branch)}", token
|
|
707
|
+
)
|
|
708
|
+
try:
|
|
709
|
+
return [BranchRule.model_validate(item) for item in items]
|
|
710
|
+
except (ValidationError, ValueError):
|
|
711
|
+
raise GitHubAPIError(op, detail="unexpected response shape") from None
|
|
712
|
+
|
|
713
|
+
def list_directory(
|
|
714
|
+
self, token: Secret, repository: RepositoryRef, path: str, ref: str
|
|
715
|
+
) -> list[ContentEntry]:
|
|
716
|
+
op = "GET /repos/{owner}/{repo}/contents/{path}"
|
|
717
|
+
encoded = "/".join(_segment(part) for part in path.split("/"))
|
|
718
|
+
url = self._url(f"{self._repo_path(repository)}/contents/{encoded}", {"ref": ref})
|
|
719
|
+
document = self._json(op, self._send(op, "GET", url, token))
|
|
720
|
+
if not isinstance(document, list):
|
|
721
|
+
return [] # a file, not a directory
|
|
722
|
+
try:
|
|
723
|
+
return [ContentEntry.model_validate(item) for item in document[:200]]
|
|
724
|
+
except (ValidationError, ValueError):
|
|
725
|
+
raise GitHubAPIError(op, detail="unexpected response shape") from None
|
|
726
|
+
|
|
727
|
+
def get_file_text(
|
|
728
|
+
self, token: Secret, repository: RepositoryRef, path: str, ref: str
|
|
729
|
+
) -> str | None:
|
|
730
|
+
"""A small UTF-8 file's contents, or None when it is too large or not text."""
|
|
731
|
+
op = "GET /repos/{owner}/{repo}/contents/{path}"
|
|
732
|
+
encoded = "/".join(_segment(part) for part in path.split("/"))
|
|
733
|
+
url = self._url(f"{self._repo_path(repository)}/contents/{encoded}", {"ref": ref})
|
|
734
|
+
document = self._json(op, self._send(op, "GET", url, token))
|
|
735
|
+
if not isinstance(document, dict) or document.get("encoding") != "base64":
|
|
736
|
+
return None
|
|
737
|
+
content = document.get("content")
|
|
738
|
+
size = document.get("size")
|
|
739
|
+
if not isinstance(content, str) or not isinstance(size, int) or size > MAX_CONTENT_BYTES:
|
|
740
|
+
return None
|
|
741
|
+
try:
|
|
742
|
+
return base64.b64decode(content, validate=False).decode("utf-8")
|
|
743
|
+
except (ValueError, UnicodeDecodeError):
|
|
744
|
+
return None
|
|
745
|
+
|
|
746
|
+
# -- user authorization (OAuth web flow of the GitHub App) ----------- #
|
|
747
|
+
def authorize_url(
|
|
748
|
+
self, *, client_id: str, redirect_uri: str, state: str, code_challenge: str
|
|
749
|
+
) -> str:
|
|
750
|
+
query = urlencode(
|
|
751
|
+
{
|
|
752
|
+
"client_id": client_id,
|
|
753
|
+
"redirect_uri": redirect_uri,
|
|
754
|
+
"state": state,
|
|
755
|
+
"code_challenge": code_challenge,
|
|
756
|
+
"code_challenge_method": "S256",
|
|
757
|
+
"allow_signup": "false",
|
|
758
|
+
}
|
|
759
|
+
)
|
|
760
|
+
return f"{self._web_url}/login/oauth/authorize?{query}"
|
|
761
|
+
|
|
762
|
+
def exchange_oauth_code(
|
|
763
|
+
self,
|
|
764
|
+
*,
|
|
765
|
+
client_id: str,
|
|
766
|
+
client_secret: Secret,
|
|
767
|
+
code: str,
|
|
768
|
+
redirect_uri: str,
|
|
769
|
+
code_verifier: str,
|
|
770
|
+
) -> OAuthGrant:
|
|
771
|
+
"""Exchange an authorization code for a user access token (never retried)."""
|
|
772
|
+
op = "POST /login/oauth/access_token"
|
|
773
|
+
body = urlencode(
|
|
774
|
+
{
|
|
775
|
+
"client_id": client_id,
|
|
776
|
+
"client_secret": client_secret.reveal(),
|
|
777
|
+
"code": code,
|
|
778
|
+
"redirect_uri": redirect_uri,
|
|
779
|
+
"code_verifier": code_verifier,
|
|
780
|
+
}
|
|
781
|
+
).encode("ascii")
|
|
782
|
+
request = HttpRequest(
|
|
783
|
+
"POST",
|
|
784
|
+
f"{self._web_url}/login/oauth/access_token",
|
|
785
|
+
{
|
|
786
|
+
"Accept": "application/json",
|
|
787
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
788
|
+
"User-Agent": f"CommitGuard-App/{__version__}",
|
|
789
|
+
},
|
|
790
|
+
body,
|
|
791
|
+
)
|
|
792
|
+
try:
|
|
793
|
+
response = self._transport.send(request, timeout=self._timeout)
|
|
794
|
+
except TransportError as exc:
|
|
795
|
+
raise GitHubUnavailableError(
|
|
796
|
+
op, detail="request timed out" if exc.timeout else "network error"
|
|
797
|
+
) from None
|
|
798
|
+
if response.status != 200:
|
|
799
|
+
self._metrics.increment(GITHUB_API_ERRORS, category=str(response.status))
|
|
800
|
+
raise GitHubAPIError(op, status=response.status, detail="token exchange failed")
|
|
801
|
+
document = self._json(op, response)
|
|
802
|
+
if not isinstance(document, dict) or "error" in document:
|
|
803
|
+
raise GitHubUnauthorizedError(op, status=401, detail="authorization code rejected")
|
|
804
|
+
token = document.get("access_token")
|
|
805
|
+
if not isinstance(token, str) or not token or len(token) > 1024:
|
|
806
|
+
raise GitHubAPIError(op, status=response.status, detail="invalid token response")
|
|
807
|
+
expires = document.get("expires_in")
|
|
808
|
+
grant = OAuthGrant(
|
|
809
|
+
access_token=Secret(token),
|
|
810
|
+
expires_in=expires if isinstance(expires, int) else None,
|
|
811
|
+
)
|
|
812
|
+
register_secret(grant.access_token)
|
|
813
|
+
return grant
|
|
814
|
+
|
|
815
|
+
def get_authenticated_user(self, user_token: Secret) -> UserInfo:
|
|
816
|
+
op = "GET /user"
|
|
817
|
+
return self._model(op, self._send(op, "GET", self._url("/user"), user_token), UserInfo)
|
|
818
|
+
|
|
819
|
+
def list_user_installations(self, user_token: Secret) -> list[InstallationInfo]:
|
|
820
|
+
"""Installations of this App the signed-in user can access."""
|
|
821
|
+
op = "GET /user/installations"
|
|
822
|
+
items = self._paginate(op, "/user/installations", user_token, key="installations")
|
|
823
|
+
try:
|
|
824
|
+
return [InstallationInfo.model_validate(item) for item in items]
|
|
825
|
+
except (ValidationError, ValueError):
|
|
826
|
+
raise GitHubAPIError(op, detail="unexpected response shape") from None
|
|
827
|
+
|
|
828
|
+
def list_user_installation_repository_ids(
|
|
829
|
+
self, user_token: Secret, installation_id: int
|
|
830
|
+
) -> list[int]:
|
|
831
|
+
"""IDs of repositories in an installation that the signed-in user can access."""
|
|
832
|
+
op = "GET /user/installations/{installation_id}/repositories"
|
|
833
|
+
items = self._paginate(
|
|
834
|
+
op,
|
|
835
|
+
f"/user/installations/{int(installation_id)}/repositories",
|
|
836
|
+
user_token,
|
|
837
|
+
key="repositories",
|
|
838
|
+
)
|
|
839
|
+
ids: list[int] = []
|
|
840
|
+
for item in items:
|
|
841
|
+
identifier = item.get("id") if isinstance(item, Mapping) else None
|
|
842
|
+
if isinstance(identifier, int) and 0 < identifier < MAX_GITHUB_ID:
|
|
843
|
+
ids.append(identifier)
|
|
844
|
+
return ids
|