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,209 @@
|
|
|
1
|
+
"""Evidence about how a repository enforces CommitGuard on GitHub.
|
|
2
|
+
|
|
3
|
+
The dashboard shows several independent signals and never merges them into
|
|
4
|
+
a claim they cannot support:
|
|
5
|
+
|
|
6
|
+
* **GitHub Actions** - whether a workflow on the default branch runs
|
|
7
|
+
CommitGuard. The workflow files are read with the installation token
|
|
8
|
+
(Contents: read) and inspected with the same static inspection
|
|
9
|
+
``commitguard doctor`` uses; nothing is executed. Reading failures give
|
|
10
|
+
``unknown``, never ``not_detected``.
|
|
11
|
+
* **Required check** - whether the default branch requires a CommitGuard
|
|
12
|
+
check before merging. Evidence comes from two read-only endpoints available
|
|
13
|
+
with the App's existing permissions:
|
|
14
|
+
|
|
15
|
+
- ``GET /repos/{owner}/{repo}/rules/branches/{branch}``: active rulesets and
|
|
16
|
+
their ``required_status_checks`` rules;
|
|
17
|
+
- ``GET /repos/{owner}/{repo}/branches/{branch}``: the ``protected`` flag
|
|
18
|
+
and, where GitHub includes it, the classic protection's required contexts.
|
|
19
|
+
|
|
20
|
+
``required`` needs a CommitGuard check context (``commitguard-app``,
|
|
21
|
+
``commitguard``) in one of those lists. ``not_required`` needs both
|
|
22
|
+
endpoints to answer and show no protection that could require it (branch
|
|
23
|
+
not protected and no required-status-check rule). Everything else - errors,
|
|
24
|
+
a protected branch whose required contexts are not visible without
|
|
25
|
+
Administration permission (which CommitGuard does not request) - is
|
|
26
|
+
``unknown``.
|
|
27
|
+
* **Merge queue** - ``enabled`` when an active ruleset on the default branch
|
|
28
|
+
has a ``merge_queue`` rule; ``not_enabled`` when both endpoints answer, no
|
|
29
|
+
ruleset requires a merge queue and the branch has no classic protection; a
|
|
30
|
+
protected branch is ``unknown``, because classic branch protection settings
|
|
31
|
+
(which can also require a merge queue) are not visible with the App's
|
|
32
|
+
permissions.
|
|
33
|
+
* **Local hooks** cannot be verified by a server and are always reported as
|
|
34
|
+
such.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from datetime import UTC, datetime
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from commitguard.github.checks import APP_CHECK_NAME
|
|
43
|
+
from commitguard.github.client import GitHubClient
|
|
44
|
+
from commitguard.github.errors import GitHubAPIError, GitHubNotFoundError
|
|
45
|
+
from commitguard.github.identifiers import RepositoryRef
|
|
46
|
+
from commitguard.github.workflow import CHECK_NAME as ACTION_CHECK_NAME
|
|
47
|
+
from commitguard.github.workflow import WORKFLOWS_DIR, inspect_workflow_text
|
|
48
|
+
from commitguard.observability.logging import get_logger
|
|
49
|
+
from commitguard.security.secrets import Secret
|
|
50
|
+
|
|
51
|
+
log = get_logger(__name__)
|
|
52
|
+
|
|
53
|
+
COMMITGUARD_CHECK_CONTEXTS = (APP_CHECK_NAME, ACTION_CHECK_NAME)
|
|
54
|
+
MAX_WORKFLOW_FILES = 50
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class EnforcementEvidence:
|
|
59
|
+
checked_at: datetime
|
|
60
|
+
branch: str | None
|
|
61
|
+
actions: str # detected | not_detected | unknown
|
|
62
|
+
actions_detail: str
|
|
63
|
+
branch_protection: str # required | not_required | unknown
|
|
64
|
+
required_checks: tuple[str, ...]
|
|
65
|
+
branch_protection_detail: str
|
|
66
|
+
merge_queue: str = "unknown" # enabled | not_enabled | unknown
|
|
67
|
+
merge_queue_detail: str = "Not checked yet."
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class EnforcementProbe:
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
client: GitHubClient,
|
|
74
|
+
*,
|
|
75
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
76
|
+
) -> None:
|
|
77
|
+
self._client = client
|
|
78
|
+
self._now = now
|
|
79
|
+
|
|
80
|
+
def probe(
|
|
81
|
+
self, token: Secret, repository: RepositoryRef, default_branch: str | None
|
|
82
|
+
) -> EnforcementEvidence:
|
|
83
|
+
now = self._now()
|
|
84
|
+
if not default_branch:
|
|
85
|
+
return EnforcementEvidence(
|
|
86
|
+
now,
|
|
87
|
+
None,
|
|
88
|
+
"unknown",
|
|
89
|
+
"the repository has no default branch",
|
|
90
|
+
"unknown",
|
|
91
|
+
(),
|
|
92
|
+
"the repository has no default branch",
|
|
93
|
+
)
|
|
94
|
+
actions, actions_detail = self._actions(token, repository, default_branch)
|
|
95
|
+
protection, checks, protection_detail, queue, queue_detail = self._required_check(
|
|
96
|
+
token, repository, default_branch
|
|
97
|
+
)
|
|
98
|
+
return EnforcementEvidence(
|
|
99
|
+
now,
|
|
100
|
+
default_branch,
|
|
101
|
+
actions,
|
|
102
|
+
actions_detail,
|
|
103
|
+
protection,
|
|
104
|
+
checks,
|
|
105
|
+
protection_detail,
|
|
106
|
+
queue,
|
|
107
|
+
queue_detail,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _actions(self, token: Secret, repository: RepositoryRef, branch: str) -> tuple[str, str]:
|
|
111
|
+
directory = str(WORKFLOWS_DIR)
|
|
112
|
+
try:
|
|
113
|
+
entries = self._client.list_directory(token, repository, directory, branch)
|
|
114
|
+
except GitHubNotFoundError:
|
|
115
|
+
return "not_detected", f"no {directory} directory on {branch}"
|
|
116
|
+
except GitHubAPIError as exc:
|
|
117
|
+
return "unknown", f"workflows could not be read ({exc.category.value})"
|
|
118
|
+
workflows = [e for e in entries if e.type == "file" and e.name.endswith((".yml", ".yaml"))][
|
|
119
|
+
:MAX_WORKFLOW_FILES
|
|
120
|
+
]
|
|
121
|
+
for entry in workflows:
|
|
122
|
+
try:
|
|
123
|
+
text = self._client.get_file_text(token, repository, entry.path, branch)
|
|
124
|
+
except GitHubAPIError as exc:
|
|
125
|
+
return "unknown", f"{entry.name} could not be read ({exc.category.value})"
|
|
126
|
+
if text is None:
|
|
127
|
+
continue
|
|
128
|
+
inspection = inspect_workflow_text(Path(entry.path), text)
|
|
129
|
+
if inspection is not None and inspection.check_names:
|
|
130
|
+
names = ", ".join(inspection.check_names)
|
|
131
|
+
return "detected", f"{entry.path} runs CommitGuard (check: {names})"
|
|
132
|
+
return "not_detected", f"no workflow on {branch} runs CommitGuard"
|
|
133
|
+
|
|
134
|
+
def _required_check(
|
|
135
|
+
self, token: Secret, repository: RepositoryRef, branch: str
|
|
136
|
+
) -> tuple[str, tuple[str, ...], str, str, str]:
|
|
137
|
+
rules_ok = branch_ok = False
|
|
138
|
+
required: set[str] = set()
|
|
139
|
+
has_status_rule = False
|
|
140
|
+
has_merge_queue = False
|
|
141
|
+
protected = None
|
|
142
|
+
try:
|
|
143
|
+
rules = self._client.get_branch_rules(token, repository, branch)
|
|
144
|
+
rules_ok = True
|
|
145
|
+
for rule in rules:
|
|
146
|
+
if rule.type == "required_status_checks":
|
|
147
|
+
has_status_rule = True
|
|
148
|
+
required.update(rule.required_contexts)
|
|
149
|
+
elif rule.type == "merge_queue":
|
|
150
|
+
has_merge_queue = True
|
|
151
|
+
except GitHubAPIError as exc:
|
|
152
|
+
log.info("branch_rules_unavailable", category=exc.category.value)
|
|
153
|
+
try:
|
|
154
|
+
info = self._client.get_branch(token, repository, branch)
|
|
155
|
+
branch_ok = True
|
|
156
|
+
protected = info.protected
|
|
157
|
+
required.update(info.required_contexts)
|
|
158
|
+
except GitHubAPIError as exc:
|
|
159
|
+
log.info("branch_unavailable", category=exc.category.value)
|
|
160
|
+
if has_merge_queue:
|
|
161
|
+
queue, queue_detail = "enabled", f"a ruleset on {branch} requires a merge queue"
|
|
162
|
+
elif rules_ok and branch_ok and not protected:
|
|
163
|
+
queue, queue_detail = (
|
|
164
|
+
"not_enabled",
|
|
165
|
+
f"no ruleset requires a merge queue on {branch}, and it has no branch protection",
|
|
166
|
+
)
|
|
167
|
+
elif branch_ok and protected:
|
|
168
|
+
queue, queue_detail = (
|
|
169
|
+
"unknown",
|
|
170
|
+
f"{branch} is protected; whether classic protection requires a merge queue is "
|
|
171
|
+
"not visible with the App's permissions",
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
queue, queue_detail = "unknown", "merge queue settings could not be read"
|
|
175
|
+
ours = tuple(sorted(c for c in required if c in COMMITGUARD_CHECK_CONTEXTS))
|
|
176
|
+
if ours:
|
|
177
|
+
return (
|
|
178
|
+
"required",
|
|
179
|
+
ours,
|
|
180
|
+
f"{branch} requires {', '.join(ours)} before merging",
|
|
181
|
+
queue,
|
|
182
|
+
queue_detail,
|
|
183
|
+
)
|
|
184
|
+
if rules_ok and branch_ok and not protected and not has_status_rule:
|
|
185
|
+
return (
|
|
186
|
+
"not_required",
|
|
187
|
+
(),
|
|
188
|
+
f"{branch} is not protected and no ruleset requires status checks",
|
|
189
|
+
queue,
|
|
190
|
+
queue_detail,
|
|
191
|
+
)
|
|
192
|
+
if rules_ok and branch_ok and has_status_rule and not protected:
|
|
193
|
+
return (
|
|
194
|
+
"not_required",
|
|
195
|
+
(),
|
|
196
|
+
f"rulesets on {branch} require status checks, but not a CommitGuard check",
|
|
197
|
+
queue,
|
|
198
|
+
queue_detail,
|
|
199
|
+
)
|
|
200
|
+
if branch_ok and protected:
|
|
201
|
+
return (
|
|
202
|
+
"unknown",
|
|
203
|
+
(),
|
|
204
|
+
f"{branch} is protected, but its required checks are not visible with the "
|
|
205
|
+
"App's permissions",
|
|
206
|
+
queue,
|
|
207
|
+
queue_detail,
|
|
208
|
+
)
|
|
209
|
+
return "unknown", (), "branch protection could not be read", queue, queue_detail
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Errors of the GitHub App integration.
|
|
2
|
+
|
|
3
|
+
Messages are safe to log and to show in a Check Run: they never contain
|
|
4
|
+
tokens, JWTs, the private key, the webhook secret or request headers, and
|
|
5
|
+
GitHub response text is sanitised, redacted and truncated before it is used.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
11
|
+
from commitguard.exceptions.configuration import ConfigurationError
|
|
12
|
+
from commitguard.security.sanitization import sanitize_for_terminal
|
|
13
|
+
from commitguard.security.secrets import redact
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def safe_text(text: str, limit: int = 300) -> str:
|
|
17
|
+
"""Untrusted or credential-adjacent text, made safe for messages."""
|
|
18
|
+
return sanitize_for_terminal(redact(text), max_length=limit)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitHubAppError(CommitGuardError):
|
|
22
|
+
"""Base class for GitHub App integration errors."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AppConfigurationError(ConfigurationError):
|
|
26
|
+
"""The App's environment configuration is missing or invalid (names only, never values)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthenticationError(GitHubAppError):
|
|
30
|
+
"""App credentials are invalid, or GitHub rejected the App / installation token."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthorizationError(GitHubAppError):
|
|
34
|
+
"""The installation is not allowed to access the repository (or no longer exists)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InsufficientPermissionsError(AuthorizationError):
|
|
38
|
+
def __init__(self, missing: dict[str, str]) -> None:
|
|
39
|
+
self.missing = dict(sorted(missing.items()))
|
|
40
|
+
wanted = ", ".join(f"{name}: {level}" for name, level in self.missing.items())
|
|
41
|
+
super().__init__(f"GitHub App installation is missing required permissions ({wanted})")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WebhookValidationError(GitHubAppError):
|
|
45
|
+
"""A webhook delivery was rejected. ``status`` is the HTTP status to return."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: str, *, status: int = 400) -> None:
|
|
48
|
+
self.status = status
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GitHubErrorCategory(StrEnum):
|
|
53
|
+
UNAUTHORIZED = "unauthorized" # 401
|
|
54
|
+
FORBIDDEN = "forbidden" # 403 (not rate limiting)
|
|
55
|
+
NOT_FOUND = "not_found" # 404
|
|
56
|
+
CONFLICT = "conflict" # 409
|
|
57
|
+
VALIDATION = "validation" # 422
|
|
58
|
+
RATE_LIMITED = "rate_limited" # 429 / 403 rate limit
|
|
59
|
+
SERVER_ERROR = "server_error" # 5xx
|
|
60
|
+
UNAVAILABLE = "unavailable" # timeout / network failure
|
|
61
|
+
UNEXPECTED = "unexpected" # redirects, malformed responses, other statuses
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GitHubAPIError(GitHubAppError):
|
|
65
|
+
"""A GitHub REST API call failed after bounded retries."""
|
|
66
|
+
|
|
67
|
+
category = GitHubErrorCategory.UNEXPECTED
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
operation: str,
|
|
72
|
+
*,
|
|
73
|
+
status: int | None = None,
|
|
74
|
+
detail: str = "",
|
|
75
|
+
request_id: str | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
self.operation = operation
|
|
78
|
+
self.status = status
|
|
79
|
+
self.request_id = safe_text(request_id, 80) if request_id else None
|
|
80
|
+
self.detail = safe_text(detail) if detail else ""
|
|
81
|
+
status_text = f"HTTP {status}" if status is not None else "no response"
|
|
82
|
+
message = f"GitHub API {operation} failed: {status_text} ({self.category.value})"
|
|
83
|
+
if self.detail:
|
|
84
|
+
message += f": {self.detail}"
|
|
85
|
+
super().__init__(message)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class GitHubUnauthorizedError(GitHubAPIError, AuthenticationError):
|
|
89
|
+
category = GitHubErrorCategory.UNAUTHORIZED
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GitHubForbiddenError(GitHubAPIError, AuthorizationError):
|
|
93
|
+
category = GitHubErrorCategory.FORBIDDEN
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class GitHubNotFoundError(GitHubAPIError):
|
|
97
|
+
category = GitHubErrorCategory.NOT_FOUND
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class GitHubConflictError(GitHubAPIError):
|
|
101
|
+
category = GitHubErrorCategory.CONFLICT
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class GitHubValidationError(GitHubAPIError):
|
|
105
|
+
category = GitHubErrorCategory.VALIDATION
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class GitHubRateLimitError(GitHubAPIError):
|
|
109
|
+
category = GitHubErrorCategory.RATE_LIMITED
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
operation: str,
|
|
114
|
+
*,
|
|
115
|
+
retry_after: float | None = None,
|
|
116
|
+
status: int | None = None,
|
|
117
|
+
detail: str = "",
|
|
118
|
+
request_id: str | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
self.retry_after = retry_after
|
|
121
|
+
super().__init__(operation, status=status, detail=detail, request_id=request_id)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class GitHubServerError(GitHubAPIError):
|
|
125
|
+
category = GitHubErrorCategory.SERVER_ERROR
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class GitHubUnavailableError(GitHubAPIError):
|
|
129
|
+
category = GitHubErrorCategory.UNAVAILABLE
|