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.
Files changed (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. 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