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,935 @@
1
+ """The CommitGuard GitHub App service.
2
+
3
+ ::
4
+
5
+ GitHub --HTTPS webhook--> WSGI endpoint (POST /webhooks/github)
6
+ rate limit -> size/content-type -> signature -> headers -> JSON
7
+ -> normalise -> event record (delivery ID: new / duplicate / retry)
8
+ -> installation events: apply (state + audit + notification, one transaction)
9
+ -> push / pull_request / merge_group: store job, enqueue
10
+ -> check_run / check_suite "rerequested": new execution of the stored scan
11
+ <- 2xx within milliseconds (no scanning on the request path)
12
+
13
+ worker threads: queue -> ScanWorker -> Check Run
14
+ notification thread: outbox -> inbox / deliveries -> e-mail, webhooks (retries)
15
+ maintenance: re-enqueue abandoned jobs, recovery (failed deliveries,
16
+ infrastructure retries), retention purge
17
+
18
+ Event records: every verified delivery is stored with a processing status
19
+ (``processing`` -> ``processed`` / ``ignored`` / ``failed``). GitHub reuses the
20
+ delivery ID when a delivery is redelivered: a processed delivery is answered as
21
+ a duplicate, while a failed or abandoned one is processed again, so an outage
22
+ while handling an event does not lose it.
23
+
24
+ The WSGI application has no framework dependency. ``commitguard github serve``
25
+ runs it with a small threaded development server; production deployments put
26
+ a WSGI server behind a TLS-terminating reverse proxy (docs/deployment.md).
27
+ The service itself never serves plain HTTP to the internet.
28
+ """
29
+
30
+ import json
31
+ import threading
32
+ import time
33
+ from collections.abc import Callable, Iterable, Mapping, Sequence
34
+ from dataclasses import dataclass, field
35
+ from datetime import UTC, datetime, timedelta
36
+ from pathlib import Path
37
+ from typing import Any
38
+ from wsgiref.types import StartResponse, WSGIEnvironment
39
+
40
+ from commitguard.audit.models import GITHUB_ACTOR, AuditEventType
41
+ from commitguard.config.sources import MandatoryPolicy, load_mandatory_policy
42
+ from commitguard.controlplane.policies import OrganizationPolicyService
43
+ from commitguard.controlplane.results import ScanResultRecorder
44
+ from commitguard.github.auth import AppCredentials, InstallationTokenProvider
45
+ from commitguard.github.checks import APP_CHECK_NAME, APP_PUSH_CHECK_NAME
46
+ from commitguard.github.client import API_URL, GitHubClient, Transport
47
+ from commitguard.github.errors import WebhookValidationError
48
+ from commitguard.github.events import (
49
+ CheckRunRerequestedEvent,
50
+ CheckSuiteRerequestedEvent,
51
+ GitHubWebhookEvent,
52
+ IgnoredEvent,
53
+ InstallationEvent,
54
+ InstallationRepositoriesEvent,
55
+ MergeGroupAction,
56
+ MergeGroupEvent,
57
+ PullRequestEvent,
58
+ PushEvent,
59
+ normalize_webhook,
60
+ )
61
+ from commitguard.github.identifiers import RepositoryRef
62
+ from commitguard.github.installations import InstallationService
63
+ from commitguard.github.pull_requests import (
64
+ PullRequestDisposition,
65
+ branch_group_key,
66
+ disposition,
67
+ group_key,
68
+ merge_group_key,
69
+ )
70
+ from commitguard.github.queue import DEFAULT_QUEUE_SIZE, EventQueue, InProcessEventQueue
71
+ from commitguard.github.recovery import RecoveryService
72
+ from commitguard.github.repositories import GitHubRemoteLocator, MirrorManager, RemoteLocator
73
+ from commitguard.github.settings import AppSettings, load_settings
74
+ from commitguard.github.storage import (
75
+ DATABASE_FILENAME,
76
+ DeliveryStatus,
77
+ EventProcessingStatus,
78
+ MergeGroupState,
79
+ NewScanJob,
80
+ ScanJob,
81
+ ScanTrigger,
82
+ SqliteStateStore,
83
+ )
84
+ from commitguard.github.webhooks import MAX_WEBHOOK_BYTES, WebhookDelivery, parse_delivery
85
+ from commitguard.github.worker import ScanWorker
86
+ from commitguard.governance.service import GovernanceServices
87
+ from commitguard.notifications.service import RUN_INTERVAL_SECONDS, NotificationService
88
+ from commitguard.notifications.settings import NotificationSettings
89
+ from commitguard.observability.logging import configure_json_logging, correlation, get_logger
90
+ from commitguard.observability.metrics import (
91
+ CHECK_RERUNS,
92
+ GITHUB_EVENTS_FAILED,
93
+ GITHUB_EVENTS_RECEIVED,
94
+ GITHUB_EVENTS_REPLAYED,
95
+ SCANS_QUEUED,
96
+ WEBHOOKS_DUPLICATE,
97
+ WEBHOOKS_RECEIVED,
98
+ WEBHOOKS_REJECTED,
99
+ InMemoryMetrics,
100
+ )
101
+ from commitguard.security.hashing import fingerprint
102
+ from commitguard.security.rate_limit import RequestRateLimiter
103
+ from commitguard.security.secrets import Secret
104
+ from commitguard.services.audit import AuditService
105
+
106
+ log = get_logger(__name__)
107
+
108
+ WEBHOOK_PATH = "/webhooks/github"
109
+ DEFAULT_RATE_LIMIT_PER_MINUTE = 600
110
+ RECOVERY_INTERVAL_SECONDS = 60.0
111
+ RECOVER_QUEUED_AFTER = timedelta(minutes=5)
112
+ RETENTION_INTERVAL_SECONDS = 3600.0
113
+ APP_CHECK_NAMES = frozenset({APP_CHECK_NAME, APP_PUSH_CHECK_NAME})
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class WebhookResult:
118
+ status: int
119
+ body: Mapping[str, str | int] = field(default_factory=dict)
120
+
121
+
122
+ class GitHubAppService:
123
+ def __init__(
124
+ self,
125
+ *,
126
+ credentials: AppCredentials,
127
+ webhook_secret: Secret,
128
+ store: SqliteStateStore,
129
+ client: GitHubClient,
130
+ mirrors: MirrorManager,
131
+ mandatory_policy: MandatoryPolicy | None = None,
132
+ workers: int = 2,
133
+ retention: timedelta = timedelta(days=30),
134
+ max_commits: int = 10_000,
135
+ queue: EventQueue | None = None,
136
+ metrics: InMemoryMetrics | None = None,
137
+ rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE,
138
+ notification_settings: NotificationSettings | None = None,
139
+ notifications: NotificationService | None = None,
140
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
141
+ ) -> None:
142
+ self.store = store
143
+ self.client = client
144
+ self.metrics = metrics or InMemoryMetrics()
145
+ self.queue = queue or InProcessEventQueue()
146
+ self.audit = AuditService([store], now=now)
147
+ self.tokens = InstallationTokenProvider(credentials, client)
148
+ self.mirrors = mirrors
149
+ self.installations = InstallationService(
150
+ store, self.tokens, client, mirrors, self.audit, now=now
151
+ )
152
+ self.policies = OrganizationPolicyService(
153
+ store, self.audit, service_policy=mandatory_policy, metrics=self.metrics, now=now
154
+ )
155
+ self.notifications = notifications or NotificationService(
156
+ store,
157
+ self.audit,
158
+ self.metrics,
159
+ notification_settings or NotificationSettings(),
160
+ now=now,
161
+ )
162
+ self.recorder = ScanResultRecorder(store, self.audit, now=now)
163
+ # Organization governance: groups, onboarding, exceptions, effective policy per repository.
164
+ self.governance = GovernanceServices(
165
+ store,
166
+ self.audit,
167
+ self.policies,
168
+ installations=self.installations,
169
+ client=client,
170
+ enqueue=self.queue.put,
171
+ now=now,
172
+ )
173
+ self.installations.add_discovery_listener(self._repositories_discovered)
174
+ self.worker = ScanWorker(
175
+ store=store,
176
+ installations=self.installations,
177
+ client=client,
178
+ mirrors=mirrors,
179
+ audit=self.audit,
180
+ metrics=self.metrics,
181
+ policy_resolver=self.policies.mandatory_for_installation,
182
+ governance_resolver=self.governance.scan_governance,
183
+ recorder=self.recorder,
184
+ max_commits=max_commits,
185
+ now=now,
186
+ )
187
+ self.recovery = RecoveryService(
188
+ store, self.audit, self.metrics, enqueue=self.queue.put, now=now
189
+ )
190
+ self._credentials = credentials
191
+ self._secret = webhook_secret
192
+ self._workers = workers
193
+ self._retention = retention
194
+ self._now = now
195
+ self._limiter = RequestRateLimiter(rate_limit_per_minute)
196
+ self._stop = threading.Event()
197
+ self._threads: list[threading.Thread] = []
198
+ self._maintenance_tasks: list[Callable[[], object]] = []
199
+
200
+ def _repositories_discovered(
201
+ self, account_id: int, installation_id: int, repositories: Sequence[RepositoryRef]
202
+ ) -> None:
203
+ self.governance.inventory.discovered(account_id, installation_id, repositories)
204
+
205
+ # ------------------------------------------------------------------ #
206
+ # Construction
207
+ # ------------------------------------------------------------------ #
208
+ @classmethod
209
+ def create(
210
+ cls,
211
+ *,
212
+ app_id: int,
213
+ private_key: Secret,
214
+ webhook_secret: Secret,
215
+ data_dir: Path,
216
+ mandatory_policy_file: Path | None = None,
217
+ transport: Transport | None = None,
218
+ api_url: str = API_URL,
219
+ remote_locator: RemoteLocator | None = None,
220
+ allowed_git_protocols: tuple[str, ...] = ("https",),
221
+ sleep: Callable[[float], None] = time.sleep,
222
+ **options: Any,
223
+ ) -> "GitHubAppService":
224
+ credentials = AppCredentials(app_id, private_key) # fails closed on a bad key
225
+ mandatory = load_mandatory_policy(mandatory_policy_file) if mandatory_policy_file else None
226
+ data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
227
+ metrics = options.pop("metrics", None) or InMemoryMetrics()
228
+ client = GitHubClient(transport, api_url=api_url, sleep=sleep, metrics=metrics)
229
+ mirrors = MirrorManager(
230
+ data_dir / "mirrors",
231
+ remote_locator or GitHubRemoteLocator(),
232
+ allowed_protocols=allowed_git_protocols,
233
+ )
234
+ return cls(
235
+ credentials=credentials,
236
+ webhook_secret=webhook_secret,
237
+ store=SqliteStateStore(data_dir / DATABASE_FILENAME),
238
+ client=client,
239
+ mirrors=mirrors,
240
+ mandatory_policy=mandatory,
241
+ metrics=metrics,
242
+ **options,
243
+ )
244
+
245
+ @classmethod
246
+ def from_settings(cls, settings: AppSettings, **overrides: Any) -> "GitHubAppService":
247
+ return cls.create(
248
+ app_id=settings.app_id,
249
+ private_key=settings.private_key,
250
+ webhook_secret=settings.webhook_secret,
251
+ data_dir=settings.data_dir,
252
+ mandatory_policy_file=settings.mandatory_policy_file,
253
+ workers=settings.workers,
254
+ retention=settings.retention,
255
+ max_commits=settings.max_commits,
256
+ **overrides,
257
+ )
258
+
259
+ # ------------------------------------------------------------------ #
260
+ # Webhooks
261
+ # ------------------------------------------------------------------ #
262
+ def handle_webhook(
263
+ self, headers: Mapping[str, str], body: bytes, *, remote_addr: str | None = None
264
+ ) -> WebhookResult:
265
+ self.metrics.increment(WEBHOOKS_RECEIVED)
266
+ if not self._limiter.allow(remote_addr or "unknown"):
267
+ self.metrics.increment(WEBHOOKS_REJECTED, reason="rate_limited")
268
+ return WebhookResult(429, {"error": "too many requests"})
269
+ try:
270
+ delivery = parse_delivery(headers, body, self._secret)
271
+ except WebhookValidationError as exc:
272
+ self.metrics.increment(WEBHOOKS_REJECTED, reason=str(exc.status))
273
+ log.warning("webhook_rejected", status=exc.status, reason=str(exc))
274
+ return WebhookResult(exc.status, {"error": str(exc)})
275
+ with correlation(delivery_id=delivery.delivery_id):
276
+ return self._handle_verified(delivery)
277
+
278
+ def _handle_verified(self, delivery: WebhookDelivery) -> WebhookResult:
279
+ self.metrics.increment(GITHUB_EVENTS_RECEIVED, event=delivery.event)
280
+ try:
281
+ event = normalize_webhook(delivery.event, delivery.payload)
282
+ except WebhookValidationError as exc:
283
+ self.metrics.increment(WEBHOOKS_REJECTED, reason="invalid_payload")
284
+ log.warning("webhook_invalid", event_name=delivery.event, reason=str(exc))
285
+ self.audit.record(
286
+ AuditEventType.WEBHOOK_REJECTED, event_name=delivery.event, reason=str(exc)
287
+ )
288
+ return WebhookResult(exc.status, {"error": str(exc)})
289
+
290
+ action = delivery.payload.get("action")
291
+ status = self.store.record_delivery(
292
+ delivery.delivery_id,
293
+ delivery.event,
294
+ delivery.body_sha256,
295
+ self._now(),
296
+ action=action if isinstance(action, str) and len(action) <= 64 else None,
297
+ )
298
+ if status is DeliveryStatus.DUPLICATE:
299
+ self.metrics.increment(WEBHOOKS_DUPLICATE)
300
+ self.metrics.increment(GITHUB_EVENTS_REPLAYED, event=delivery.event)
301
+ log.info("webhook_duplicate", event_name=delivery.event)
302
+ return WebhookResult(200, {"status": "duplicate"})
303
+ if status is DeliveryStatus.RETRY:
304
+ log.info("webhook_redelivery_processed_again", event_name=delivery.event)
305
+ if status is DeliveryStatus.CONFLICT:
306
+ self.metrics.increment(WEBHOOKS_REJECTED, reason="delivery_conflict")
307
+ log.warning("webhook_delivery_id_conflict", event_name=delivery.event)
308
+ self.audit.record(
309
+ AuditEventType.WEBHOOK_REJECTED,
310
+ event_name=delivery.event,
311
+ reason="delivery ID reused with a different payload",
312
+ )
313
+ return WebhookResult(409, {"error": "delivery already received with different content"})
314
+
315
+ log.info("webhook_accepted", event_name=delivery.event, kind=event.kind)
316
+ try:
317
+ result = self._dispatch(event, delivery)
318
+ except Exception as exc:
319
+ # The event record stays "failed": GitHub's redelivery of this delivery ID is
320
+ # processed again instead of being dropped as a duplicate.
321
+ self.metrics.increment(GITHUB_EVENTS_FAILED, event=delivery.event)
322
+ self.store.finish_delivery(
323
+ delivery.delivery_id,
324
+ EventProcessingStatus.FAILED,
325
+ self._now(),
326
+ detail=f"processing failed ({type(exc).__name__})",
327
+ )
328
+ raise
329
+ ignored = result.body.get("status") in ("ignored", "duplicate")
330
+ self.store.finish_delivery(
331
+ delivery.delivery_id,
332
+ EventProcessingStatus.IGNORED if ignored else EventProcessingStatus.PROCESSED,
333
+ self._now(),
334
+ installation_id=getattr(event, "installation_id", None),
335
+ repository_id=getattr(getattr(event, "repository", None), "id", None),
336
+ detail=str(result.body.get("status", "")),
337
+ )
338
+ return result
339
+
340
+ def _dispatch(self, event: GitHubWebhookEvent, delivery: WebhookDelivery) -> WebhookResult:
341
+ if isinstance(event, IgnoredEvent):
342
+ return WebhookResult(202, {"status": "ignored"})
343
+ if isinstance(event, InstallationEvent):
344
+ self.installations.handle_installation(event)
345
+ return WebhookResult(200, {"status": "processed"})
346
+ if isinstance(event, InstallationRepositoriesEvent):
347
+ self.installations.handle_repositories(event)
348
+ return WebhookResult(200, {"status": "processed"})
349
+
350
+ with correlation(
351
+ installation_id=event.installation_id, repository=event.repository.full_name
352
+ ):
353
+ denial = self.installations.denial_reason(event.installation_id)
354
+ if denial is not None:
355
+ self.audit.record(
356
+ AuditEventType.AUTHORIZATION_DENIED,
357
+ installation_id=event.installation_id,
358
+ repository_id=event.repository.id,
359
+ repository=event.repository.full_name,
360
+ reason=denial,
361
+ )
362
+ return WebhookResult(202, {"status": "ignored"})
363
+ if isinstance(event, PushEvent):
364
+ return self._push(event, delivery)
365
+ if isinstance(event, MergeGroupEvent):
366
+ return self._merge_group(event, delivery)
367
+ if isinstance(event, CheckRunRerequestedEvent):
368
+ return self._check_run_rerequested(event, delivery)
369
+ if isinstance(event, CheckSuiteRerequestedEvent):
370
+ return self._check_suite_rerequested(event, delivery)
371
+ return self._pull_request(event, delivery)
372
+
373
+ def _monitoring_paused(self, installation_id: int, repository_id: int) -> bool:
374
+ if self.store.monitoring_enabled(installation_id, repository_id):
375
+ return False
376
+ log.info("repository_monitoring_paused")
377
+ return True
378
+
379
+ def _push(self, event: PushEvent, delivery: WebhookDelivery) -> WebhookResult:
380
+ context = event.context
381
+ ref = context.ref or ""
382
+ if not ref.startswith("refs/heads/"):
383
+ return WebhookResult(202, {"status": "ignored"}) # tags are not scanned
384
+ if context.ref_deleted or context.after_sha is None:
385
+ # Nothing to scan; violations seen only on this branch are no longer present.
386
+ self.recorder.branch_deleted(event.installation_id, event.repository.id, ref)
387
+ return WebhookResult(202, {"status": "ignored"})
388
+ if self._monitoring_paused(event.installation_id, event.repository.id):
389
+ return WebhookResult(202, {"status": "ignored"})
390
+ return self._enqueue(
391
+ NewScanJob(
392
+ job_key=fingerprint(["push", ref, context.before_sha or "", context.after_sha]),
393
+ installation_id=event.installation_id,
394
+ repository=event.repository,
395
+ delivery_id=delivery.delivery_id,
396
+ event="push",
397
+ group_key=branch_group_key(ref),
398
+ head_sha=context.after_sha,
399
+ check_name=APP_PUSH_CHECK_NAME,
400
+ pull_request_number=None,
401
+ context=context,
402
+ )
403
+ )
404
+
405
+ def _pull_request(self, event: PullRequestEvent, delivery: WebhookDelivery) -> WebhookResult:
406
+ action = disposition(event)
407
+ if action is PullRequestDisposition.IGNORE:
408
+ return WebhookResult(202, {"status": "ignored"})
409
+ if action is PullRequestDisposition.CLOSE:
410
+ self.store.cancel_queued_group(
411
+ event.installation_id, event.repository.id, group_key(event.number), self._now()
412
+ )
413
+ self.recorder.pull_request_closed(
414
+ event.installation_id,
415
+ event.repository.id,
416
+ event.number,
417
+ merged=False,
418
+ base_ref=None,
419
+ )
420
+ return WebhookResult(200, {"status": "processed"})
421
+ if action is PullRequestDisposition.RECORD_MERGE:
422
+ self.recorder.pull_request_closed(
423
+ event.installation_id,
424
+ event.repository.id,
425
+ event.number,
426
+ merged=True,
427
+ base_ref=event.context.ref,
428
+ )
429
+ self.audit.record(
430
+ AuditEventType.PULL_REQUEST_MERGED,
431
+ installation_id=event.installation_id,
432
+ repository_id=event.repository.id,
433
+ repository=event.repository.full_name,
434
+ head_sha=event.context.head_sha,
435
+ pull_request=event.number,
436
+ )
437
+ return WebhookResult(200, {"status": "processed"})
438
+ if self._monitoring_paused(event.installation_id, event.repository.id):
439
+ return WebhookResult(202, {"status": "ignored"})
440
+ context = event.context
441
+ head = context.head_sha or ""
442
+ result = self._enqueue(
443
+ NewScanJob(
444
+ job_key=fingerprint(
445
+ ["pull_request", str(event.number), context.base_sha or "", head]
446
+ ),
447
+ installation_id=event.installation_id,
448
+ repository=event.repository,
449
+ delivery_id=delivery.delivery_id,
450
+ event="pull_request",
451
+ group_key=group_key(event.number),
452
+ head_sha=head,
453
+ check_name=APP_CHECK_NAME,
454
+ pull_request_number=event.number,
455
+ context=context,
456
+ )
457
+ )
458
+ if event.action == "reopened" and result.body.get("status") == "duplicate":
459
+ # Same commits as an earlier completed scan: nothing is re-scanned, so the
460
+ # violations that closing the pull request ended are present again.
461
+ self.recorder.pull_request_reopened(
462
+ event.installation_id, event.repository.id, event.number
463
+ )
464
+ return result
465
+
466
+ # ------------------------------------------------------------------ #
467
+ # Merge queue
468
+ # ------------------------------------------------------------------ #
469
+ def _merge_group(self, event: MergeGroupEvent, delivery: WebhookDelivery) -> WebhookResult:
470
+ """Validate the exact merge group commit the merge queue waits on.
471
+
472
+ The pull request's own check is not reused: a merge group combines the pull
473
+ request with the latest base branch and the changes queued ahead of it, so it
474
+ is scanned as ``base_sha..head_sha`` and the result is published to
475
+ ``head_sha``. Every merge group SHA has its own scan; a recreated group is a
476
+ new SHA and gets a new scan.
477
+ """
478
+ common: dict[str, Any] = {
479
+ "installation_id": event.installation_id,
480
+ "repository_id": event.repository.id,
481
+ "head_sha": event.head_sha,
482
+ "head_ref": event.head_ref,
483
+ "base_sha": event.base_sha,
484
+ "base_ref": event.base_ref,
485
+ "pull_requests": event.pull_requests,
486
+ }
487
+ prs = ",".join(f"#{n}" for n in event.pull_requests) or None
488
+ if event.action is MergeGroupAction.DESTROYED:
489
+ record, changed = self.store.destroy_merge_group(
490
+ **common, reason=event.reason, now=self._now()
491
+ )
492
+ if not changed:
493
+ return WebhookResult(202, {"status": "duplicate"})
494
+ self.store.cancel_queued_group(
495
+ event.installation_id,
496
+ event.repository.id,
497
+ merge_group_key(event.head_sha),
498
+ self._now(),
499
+ )
500
+ self.recorder.merge_group_destroyed(
501
+ event.installation_id,
502
+ event.repository.id,
503
+ event.head_sha,
504
+ reason=event.reason,
505
+ base_ref=event.base_ref,
506
+ )
507
+ self.audit.record(
508
+ AuditEventType.MERGE_GROUP_DESTROYED,
509
+ installation_id=event.installation_id,
510
+ repository_id=event.repository.id,
511
+ repository=event.repository.full_name,
512
+ head_sha=event.head_sha,
513
+ reason=event.reason,
514
+ pull_requests=prs,
515
+ job=record.job_id,
516
+ )
517
+ return WebhookResult(200, {"status": "processed"})
518
+
519
+ if self._monitoring_paused(event.installation_id, event.repository.id):
520
+ return WebhookResult(202, {"status": "ignored"})
521
+ record, requested = self.store.record_merge_group(
522
+ **common, delivery_id=delivery.delivery_id, now=self._now()
523
+ )
524
+ if not requested:
525
+ reason = (
526
+ "merge group already destroyed"
527
+ if record.state is MergeGroupState.DESTROYED
528
+ else "merge group already requested"
529
+ )
530
+ log.info("merge_group_not_scanned", reason=reason)
531
+ return WebhookResult(202, {"status": "duplicate"})
532
+ self.audit.record(
533
+ AuditEventType.MERGE_GROUP_CREATED,
534
+ installation_id=event.installation_id,
535
+ repository_id=event.repository.id,
536
+ repository=event.repository.full_name,
537
+ head_sha=event.head_sha,
538
+ base_sha=event.base_sha,
539
+ base_ref=event.base_ref,
540
+ pull_requests=prs,
541
+ )
542
+ result = self._enqueue(
543
+ NewScanJob(
544
+ job_key=fingerprint(
545
+ ["merge_group", event.head_ref, event.base_sha, event.head_sha]
546
+ ),
547
+ installation_id=event.installation_id,
548
+ repository=event.repository,
549
+ delivery_id=delivery.delivery_id,
550
+ event="merge_group",
551
+ group_key=merge_group_key(event.head_sha),
552
+ head_sha=event.head_sha,
553
+ check_name=APP_CHECK_NAME, # the required check, on the merge group commit
554
+ pull_request_number=None, # queued pull requests are listed on the merge group
555
+ context=event.context,
556
+ )
557
+ )
558
+ job = self.store.latest_group_job(
559
+ event.installation_id, event.repository.id, merge_group_key(event.head_sha)
560
+ )
561
+ if job is not None:
562
+ self.store.set_merge_group_job(
563
+ event.installation_id, event.repository.id, event.head_sha, job.job_id, self._now()
564
+ )
565
+ return result
566
+
567
+ # ------------------------------------------------------------------ #
568
+ # Check re-runs
569
+ # ------------------------------------------------------------------ #
570
+ def _reject_rerun(
571
+ self, event: CheckRunRerequestedEvent | CheckSuiteRerequestedEvent, reason: str
572
+ ) -> WebhookResult:
573
+ self.audit.record(
574
+ AuditEventType.CHECK_RERUN_REJECTED,
575
+ installation_id=event.installation_id,
576
+ repository_id=event.repository.id,
577
+ repository=event.repository.full_name,
578
+ head_sha=event.head_sha,
579
+ reason=reason,
580
+ )
581
+ log.info("check_rerun_rejected", reason=reason)
582
+ return WebhookResult(202, {"status": "ignored"})
583
+
584
+ def _check_run_rerequested(
585
+ self, event: CheckRunRerequestedEvent, delivery: WebhookDelivery
586
+ ) -> WebhookResult:
587
+ """GitHub "Re-run" on a CommitGuard check run: a new execution of the same scan.
588
+
589
+ The stored scan is found through the check run's ``external_id`` (the job ID
590
+ CommitGuard set when it created the run) and must match the event's
591
+ installation, repository, commit and check name exactly; nothing else in the
592
+ payload is used.
593
+ """
594
+ if event.app_id != self._credentials.app_id:
595
+ return WebhookResult(202, {"status": "ignored"}) # another App's check
596
+ job = self.store.get_job(event.external_id) if event.external_id else None
597
+ if (
598
+ job is None
599
+ or job.installation_id != event.installation_id
600
+ or job.repository.id != event.repository.id
601
+ or job.head_sha != event.head_sha
602
+ or job.check_name != event.name
603
+ ):
604
+ return self._reject_rerun(event, "the check run does not match a CommitGuard scan")
605
+ return self._request_rerun(event, job, delivery)
606
+
607
+ def _check_suite_rerequested(
608
+ self, event: CheckSuiteRerequestedEvent, delivery: WebhookDelivery
609
+ ) -> WebhookResult:
610
+ """GitHub "Re-run all checks": re-run CommitGuard's newest scan per check on the commit."""
611
+ if event.app_id != self._credentials.app_id:
612
+ return WebhookResult(202, {"status": "ignored"})
613
+ jobs = [
614
+ j
615
+ for j in self.store.latest_jobs_for_commit(
616
+ event.installation_id, event.repository.id, event.head_sha
617
+ )
618
+ if j.check_name in APP_CHECK_NAMES
619
+ ]
620
+ if not jobs:
621
+ return self._reject_rerun(event, "no CommitGuard scan exists for this commit")
622
+ results = [self._request_rerun(event, job, delivery) for job in jobs]
623
+ statuses = {r.body.get("status") for r in results}
624
+ status = "queued" if "queued" in statuses else sorted(str(x) for x in statuses)[0]
625
+ return WebhookResult(202, {"status": status})
626
+
627
+ def _request_rerun(
628
+ self,
629
+ event: CheckRunRerequestedEvent | CheckSuiteRerequestedEvent,
630
+ job: ScanJob,
631
+ delivery: WebhookDelivery,
632
+ ) -> WebhookResult:
633
+ if self._monitoring_paused(job.installation_id, job.repository.id):
634
+ return self._reject_rerun(event, "monitoring is paused for this repository")
635
+ latest = self.store.latest_group_job(job.installation_id, job.repository.id, job.group_key)
636
+ if latest is not None and latest.scan_key != job.scan_key:
637
+ # Re-running an outdated commit's check would record old commits as the
638
+ # current state of the pull request or branch.
639
+ return self._reject_rerun(
640
+ event, "a newer commit has been scanned for this pull request or branch"
641
+ )
642
+ if job.event == "merge_group":
643
+ group = self.store.get_merge_group(job.installation_id, job.repository.id, job.head_sha)
644
+ if group is None or group.state is MergeGroupState.DESTROYED:
645
+ return self._reject_rerun(event, "the merge group no longer exists")
646
+ execution, created = self.store.create_execution(
647
+ job, trigger=ScanTrigger.RERUN, now=self._now(), delivery_id=delivery.delivery_id
648
+ )
649
+ if not created:
650
+ self.metrics.increment(WEBHOOKS_DUPLICATE, reason="rerun")
651
+ return WebhookResult(202, {"status": "duplicate"})
652
+ self.metrics.increment(CHECK_RERUNS)
653
+ self.audit.record(
654
+ AuditEventType.CHECK_RERUN_REQUESTED,
655
+ actor=GITHUB_ACTOR,
656
+ installation_id=job.installation_id,
657
+ repository_id=job.repository.id,
658
+ repository=job.repository.full_name,
659
+ head_sha=job.head_sha,
660
+ job=execution.job_id,
661
+ previous_scan=job.job_id,
662
+ execution=execution.execution,
663
+ check=job.check_name,
664
+ )
665
+ self.queue.put(execution.job_id)
666
+ return WebhookResult(202, {"status": "queued"})
667
+
668
+ def _enqueue(self, new_job: NewScanJob) -> WebhookResult:
669
+ job, created = self.store.create_job(new_job, self._now())
670
+ if not created:
671
+ self.metrics.increment(WEBHOOKS_DUPLICATE, reason="scan")
672
+ log.info("scan_already_known", job_id=job.job_id, state=job.state.value)
673
+ return WebhookResult(202, {"status": "duplicate"})
674
+ self.metrics.increment(SCANS_QUEUED)
675
+ self.audit.record(
676
+ AuditEventType.SCAN_QUEUED,
677
+ installation_id=job.installation_id,
678
+ repository_id=job.repository.id,
679
+ repository=job.repository.full_name,
680
+ head_sha=job.head_sha,
681
+ job=job.job_id,
682
+ )
683
+ self.queue.put(job.job_id) # if full, recovery picks the stored job up later
684
+ return WebhookResult(202, {"status": "queued"})
685
+
686
+ # ------------------------------------------------------------------ #
687
+ # Workers and maintenance
688
+ # ------------------------------------------------------------------ #
689
+ def process_pending(self, max_jobs: int | None = None) -> int:
690
+ """Process queued jobs synchronously on the calling thread (tests, one-off runs)."""
691
+ processed = 0
692
+ while max_jobs is None or processed < max_jobs:
693
+ job_id = self.queue.get(timeout=0)
694
+ if job_id is None:
695
+ break
696
+ if self.worker.process(job_id) is not None:
697
+ processed += 1
698
+ return processed
699
+
700
+ def recover(self, *, queued_before: datetime | None = None) -> int:
701
+ """Re-enqueue stored jobs that were queued long ago or abandoned by a crash."""
702
+ now = self._now()
703
+ ids = self.store.recoverable_jobs(now, queued_before=queued_before or now)
704
+ for job_id in ids:
705
+ self.queue.put(job_id)
706
+ return len(ids)
707
+
708
+ def add_maintenance_task(self, task: Callable[[], object]) -> None:
709
+ """Run ``task`` with the hourly retention purge (e.g. expired dashboard sessions)."""
710
+ self._maintenance_tasks.append(task)
711
+
712
+ def purge_expired(self) -> dict[str, int]:
713
+ cutoff = self._now() - self._retention
714
+ counts = self.store.purge_expired(cutoff)
715
+ counts["mirrors"] = self.mirrors.purge_unused(self._retention.total_seconds())
716
+ counts["notifications"] = self.notifications.purge_expired()
717
+ for task in self._maintenance_tasks:
718
+ task()
719
+ log.info("retention_purge", **counts)
720
+ return counts
721
+
722
+ def _worker_loop(self) -> None:
723
+ while not self._stop.is_set():
724
+ job_id = self.queue.get(timeout=1.0)
725
+ if job_id is None:
726
+ continue
727
+ try:
728
+ self.worker.process(job_id)
729
+ except Exception as exc: # noqa: BLE001 - keep the worker alive
730
+ log.error("worker_crashed_on_job", error_type=type(exc).__name__)
731
+
732
+ def _notification_loop(self) -> None:
733
+ while not self._stop.wait(RUN_INTERVAL_SECONDS):
734
+ try:
735
+ self.notifications.run_once()
736
+ except Exception as exc: # noqa: BLE001 - keep notifications alive
737
+ log.error("notifications_failed", error_type=type(exc).__name__)
738
+
739
+ def _maintenance_loop(self) -> None:
740
+ last_purge = 0.0
741
+ while not self._stop.wait(RECOVERY_INTERVAL_SECONDS):
742
+ try:
743
+ self.recover(queued_before=self._now() - RECOVER_QUEUED_AFTER)
744
+ self.recovery.run_once()
745
+ self.governance.run_maintenance()
746
+ if time.monotonic() - last_purge > RETENTION_INTERVAL_SECONDS:
747
+ self.purge_expired()
748
+ last_purge = time.monotonic()
749
+ except Exception as exc: # noqa: BLE001 - keep maintenance alive
750
+ log.error("maintenance_failed", error_type=type(exc).__name__)
751
+
752
+ def start(self) -> None:
753
+ if self._threads:
754
+ return
755
+ self._stop.clear()
756
+ self.recover()
757
+ for index in range(self._workers):
758
+ thread = threading.Thread(
759
+ target=self._worker_loop, name=f"commitguard-worker-{index}", daemon=True
760
+ )
761
+ thread.start()
762
+ self._threads.append(thread)
763
+ maintenance = threading.Thread(
764
+ target=self._maintenance_loop, name="commitguard-maintenance", daemon=True
765
+ )
766
+ maintenance.start()
767
+ self._threads.append(maintenance)
768
+ notifications = threading.Thread(
769
+ target=self._notification_loop, name="commitguard-notifications", daemon=True
770
+ )
771
+ notifications.start()
772
+ self._threads.append(notifications)
773
+ log.info("service_started", workers=self._workers)
774
+
775
+ def stop(self, timeout: float = 10.0) -> None:
776
+ self._stop.set()
777
+ for thread in self._threads:
778
+ thread.join(timeout)
779
+ self._threads.clear()
780
+
781
+ # ------------------------------------------------------------------ #
782
+ # Health
783
+ # ------------------------------------------------------------------ #
784
+ def readiness(self) -> tuple[bool, dict[str, str]]:
785
+ checks = {
786
+ "configuration": "ok", # credentials were parsed at construction
787
+ "store": "ok" if self.store.ping() else "unavailable",
788
+ "workers": "ok"
789
+ if self._threads and all(t.is_alive() for t in self._threads)
790
+ else "not running",
791
+ "queue": "ok" if self.queue.size() < DEFAULT_QUEUE_SIZE * 0.9 else "saturated",
792
+ }
793
+ return all(v == "ok" for v in checks.values()), checks
794
+
795
+
796
+ # --------------------------------------------------------------------------- #
797
+ # WSGI
798
+ # --------------------------------------------------------------------------- #
799
+ _SECURITY_HEADERS = [
800
+ ("Cache-Control", "no-store"),
801
+ ("X-Content-Type-Options", "nosniff"),
802
+ ("Content-Security-Policy", "default-src 'none'"),
803
+ ]
804
+
805
+
806
+ def _json_response(
807
+ start_response: StartResponse,
808
+ status: int,
809
+ body: Mapping[str, object],
810
+ extra_headers: Iterable[tuple[str, str]] = (),
811
+ ) -> list[bytes]:
812
+ payload = json.dumps(body, ensure_ascii=True, sort_keys=True).encode("ascii")
813
+ reason = {
814
+ 200: "OK",
815
+ 202: "Accepted",
816
+ 400: "Bad Request",
817
+ 401: "Unauthorized",
818
+ 404: "Not Found",
819
+ 405: "Method Not Allowed",
820
+ 409: "Conflict",
821
+ 411: "Length Required",
822
+ 413: "Payload Too Large",
823
+ 415: "Unsupported Media Type",
824
+ 429: "Too Many Requests",
825
+ 500: "Internal Server Error",
826
+ 503: "Service Unavailable",
827
+ }.get(status, "Error")
828
+ headers = [
829
+ ("Content-Type", "application/json"),
830
+ ("Content-Length", str(len(payload))),
831
+ *_SECURITY_HEADERS,
832
+ *extra_headers,
833
+ ]
834
+ start_response(f"{status} {reason}", headers)
835
+ return [payload]
836
+
837
+
838
+ def _read_body(environ: WSGIEnvironment, length: int) -> bytes | None:
839
+ stream = environ["wsgi.input"]
840
+ chunks: list[bytes] = []
841
+ remaining = length
842
+ while remaining > 0:
843
+ chunk = stream.read(min(remaining, 65536))
844
+ if not chunk:
845
+ return None # client sent less than Content-Length
846
+ chunks.append(chunk)
847
+ remaining -= len(chunk)
848
+ return b"".join(chunks)
849
+
850
+
851
+ def create_wsgi_app(
852
+ service: GitHubAppService, *, webhook_path: str = WEBHOOK_PATH
853
+ ) -> Callable[[WSGIEnvironment, StartResponse], Iterable[bytes]]:
854
+ def application(environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
855
+ path = environ.get("PATH_INFO", "")
856
+ method = environ.get("REQUEST_METHOD", "")
857
+ try:
858
+ if path == "/health":
859
+ if method not in ("GET", "HEAD"):
860
+ return _json_response(start_response, 405, {"error": "method not allowed"})
861
+ return _json_response(start_response, 200, {"status": "ok"})
862
+ if path == "/ready":
863
+ if method not in ("GET", "HEAD"):
864
+ return _json_response(start_response, 405, {"error": "method not allowed"})
865
+ ready, checks = service.readiness()
866
+ return _json_response(
867
+ start_response,
868
+ 200 if ready else 503,
869
+ {"status": "ready" if ready else "not_ready", "checks": checks},
870
+ )
871
+ if path != webhook_path:
872
+ return _json_response(start_response, 404, {"error": "not found"})
873
+ if method != "POST":
874
+ return _json_response(
875
+ start_response, 405, {"error": "method not allowed"}, [("Allow", "POST")]
876
+ )
877
+ content_type = environ.get("CONTENT_TYPE", "").split(";", 1)[0].strip().lower()
878
+ if content_type != "application/json":
879
+ return _json_response(start_response, 415, {"error": "expected application/json"})
880
+ raw_length = environ.get("CONTENT_LENGTH", "")
881
+ if not raw_length or not raw_length.isascii() or not raw_length.isdigit():
882
+ return _json_response(start_response, 411, {"error": "content length required"})
883
+ length = int(raw_length)
884
+ if length > MAX_WEBHOOK_BYTES:
885
+ return _json_response(start_response, 413, {"error": "payload too large"})
886
+ body = _read_body(environ, length)
887
+ if body is None:
888
+ return _json_response(start_response, 400, {"error": "incomplete body"})
889
+ headers = {
890
+ key[5:].replace("_", "-").lower(): value
891
+ for key, value in environ.items()
892
+ if key.startswith("HTTP_") and isinstance(value, str)
893
+ }
894
+ result = service.handle_webhook(headers, body, remote_addr=environ.get("REMOTE_ADDR"))
895
+ return _json_response(start_response, result.status, result.body)
896
+ except Exception as exc: # noqa: BLE001 - never leak internals to the client
897
+ log.error("http_internal_error", error_type=type(exc).__name__)
898
+ return _json_response(start_response, 500, {"error": "internal error"})
899
+
900
+ return application
901
+
902
+
903
+ def service_from_environment() -> "GitHubAppService":
904
+ """Build the service from environment variables.
905
+
906
+ Both entry points (``commitguard github serve`` and the WSGI factory) use
907
+ this, so they cannot be configured differently: notification delivery was
908
+ once loaded only by the WSGI factory, so ``serve`` delivered nothing.
909
+ """
910
+ from commitguard.notifications.settings import load_notification_settings
911
+
912
+ return GitHubAppService.from_settings(
913
+ load_settings(), notification_settings=load_notification_settings()
914
+ )
915
+
916
+
917
+ def wsgi_app_from_environment() -> Callable[[WSGIEnvironment, StartResponse], Iterable[bytes]]:
918
+ """Entry point for WSGI servers: settings from the environment, workers started.
919
+
920
+ With ``COMMITGUARD_DASHBOARD_URL`` set, the dashboard API (and, with
921
+ ``COMMITGUARD_DASHBOARD_STATIC_DIR``, the dashboard) is served too.
922
+
923
+ Example (one process, several threads, behind a TLS reverse proxy)::
924
+
925
+ gunicorn --workers 1 --threads 8 \\
926
+ 'commitguard.github.app:wsgi_app_from_environment()'
927
+ """
928
+ from commitguard.api.hosting import build_dashboard, create_server_app
929
+ from commitguard.api.settings import dashboard_enabled, load_dashboard_settings
930
+
931
+ configure_json_logging()
932
+ service = service_from_environment()
933
+ dashboard = build_dashboard(service, load_dashboard_settings()) if dashboard_enabled() else None
934
+ service.start()
935
+ return create_server_app(service, dashboard)