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,201 @@
1
+ """Outbox dispatcher: notification events -> inbox entries and delivery records.
2
+
3
+ Each pending event is claimed with a conditional update (``dispatched_at IS
4
+ NULL``) inside the same transaction that writes its in-app notifications,
5
+ e-mail and webhook delivery records and the ``notification_created`` audit
6
+ event. Several processes can run the dispatcher: only one claims an event, and
7
+ a crash before commit leaves the event pending for the next run.
8
+ """
9
+
10
+ import json
11
+ import sqlite3
12
+ import uuid
13
+ from collections.abc import Callable
14
+ from datetime import UTC, datetime
15
+
16
+ from commitguard.audit.models import AuditEventType
17
+ from commitguard.core.result import Severity
18
+ from commitguard.github.storage import SqliteStateStore
19
+ from commitguard.notifications.channels import in_app
20
+ from commitguard.notifications.deduplication import delivery_idempotency_key
21
+ from commitguard.notifications.models import (
22
+ DEFINITIONS,
23
+ DeliveryStatus,
24
+ NotificationChannel,
25
+ NotificationEvent,
26
+ NotificationType,
27
+ StoredNotificationEvent,
28
+ )
29
+ from commitguard.notifications.preferences import load_organization_settings
30
+ from commitguard.notifications.settings import NotificationSettings
31
+ from commitguard.observability.logging import correlation, get_logger
32
+ from commitguard.observability.metrics import NOTIFICATIONS_CREATED, Metrics
33
+ from commitguard.services.audit import AuditService
34
+
35
+ log = get_logger(__name__)
36
+
37
+ DISPATCH_BATCH = 100
38
+
39
+
40
+ def _dt(value: float | None) -> datetime | None:
41
+ return None if value is None else datetime.fromtimestamp(float(value), UTC)
42
+
43
+
44
+ def stored_event(row: sqlite3.Row) -> StoredNotificationEvent:
45
+ event = NotificationEvent.model_construct(
46
+ event_id=row["event_id"],
47
+ type=NotificationType(row["type"]),
48
+ account_id=int(row["account_id"]),
49
+ severity=Severity(row["severity"]),
50
+ installation_id=row["installation_id"],
51
+ repository_id=row["repository_id"],
52
+ resource_type=row["resource_type"],
53
+ resource_id=row["resource_id"],
54
+ dedup_key=row["dedup_key"],
55
+ title=row["title"],
56
+ body=row["body"],
57
+ metadata=json.loads(row["metadata"] or "{}"),
58
+ )
59
+ return StoredNotificationEvent(
60
+ event=event,
61
+ occurrences=int(row["occurrences"]),
62
+ created_at=datetime.fromtimestamp(float(row["created_at"]), UTC),
63
+ last_occurred_at=datetime.fromtimestamp(float(row["last_occurred_at"]), UTC),
64
+ dispatched_at=_dt(row["dispatched_at"]),
65
+ request_id=row["request_id"],
66
+ delivery_id=row["delivery_id"],
67
+ job_id=row["job_id"],
68
+ )
69
+
70
+
71
+ class NotificationDispatcher:
72
+ def __init__(
73
+ self,
74
+ store: SqliteStateStore,
75
+ audit: AuditService,
76
+ metrics: Metrics,
77
+ settings: NotificationSettings,
78
+ *,
79
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
80
+ ) -> None:
81
+ self._store = store
82
+ self._audit = audit
83
+ self._metrics = metrics
84
+ self._settings = settings
85
+ self._now = now
86
+
87
+ def dispatch_pending(self, limit: int = DISPATCH_BATCH) -> int:
88
+ rows = self._store.query(
89
+ "SELECT event_id FROM notification_events WHERE dispatched_at IS NULL "
90
+ "ORDER BY created_at LIMIT ?",
91
+ (int(limit),),
92
+ )
93
+ dispatched = 0
94
+ for row in rows:
95
+ with correlation(notification_event_id=row["event_id"]):
96
+ if self._dispatch(str(row["event_id"])):
97
+ dispatched += 1
98
+ return dispatched
99
+
100
+ def _dispatch(self, event_id: str) -> bool:
101
+ now = self._now()
102
+ audit_event = None
103
+ with self._store.transaction() as db:
104
+ claimed = db.execute(
105
+ "UPDATE notification_events SET dispatched_at = ? WHERE event_id = ? "
106
+ "AND dispatched_at IS NULL",
107
+ (now.timestamp(), event_id),
108
+ ).rowcount
109
+ if claimed != 1:
110
+ return False
111
+ row = db.execute(
112
+ "SELECT * FROM notification_events WHERE event_id = ?", (event_id,)
113
+ ).fetchone()
114
+ stored = stored_event(row)
115
+ event = stored.event
116
+ definition = DEFINITIONS[event.type]
117
+ organization = load_organization_settings(db, event.account_id)
118
+ users = in_app.recipients(db, event.account_id, definition, organization)
119
+ db.executemany(
120
+ "INSERT OR IGNORE INTO notifications (notification_id, event_id, account_id, "
121
+ "user_id, state, severity, created_at, updated_at, sort_at) "
122
+ "VALUES (?, ?, ?, ?, 'unread', ?, ?, ?, ?)",
123
+ [
124
+ (
125
+ uuid.uuid4().hex,
126
+ event_id,
127
+ event.account_id,
128
+ user,
129
+ event.severity.value,
130
+ now.timestamp(),
131
+ now.timestamp(),
132
+ stored.last_occurred_at.timestamp(),
133
+ )
134
+ for user in users
135
+ ],
136
+ )
137
+ channel = organization.types[event.type]
138
+ deliveries: list[tuple[str, str, str]] = [] # channel, destination, provider
139
+ # An aggregated event is delivered externally through its organization digest.
140
+ external = event.metadata.get("aggregated") is not True
141
+ if external and channel.email and self._settings.email_available:
142
+ deliveries += [
143
+ (NotificationChannel.EMAIL.value, address, "email")
144
+ for address in organization.email_recipients
145
+ ]
146
+ if external and channel.webhook and self._settings.webhook_available:
147
+ deliveries += [
148
+ (NotificationChannel.WEBHOOK.value, str(endpoint["endpoint_id"]), "webhook")
149
+ for endpoint in db.execute(
150
+ "SELECT endpoint_id FROM notification_webhooks WHERE account_id = ? "
151
+ "AND removed_at IS NULL ORDER BY created_at",
152
+ (event.account_id,),
153
+ ).fetchall()
154
+ ]
155
+ db.executemany(
156
+ "INSERT OR IGNORE INTO notification_deliveries (delivery_id, event_id, account_id, "
157
+ "channel, destination, idempotency_key, status, attempt_count, provider, "
158
+ "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
159
+ [
160
+ (
161
+ uuid.uuid4().hex,
162
+ event_id,
163
+ event.account_id,
164
+ channel_name,
165
+ destination,
166
+ delivery_idempotency_key(event_id, channel_name, destination),
167
+ DeliveryStatus.PENDING.value,
168
+ provider,
169
+ now.timestamp(),
170
+ now.timestamp(),
171
+ )
172
+ for channel_name, destination, provider in deliveries
173
+ ],
174
+ )
175
+ audit_event = self._store.insert_audit_event(
176
+ db,
177
+ self._audit.build(
178
+ AuditEventType.NOTIFICATION_CREATED,
179
+ account_id=event.account_id,
180
+ installation_id=event.installation_id,
181
+ repository_id=event.repository_id,
182
+ notification_type=event.type.value,
183
+ notification_event=event_id,
184
+ severity=event.severity.value,
185
+ recipients=len(users),
186
+ email_deliveries=sum(1 for d in deliveries if d[0] == "email"),
187
+ webhook_deliveries=sum(1 for d in deliveries if d[0] == "webhook"),
188
+ source_request=stored.request_id,
189
+ source_delivery=stored.delivery_id,
190
+ source_job=stored.job_id,
191
+ ),
192
+ )
193
+ self._audit.log_stored(audit_event)
194
+ self._metrics.increment(NOTIFICATIONS_CREATED, type=event.type.value)
195
+ log.info(
196
+ "notification_dispatched",
197
+ type=event.type.value,
198
+ recipients=len(users),
199
+ deliveries=len(deliveries),
200
+ )
201
+ return True
@@ -0,0 +1,439 @@
1
+ """Notification vocabulary: types, channels, states and the normalised event.
2
+
3
+ A notification never makes a security decision. It is produced *after* a
4
+ domain service has decided and stored something - a scan blocked a commit, an
5
+ administrator changed or rolled back policy, a GitHub installation lost access
6
+ - and it is written into the notification outbox in the same database
7
+ transaction as that change (:mod:`commitguard.notifications.outbox`).
8
+
9
+ Types
10
+ =====
11
+
12
+ ============================= =========== =============================== =========
13
+ Type Category In-app recipients (permission) Mandatory
14
+ ============================= =========== =============================== =========
15
+ ``critical_violation`` violations ``violations:read`` in-app
16
+ ``high_violation`` violations ``violations:read``
17
+ ``policy_changed`` policy ``audit:read`` in-app
18
+ ``policy_rolled_back`` policy ``audit:read`` in-app
19
+ ``installation_disconnected`` github ``github:manage`` in-app
20
+ ``installation_reconnected`` github ``github:manage``
21
+ ``merge_queue_failure`` scans ``scans:read``
22
+ ``check_rerun_failed`` scans ``scans:read``
23
+ ``violation_digest`` violations ``violations:read``
24
+ ``policy_approval_requested`` policy ``policies:approve``
25
+ ``policy_emergency_published`` policy ``audit:read`` in-app
26
+ ``policy_rollout_failed`` policy ``policies:publish`` in-app
27
+ ``policy_propagation_failed`` policy ``policies:publish`` in-app
28
+ ``exception_requested`` policy ``exceptions:approve``
29
+ ``exception_approved`` policy ``exceptions:read``
30
+ ``exception_expiring`` policy ``exceptions:read``
31
+ ``exception_ended`` policy ``exceptions:read``
32
+ ``repository_unprotected`` github ``repositories:manage`` in-app
33
+ ``organization_settings_changed`` policy ``audit:read``
34
+ ============================= =========== =============================== =========
35
+
36
+ ``violation_digest`` is the organization-level aggregation of violation
37
+ alerts: when an organization enables it, one notification per rule and hour
38
+ ("blocked commits in 14 repositories") replaces the per-pull-request e-mails and
39
+ webhooks; the dashboard keeps the per-repository detail.
40
+
41
+ "Mandatory" in-app notifications cannot be muted by a user or turned off by an
42
+ organization: they cover events after which enforcement may no longer be what
43
+ people believe it is. E-mail and webhook delivery is configurable per type by
44
+ administrators (``notifications:manage``).
45
+
46
+ Severity reuses the core :class:`~commitguard.core.result.Severity` model.
47
+ Violation notifications take the finding's severity; the bundled detectors
48
+ classify AI attribution as ``high``, so ``critical_violation`` is produced only
49
+ by rules that report ``critical``.
50
+ """
51
+
52
+ import re
53
+ import uuid
54
+ from dataclasses import dataclass
55
+ from datetime import datetime
56
+ from enum import StrEnum
57
+ from typing import Literal
58
+
59
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
60
+
61
+ from commitguard.controlplane.access import Permission
62
+ from commitguard.core.result import Severity
63
+ from commitguard.security.sanitization import sanitize_for_terminal
64
+ from commitguard.security.secrets import redact
65
+
66
+ MAX_TITLE_CHARS = 200
67
+ MAX_BODY_CHARS = 1000
68
+ MAX_METADATA_ITEMS = 24
69
+ MAX_METADATA_STRING = 256
70
+ _KEY_RE = re.compile(r"\A[a-z][a-z0-9_]{0,63}\Z")
71
+
72
+ type MetadataValue = str | int | bool | None
73
+
74
+
75
+ def clean(value: str, limit: int) -> str:
76
+ """Untrusted text as stored in a notification: secrets redacted, controls visible."""
77
+ return sanitize_for_terminal(redact(value), max_length=limit)
78
+
79
+
80
+ class NotificationType(StrEnum):
81
+ CRITICAL_VIOLATION = "critical_violation"
82
+ HIGH_VIOLATION = "high_violation"
83
+ POLICY_CHANGED = "policy_changed"
84
+ POLICY_ROLLED_BACK = "policy_rolled_back"
85
+ INSTALLATION_DISCONNECTED = "installation_disconnected"
86
+ INSTALLATION_RECONNECTED = "installation_reconnected"
87
+ MERGE_QUEUE_FAILURE = "merge_queue_failure"
88
+ CHECK_RERUN_FAILED = "check_rerun_failed"
89
+ # Phase 8: organization governance.
90
+ VIOLATION_DIGEST = "violation_digest"
91
+ POLICY_APPROVAL_REQUESTED = "policy_approval_requested"
92
+ POLICY_EMERGENCY_PUBLISHED = "policy_emergency_published"
93
+ POLICY_ROLLOUT_FAILED = "policy_rollout_failed"
94
+ POLICY_PROPAGATION_FAILED = "policy_propagation_failed"
95
+ EXCEPTION_REQUESTED = "exception_requested"
96
+ EXCEPTION_APPROVED = "exception_approved"
97
+ EXCEPTION_EXPIRING = "exception_expiring"
98
+ EXCEPTION_ENDED = "exception_ended"
99
+ REPOSITORY_UNPROTECTED = "repository_unprotected"
100
+ ORGANIZATION_SETTINGS_CHANGED = "organization_settings_changed"
101
+
102
+
103
+ class NotificationCategory(StrEnum):
104
+ VIOLATIONS = "violations"
105
+ POLICY = "policy"
106
+ GITHUB = "github"
107
+ SCANS = "scans"
108
+
109
+
110
+ class NotificationChannel(StrEnum):
111
+ IN_APP = "in_app"
112
+ EMAIL = "email"
113
+ WEBHOOK = "webhook"
114
+
115
+
116
+ class NotificationState(StrEnum):
117
+ UNREAD = "unread"
118
+ READ = "read"
119
+ ARCHIVED = "archived"
120
+
121
+
122
+ class DeliveryStatus(StrEnum):
123
+ PENDING = "pending"
124
+ SENT = "sent"
125
+ FAILED = "failed"
126
+ CANCELLED = "cancelled"
127
+
128
+
129
+ ResourceType = Literal[
130
+ "violation",
131
+ "scan",
132
+ "policy",
133
+ "installation",
134
+ "repository",
135
+ "exception",
136
+ "draft",
137
+ "rollout",
138
+ "organization",
139
+ ]
140
+
141
+
142
+ @dataclass(frozen=True, slots=True)
143
+ class TypeDefinition:
144
+ type: NotificationType
145
+ label: str
146
+ description: str
147
+ category: NotificationCategory
148
+ permission: Permission # who receives the in-app notification
149
+ repository_scoped: bool # only users who can see the repository on GitHub see it
150
+ mandatory_in_app: bool
151
+ default_email: bool
152
+ default_webhook: bool
153
+ default_in_app: bool = True
154
+ #: repeated events with the same key inside this window update one notification
155
+ coalesce_seconds: int | None = None
156
+
157
+
158
+ DEFINITIONS: dict[NotificationType, TypeDefinition] = {
159
+ d.type: d
160
+ for d in (
161
+ TypeDefinition(
162
+ NotificationType.CRITICAL_VIOLATION,
163
+ "Critical violations",
164
+ "A scan blocked commits for a finding classified critical.",
165
+ NotificationCategory.VIOLATIONS,
166
+ Permission.VIOLATIONS_READ,
167
+ repository_scoped=True,
168
+ mandatory_in_app=True,
169
+ default_email=True,
170
+ default_webhook=True,
171
+ coalesce_seconds=3600,
172
+ ),
173
+ TypeDefinition(
174
+ NotificationType.HIGH_VIOLATION,
175
+ "High-severity violations",
176
+ "A scan blocked commits for a finding classified high (for example AI attribution).",
177
+ NotificationCategory.VIOLATIONS,
178
+ Permission.VIOLATIONS_READ,
179
+ repository_scoped=True,
180
+ mandatory_in_app=False,
181
+ default_email=False,
182
+ default_webhook=False,
183
+ coalesce_seconds=3600,
184
+ ),
185
+ TypeDefinition(
186
+ NotificationType.POLICY_CHANGED,
187
+ "Policy changes",
188
+ "An administrator published a new organization policy version.",
189
+ NotificationCategory.POLICY,
190
+ Permission.AUDIT_READ,
191
+ repository_scoped=False,
192
+ mandatory_in_app=True,
193
+ default_email=True,
194
+ default_webhook=True,
195
+ ),
196
+ TypeDefinition(
197
+ NotificationType.POLICY_ROLLED_BACK,
198
+ "Policy rollbacks",
199
+ "An administrator restored an earlier organization policy version.",
200
+ NotificationCategory.POLICY,
201
+ Permission.AUDIT_READ,
202
+ repository_scoped=False,
203
+ mandatory_in_app=True,
204
+ default_email=True,
205
+ default_webhook=True,
206
+ ),
207
+ TypeDefinition(
208
+ NotificationType.INSTALLATION_DISCONNECTED,
209
+ "Installation disconnects",
210
+ "The GitHub App was uninstalled or suspended: GitHub enforcement is at risk.",
211
+ NotificationCategory.GITHUB,
212
+ Permission.GITHUB_MANAGE,
213
+ repository_scoped=False,
214
+ mandatory_in_app=True,
215
+ default_email=True,
216
+ default_webhook=True,
217
+ coalesce_seconds=3600,
218
+ ),
219
+ TypeDefinition(
220
+ NotificationType.INSTALLATION_RECONNECTED,
221
+ "Installation reconnects",
222
+ "A GitHub App installation became available again.",
223
+ NotificationCategory.GITHUB,
224
+ Permission.GITHUB_MANAGE,
225
+ repository_scoped=False,
226
+ mandatory_in_app=False,
227
+ default_email=True,
228
+ default_webhook=True,
229
+ coalesce_seconds=3600,
230
+ ),
231
+ TypeDefinition(
232
+ NotificationType.MERGE_QUEUE_FAILURE,
233
+ "Merge queue failures",
234
+ "A merge group was blocked, or could not be validated.",
235
+ NotificationCategory.SCANS,
236
+ Permission.SCANS_READ,
237
+ repository_scoped=True,
238
+ mandatory_in_app=False,
239
+ default_email=False,
240
+ default_webhook=True,
241
+ ),
242
+ TypeDefinition(
243
+ NotificationType.CHECK_RERUN_FAILED,
244
+ "Check re-run failures",
245
+ "A re-run of the CommitGuard check could not be completed.",
246
+ NotificationCategory.SCANS,
247
+ Permission.SCANS_READ,
248
+ repository_scoped=True,
249
+ mandatory_in_app=False,
250
+ default_email=False,
251
+ default_webhook=False,
252
+ ),
253
+ TypeDefinition(
254
+ NotificationType.VIOLATION_DIGEST,
255
+ "Violation digests",
256
+ "Blocked violations across repositories, aggregated per rule and hour.",
257
+ NotificationCategory.VIOLATIONS,
258
+ Permission.VIOLATIONS_READ,
259
+ repository_scoped=False,
260
+ mandatory_in_app=False,
261
+ default_email=True,
262
+ default_webhook=True,
263
+ coalesce_seconds=3600,
264
+ ),
265
+ TypeDefinition(
266
+ NotificationType.POLICY_APPROVAL_REQUESTED,
267
+ "Policy approval requests",
268
+ "A policy change is waiting for approval.",
269
+ NotificationCategory.POLICY,
270
+ Permission.POLICIES_APPROVE,
271
+ repository_scoped=False,
272
+ mandatory_in_app=False,
273
+ default_email=True,
274
+ default_webhook=False,
275
+ ),
276
+ TypeDefinition(
277
+ NotificationType.POLICY_EMERGENCY_PUBLISHED,
278
+ "Emergency policy publications",
279
+ "A policy change was published without the approval workflow.",
280
+ NotificationCategory.POLICY,
281
+ Permission.AUDIT_READ,
282
+ repository_scoped=False,
283
+ mandatory_in_app=True,
284
+ default_email=True,
285
+ default_webhook=True,
286
+ ),
287
+ TypeDefinition(
288
+ NotificationType.POLICY_ROLLOUT_FAILED,
289
+ "Policy rollout failures",
290
+ "A staged policy rollout was paused or rolled back by its safety thresholds.",
291
+ NotificationCategory.POLICY,
292
+ Permission.POLICIES_PUBLISH,
293
+ repository_scoped=False,
294
+ mandatory_in_app=True,
295
+ default_email=True,
296
+ default_webhook=True,
297
+ ),
298
+ TypeDefinition(
299
+ NotificationType.POLICY_PROPAGATION_FAILED,
300
+ "Policy propagation failures",
301
+ "The effective policy of one or more repositories could not be updated.",
302
+ NotificationCategory.POLICY,
303
+ Permission.POLICIES_PUBLISH,
304
+ repository_scoped=False,
305
+ mandatory_in_app=True,
306
+ default_email=True,
307
+ default_webhook=True,
308
+ coalesce_seconds=3600,
309
+ ),
310
+ TypeDefinition(
311
+ NotificationType.EXCEPTION_REQUESTED,
312
+ "Exception requests",
313
+ "A policy exception for a high or critical rule is waiting for approval.",
314
+ NotificationCategory.POLICY,
315
+ Permission.EXCEPTIONS_APPROVE,
316
+ repository_scoped=False,
317
+ mandatory_in_app=False,
318
+ default_email=True,
319
+ default_webhook=False,
320
+ ),
321
+ TypeDefinition(
322
+ NotificationType.EXCEPTION_APPROVED,
323
+ "Exception approvals",
324
+ "A policy exception became active and lowers enforcement until it expires.",
325
+ NotificationCategory.POLICY,
326
+ Permission.EXCEPTIONS_READ,
327
+ repository_scoped=False,
328
+ mandatory_in_app=False,
329
+ default_email=True,
330
+ default_webhook=True,
331
+ ),
332
+ TypeDefinition(
333
+ NotificationType.EXCEPTION_EXPIRING,
334
+ "Exceptions expiring soon",
335
+ "An active policy exception expires soon; the policy applies again afterwards.",
336
+ NotificationCategory.POLICY,
337
+ Permission.EXCEPTIONS_READ,
338
+ repository_scoped=False,
339
+ mandatory_in_app=False,
340
+ default_email=False,
341
+ default_webhook=False,
342
+ ),
343
+ TypeDefinition(
344
+ NotificationType.EXCEPTION_ENDED,
345
+ "Exceptions ended",
346
+ "A policy exception expired or was revoked: the policy applies again.",
347
+ NotificationCategory.POLICY,
348
+ Permission.EXCEPTIONS_READ,
349
+ repository_scoped=False,
350
+ mandatory_in_app=False,
351
+ default_email=False,
352
+ default_webhook=True,
353
+ ),
354
+ TypeDefinition(
355
+ NotificationType.REPOSITORY_UNPROTECTED,
356
+ "Repositories losing protection",
357
+ "A repository that was protected is no longer protected by GitHub.",
358
+ NotificationCategory.GITHUB,
359
+ Permission.REPOSITORIES_MANAGE,
360
+ repository_scoped=True,
361
+ mandatory_in_app=True,
362
+ default_email=True,
363
+ default_webhook=True,
364
+ coalesce_seconds=3600,
365
+ ),
366
+ TypeDefinition(
367
+ NotificationType.ORGANIZATION_SETTINGS_CHANGED,
368
+ "Security settings changes",
369
+ "An administrator changed organization security settings.",
370
+ NotificationCategory.POLICY,
371
+ Permission.AUDIT_READ,
372
+ repository_scoped=False,
373
+ mandatory_in_app=False,
374
+ default_email=True,
375
+ default_webhook=True,
376
+ ),
377
+ )
378
+ }
379
+
380
+
381
+ class NotificationEvent(BaseModel):
382
+ """A normalised notification event, as written to the outbox."""
383
+
384
+ model_config = ConfigDict(frozen=True, extra="forbid")
385
+
386
+ event_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
387
+ type: NotificationType
388
+ account_id: int
389
+ severity: Severity
390
+ installation_id: int | None = None
391
+ repository_id: int | None = None
392
+ resource_type: ResourceType
393
+ resource_id: str = Field(min_length=1, max_length=64)
394
+ #: Domain identity of the underlying occurrence, before the coalescing window.
395
+ dedup_key: str = Field(min_length=1, max_length=512)
396
+ title: str
397
+ body: str
398
+ metadata: dict[str, MetadataValue] = {}
399
+
400
+ @field_validator("title")
401
+ @classmethod
402
+ def _title(cls, value: str) -> str:
403
+ return clean(value, MAX_TITLE_CHARS)
404
+
405
+ @field_validator("body")
406
+ @classmethod
407
+ def _body(cls, value: str) -> str:
408
+ return clean(value, MAX_BODY_CHARS)
409
+
410
+ @field_validator("metadata")
411
+ @classmethod
412
+ def _metadata(cls, value: dict[str, MetadataValue]) -> dict[str, MetadataValue]:
413
+ if len(value) > MAX_METADATA_ITEMS:
414
+ raise ValueError(f"notification metadata has more than {MAX_METADATA_ITEMS} items")
415
+ result: dict[str, MetadataValue] = {}
416
+ for key, item in value.items():
417
+ if not _KEY_RE.match(key):
418
+ raise ValueError(f"invalid notification metadata key {key!r}")
419
+ result[key] = clean(item, MAX_METADATA_STRING) if isinstance(item, str) else item
420
+ return result
421
+
422
+ @property
423
+ def definition(self) -> TypeDefinition:
424
+ return DEFINITIONS[self.type]
425
+
426
+
427
+ class StoredNotificationEvent(BaseModel):
428
+ """A notification event as read back from the outbox."""
429
+
430
+ model_config = ConfigDict(frozen=True, extra="forbid")
431
+
432
+ event: NotificationEvent
433
+ occurrences: int
434
+ created_at: datetime
435
+ last_occurred_at: datetime
436
+ dispatched_at: datetime | None
437
+ request_id: str | None
438
+ delivery_id: str | None
439
+ job_id: str | None