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,106 @@
1
+ """The notification outbox.
2
+
3
+ ::
4
+
5
+ domain transaction (scan result / policy version / installation state)
6
+ ├── state change
7
+ ├── audit event
8
+ └── notification event ── emit(db, event) <- same transaction
9
+
10
+ dispatcher (background) ▼
11
+ notification_events WHERE dispatched_at IS NULL
12
+ -> in-app notifications, e-mail and webhook deliveries (one transaction)
13
+
14
+ Because the event is written with the state change, a crash can never leave a
15
+ policy change or a blocked commit without its notification, and a failed
16
+ transaction never produces a notification for a change that did not happen.
17
+ Delivery is a separate, later step: a provider outage cannot undo or delay the
18
+ security decision.
19
+ """
20
+
21
+ import json
22
+ import sqlite3
23
+ from datetime import datetime
24
+
25
+ from commitguard.notifications.deduplication import storage_key
26
+ from commitguard.notifications.models import NotificationEvent
27
+ from commitguard.observability.logging import current_correlation, get_logger
28
+
29
+ log = get_logger(__name__)
30
+
31
+
32
+ def emit(db: sqlite3.Connection, event: NotificationEvent, now: datetime) -> str:
33
+ """Write ``event`` inside the caller's transaction. Returns the stored event ID.
34
+
35
+ A repeat of an event with the same storage key (see
36
+ :mod:`commitguard.notifications.deduplication`) updates the stored event and
37
+ marks its in-app notifications unread again; it does not fan out again.
38
+ """
39
+ key = storage_key(event.type, event.dedup_key, now)
40
+ correlation = current_correlation()
41
+
42
+ def _corr(name: str) -> str | None:
43
+ value = correlation.get(name)
44
+ return None if value is None else str(value)[:128]
45
+
46
+ cursor = db.execute(
47
+ "INSERT INTO notification_events (event_id, account_id, type, severity, installation_id, "
48
+ "repository_id, resource_type, resource_id, dedup_key, title, body, metadata, "
49
+ "occurrences, created_at, last_occurred_at, request_id, delivery_id, job_id) "
50
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) "
51
+ "ON CONFLICT (account_id, dedup_key) DO NOTHING",
52
+ (
53
+ event.event_id,
54
+ event.account_id,
55
+ event.type.value,
56
+ event.severity.value,
57
+ event.installation_id,
58
+ event.repository_id,
59
+ event.resource_type,
60
+ event.resource_id,
61
+ key,
62
+ event.title,
63
+ event.body,
64
+ json.dumps(event.metadata, sort_keys=True),
65
+ now.timestamp(),
66
+ now.timestamp(),
67
+ _corr("request_id"),
68
+ _corr("delivery_id"),
69
+ _corr("job_id"),
70
+ ),
71
+ )
72
+ if cursor.rowcount == 1:
73
+ log.info("notification_event_emitted", type=event.type.value, event_id=event.event_id)
74
+ return event.event_id
75
+ row = db.execute(
76
+ "SELECT event_id FROM notification_events WHERE account_id = ? AND dedup_key = ?",
77
+ (event.account_id, key),
78
+ ).fetchone()
79
+ existing = str(row["event_id"])
80
+ db.execute(
81
+ "UPDATE notification_events SET occurrences = occurrences + 1, last_occurred_at = ?, "
82
+ "title = ?, body = ?, metadata = ?, resource_id = ? WHERE event_id = ?",
83
+ (
84
+ now.timestamp(),
85
+ event.title,
86
+ event.body,
87
+ json.dumps(event.metadata, sort_keys=True),
88
+ event.resource_id,
89
+ existing,
90
+ ),
91
+ )
92
+ db.execute(
93
+ "UPDATE notifications SET state = CASE WHEN state = 'read' THEN 'unread' ELSE state END, "
94
+ "read_at = CASE WHEN state = 'read' THEN NULL ELSE read_at END, "
95
+ "updated_at = ?, sort_at = ? WHERE event_id = ? AND state != 'archived'",
96
+ (now.timestamp(), now.timestamp(), existing),
97
+ )
98
+ log.info("notification_event_coalesced", type=event.type.value, event_id=existing)
99
+ return existing
100
+
101
+
102
+ def account_for_installation(db: sqlite3.Connection, installation_id: int) -> int | None:
103
+ row = db.execute(
104
+ "SELECT account_id FROM installations WHERE installation_id = ?", (int(installation_id),)
105
+ ).fetchone()
106
+ return int(row["account_id"]) if row is not None else None
@@ -0,0 +1,224 @@
1
+ """Notification preferences: organization defaults, personal choices, effective result.
2
+
3
+ ::
4
+
5
+ built-in defaults (per type)
6
+ + organization settings administrators (notifications:manage), versioned
7
+ + personal preferences each member, in-app only, for their own inbox
8
+ = effective preferences
9
+
10
+ Ownership rules:
11
+
12
+ * **Organization settings** decide which types reach the organization's e-mail
13
+ recipients and webhooks, and whether a non-mandatory type appears in members'
14
+ inboxes at all. Only ``notifications:manage`` may change them; turning a
15
+ delivery *off* needs explicit confirmation.
16
+ * **Personal preferences** only mute non-mandatory types in the member's own
17
+ inbox. They cannot touch e-mail, webhooks or anyone else's inbox, so a viewer
18
+ can never silence security notifications for the organization.
19
+ * **Mandatory** in-app types (see :mod:`commitguard.notifications.models`) are
20
+ delivered to every eligible member regardless of either setting.
21
+ """
22
+
23
+ import json
24
+ import re
25
+ import sqlite3
26
+ from collections.abc import Mapping, Sequence
27
+ from dataclasses import dataclass, field
28
+ from datetime import UTC, datetime
29
+
30
+ from commitguard.controlplane.errors import InputValidationError
31
+ from commitguard.notifications.models import (
32
+ DEFINITIONS,
33
+ NotificationChannel,
34
+ NotificationType,
35
+ TypeDefinition,
36
+ )
37
+
38
+ MAX_EMAIL_RECIPIENTS = 20
39
+ MAX_EMAIL_CHARS = 254
40
+ # Deliberately strict: a plain mailbox address, no display name, comments or quoting.
41
+ _EMAIL_RE = re.compile(
42
+ r"\A[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[A-Za-z0-9-]{1,63}(\.[A-Za-z0-9-]{1,63})+\Z"
43
+ )
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class ChannelSetting:
48
+ in_app: bool
49
+ email: bool
50
+ webhook: bool
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class OrganizationSettings:
55
+ account_id: int
56
+ version: int # 0: built-in defaults, never saved
57
+ types: Mapping[NotificationType, ChannelSetting]
58
+ email_recipients: tuple[str, ...]
59
+ updated_at: datetime | None = None
60
+ updated_by: str | None = None
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class UserPreferences:
65
+ user_id: int
66
+ account_id: int
67
+ muted: frozenset[NotificationType] = field(default_factory=frozenset)
68
+
69
+
70
+ def default_setting(definition: TypeDefinition) -> ChannelSetting:
71
+ return ChannelSetting(
72
+ in_app=definition.default_in_app or definition.mandatory_in_app,
73
+ email=definition.default_email,
74
+ webhook=definition.default_webhook,
75
+ )
76
+
77
+
78
+ def _normalize(document: Mapping[str, object]) -> dict[NotificationType, ChannelSetting]:
79
+ types: dict[NotificationType, ChannelSetting] = {}
80
+ for notification_type, definition in DEFINITIONS.items():
81
+ default = default_setting(definition)
82
+ raw = document.get(notification_type.value)
83
+ entry = raw if isinstance(raw, Mapping) else {}
84
+ in_app = entry.get("in_app", default.in_app)
85
+ email = entry.get("email", default.email)
86
+ webhook = entry.get("webhook", default.webhook)
87
+ types[notification_type] = ChannelSetting(
88
+ in_app=True if definition.mandatory_in_app else bool(in_app),
89
+ email=bool(email),
90
+ webhook=bool(webhook),
91
+ )
92
+ return types
93
+
94
+
95
+ def load_organization_settings(db: sqlite3.Connection, account_id: int) -> OrganizationSettings:
96
+ row = db.execute(
97
+ "SELECT * FROM notification_settings WHERE account_id = ?", (int(account_id),)
98
+ ).fetchone()
99
+ if row is None:
100
+ return OrganizationSettings(account_id, 0, _normalize({}), ())
101
+ return OrganizationSettings(
102
+ account_id=int(account_id),
103
+ version=int(row["version"]),
104
+ types=_normalize(json.loads(row["document"])),
105
+ email_recipients=tuple(json.loads(row["email_recipients"])),
106
+ updated_at=datetime.fromtimestamp(float(row["updated_at"]), UTC),
107
+ updated_by=row["updated_by_login"],
108
+ )
109
+
110
+
111
+ def load_user_preferences(db: sqlite3.Connection, user_id: int, account_id: int) -> UserPreferences:
112
+ rows = db.execute(
113
+ "SELECT type, in_app FROM notification_user_preferences WHERE user_id = ? "
114
+ "AND account_id = ?",
115
+ (int(user_id), int(account_id)),
116
+ ).fetchall()
117
+ muted = frozenset(
118
+ NotificationType(r["type"]) for r in rows if not r["in_app"] and r["type"] in DEFINITIONS
119
+ )
120
+ return UserPreferences(int(user_id), int(account_id), muted)
121
+
122
+
123
+ def in_app_enabled(
124
+ definition: TypeDefinition,
125
+ organization: OrganizationSettings,
126
+ user: UserPreferences | None,
127
+ ) -> bool:
128
+ if definition.mandatory_in_app:
129
+ return True
130
+ if not organization.types[definition.type].in_app:
131
+ return False
132
+ return user is None or definition.type not in user.muted
133
+
134
+
135
+ # --------------------------------------------------------------------------- #
136
+ # Validation of API input
137
+ # --------------------------------------------------------------------------- #
138
+ def parse_type(value: object, field_name: str) -> NotificationType:
139
+ if not isinstance(value, str) or value not in {t.value for t in NotificationType}:
140
+ raise InputValidationError("unknown notification type", field=field_name)
141
+ return NotificationType(value)
142
+
143
+
144
+ def validate_organization_document(raw: object) -> dict[NotificationType, ChannelSetting]:
145
+ """``{"type": {"in_app": bool, "email": bool, "webhook": bool}}`` - partial updates allowed."""
146
+ if not isinstance(raw, Mapping):
147
+ raise InputValidationError("types must be an object of notification types", field="types")
148
+ document: dict[str, dict[str, bool]] = {}
149
+ for key, entry in raw.items():
150
+ notification_type = parse_type(key, "types")
151
+ if not isinstance(entry, Mapping):
152
+ raise InputValidationError("each type must be an object", field=f"types.{key}")
153
+ clean: dict[str, bool] = {}
154
+ for channel, value in entry.items():
155
+ if channel not in {c.value for c in NotificationChannel}:
156
+ raise InputValidationError("unknown channel", field=f"types.{key}")
157
+ if not isinstance(value, bool):
158
+ raise InputValidationError(
159
+ "channel settings must be true or false", field=f"types.{key}.{channel}"
160
+ )
161
+ if (
162
+ channel == NotificationChannel.IN_APP.value
163
+ and not value
164
+ and DEFINITIONS[notification_type].mandatory_in_app
165
+ ):
166
+ raise InputValidationError(
167
+ "in-app delivery of this notification type cannot be turned off",
168
+ field=f"types.{key}.in_app",
169
+ )
170
+ clean[channel] = value
171
+ document[notification_type.value] = clean
172
+ return _normalize(document)
173
+
174
+
175
+ def validate_email_recipients(raw: object) -> tuple[str, ...]:
176
+ if not isinstance(raw, Sequence) or isinstance(raw, str):
177
+ raise InputValidationError("email_recipients must be a list", field="email_recipients")
178
+ if len(raw) > MAX_EMAIL_RECIPIENTS:
179
+ raise InputValidationError(
180
+ f"at most {MAX_EMAIL_RECIPIENTS} e-mail recipients", field="email_recipients"
181
+ )
182
+ recipients: list[str] = []
183
+ for item in raw:
184
+ if (
185
+ not isinstance(item, str)
186
+ or len(item) > MAX_EMAIL_CHARS
187
+ or not _EMAIL_RE.match(item.strip())
188
+ ):
189
+ raise InputValidationError(
190
+ "each recipient must be a plain e-mail address", field="email_recipients"
191
+ )
192
+ address = item.strip().lower()
193
+ if address not in recipients:
194
+ recipients.append(address)
195
+ return tuple(recipients)
196
+
197
+
198
+ def serialize(types: Mapping[NotificationType, ChannelSetting]) -> str:
199
+ return json.dumps(
200
+ {
201
+ t.value: {"in_app": s.in_app, "email": s.email, "webhook": s.webhook}
202
+ for t, s in sorted(types.items())
203
+ },
204
+ sort_keys=True,
205
+ )
206
+
207
+
208
+ def disabled_deliveries(
209
+ before: OrganizationSettings,
210
+ types: Mapping[NotificationType, ChannelSetting],
211
+ recipients: Sequence[str],
212
+ ) -> list[str]:
213
+ """Human-readable list of deliveries a settings change turns off (needs confirmation)."""
214
+ removed: list[str] = []
215
+ for notification_type, setting in types.items():
216
+ old = before.types[notification_type]
217
+ label = DEFINITIONS[notification_type].label
218
+ for channel in ("in_app", "email", "webhook"):
219
+ if getattr(old, channel) and not getattr(setting, channel):
220
+ removed.append(f"{label}: {channel.replace('_', '-')}")
221
+ dropped = sorted(set(before.email_recipients) - set(recipients))
222
+ if dropped:
223
+ removed.append(f"e-mail recipients removed: {len(dropped)}")
224
+ return removed
@@ -0,0 +1,282 @@
1
+ """Delivery worker: bounded retries for e-mail and webhook deliveries.
2
+
3
+ ::
4
+
5
+ pending ──claim (lease)──> attempt ──success──> sent
6
+
7
+ ├─ retryable failure, attempts < 5 ──> pending
8
+ │ next_retry_at = now + 1m, 5m, 30m, 2h
9
+ └─ permanent failure, or 5th attempt ──> failed
10
+
11
+ * **Bounded.** At most :data:`MAX_ATTEMPTS` attempts; there is no infinite loop.
12
+ * **One sender at a time.** A delivery is claimed with a lease
13
+ (:data:`LEASE_SECONDS`) through a conditional update; another worker or
14
+ process skips it until the lease expires.
15
+ * **Idempotent.** The idempotency key travels with every attempt. If a process
16
+ dies after the provider accepted a message but before ``sent`` was stored,
17
+ the lease expires and the message is sent again with the *same* key, which
18
+ receivers use to discard the duplicate (at-least-once delivery).
19
+ * **Secondary to security.** Failures change only the delivery record. The
20
+ scan result, the violation, the policy version and the in-app notification
21
+ are already stored.
22
+ """
23
+
24
+ import sqlite3
25
+ from collections.abc import Callable
26
+ from datetime import UTC, datetime, timedelta
27
+
28
+ from commitguard.audit.models import AuditEventType
29
+ from commitguard.github.storage import SqliteStateStore
30
+ from commitguard.notifications.channels.base import DeliveryError, DeliveryReceipt
31
+ from commitguard.notifications.channels.email import EmailProvider
32
+ from commitguard.notifications.channels.webhook import WebhookProvider
33
+ from commitguard.notifications.dispatcher import stored_event
34
+ from commitguard.notifications.models import (
35
+ DeliveryStatus,
36
+ NotificationChannel,
37
+ StoredNotificationEvent,
38
+ )
39
+ from commitguard.notifications.templates import render_email, render_webhook
40
+ from commitguard.observability.logging import correlation, get_logger
41
+ from commitguard.observability.metrics import (
42
+ NOTIFICATION_RETRIES,
43
+ NOTIFICATIONS_FAILED,
44
+ NOTIFICATIONS_SENT,
45
+ Metrics,
46
+ )
47
+ from commitguard.services.audit import AuditService
48
+
49
+ log = get_logger(__name__)
50
+
51
+ MAX_ATTEMPTS = 5
52
+ BACKOFF_SECONDS = (60, 300, 1800, 7200)
53
+ LEASE_SECONDS = 120
54
+ DELIVERY_BATCH = 50
55
+
56
+
57
+ def retry_delay(attempts_made: int) -> timedelta | None:
58
+ """Delay before the next attempt, or None when no attempt is left."""
59
+ if attempts_made >= MAX_ATTEMPTS:
60
+ return None
61
+ return timedelta(seconds=BACKOFF_SECONDS[min(attempts_made, len(BACKOFF_SECONDS)) - 1])
62
+
63
+
64
+ def mask_destination(channel: str, destination: str) -> str:
65
+ """How a destination appears in audit events (no full e-mail addresses)."""
66
+ if channel != NotificationChannel.EMAIL.value or "@" not in destination:
67
+ return destination
68
+ local, _, domain = destination.partition("@")
69
+ return f"{local[:1]}***@{domain}"
70
+
71
+
72
+ class DeliveryWorker:
73
+ def __init__(
74
+ self,
75
+ store: SqliteStateStore,
76
+ audit: AuditService,
77
+ metrics: Metrics,
78
+ *,
79
+ email: EmailProvider | None,
80
+ webhook: WebhookProvider | None,
81
+ dashboard_origin: str | None,
82
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
83
+ ) -> None:
84
+ self._store = store
85
+ self._audit = audit
86
+ self._metrics = metrics
87
+ self._email = email
88
+ self._webhook = webhook
89
+ self._origin = dashboard_origin
90
+ self._now = now
91
+
92
+ # ------------------------------------------------------------------ #
93
+ def deliver_due(self, limit: int = DELIVERY_BATCH) -> int:
94
+ now = self._now().timestamp()
95
+ rows = self._store.query(
96
+ "SELECT delivery_id FROM notification_deliveries WHERE status = 'pending' "
97
+ "AND (next_retry_at IS NULL OR next_retry_at <= ?) "
98
+ "AND (lease_expires_at IS NULL OR lease_expires_at < ?) ORDER BY created_at LIMIT ?",
99
+ (now, now, int(limit)),
100
+ )
101
+ attempted = 0
102
+ for row in rows:
103
+ if self._claim(str(row["delivery_id"])):
104
+ self._attempt(str(row["delivery_id"]))
105
+ attempted += 1
106
+ return attempted
107
+
108
+ def _claim(self, delivery_id: str) -> bool:
109
+ now = self._now().timestamp()
110
+ with self._store.transaction() as db:
111
+ return (
112
+ db.execute(
113
+ "UPDATE notification_deliveries SET lease_expires_at = ?, updated_at = ? "
114
+ "WHERE delivery_id = ? AND status = 'pending' AND (next_retry_at IS NULL OR "
115
+ "next_retry_at <= ?) AND (lease_expires_at IS NULL OR lease_expires_at < ?)",
116
+ (now + LEASE_SECONDS, now, delivery_id, now, now),
117
+ ).rowcount
118
+ == 1
119
+ )
120
+
121
+ def _attempt(self, delivery_id: str) -> None:
122
+ rows = self._store.query(
123
+ "SELECT d.*, e.event_id AS e_event_id, i.account_login, "
124
+ "(SELECT owner || '/' || name FROM known_repositories k WHERE "
125
+ "k.installation_id = e.installation_id AND k.repository_id = e.repository_id) "
126
+ "AS repository_name, w.url AS webhook_url, w.removed_at AS webhook_removed_at "
127
+ "FROM notification_deliveries d JOIN notification_events e ON e.event_id = d.event_id "
128
+ "LEFT JOIN (SELECT account_id, MIN(account_login) AS account_login FROM installations "
129
+ "GROUP BY account_id) i ON i.account_id = d.account_id "
130
+ "LEFT JOIN notification_webhooks w ON w.endpoint_id = d.destination "
131
+ "AND w.account_id = d.account_id WHERE d.delivery_id = ?",
132
+ (delivery_id,),
133
+ )
134
+ if not rows:
135
+ return
136
+ row = rows[0]
137
+ event_rows = self._store.query(
138
+ "SELECT * FROM notification_events WHERE event_id = ?", (row["event_id"],)
139
+ )
140
+ stored = stored_event(event_rows[0])
141
+ organization = row["account_login"] or f"account {row['account_id']}"
142
+ channel = str(row["channel"])
143
+ with correlation(notification_event_id=stored.event.event_id):
144
+ try:
145
+ receipt = self._send(row, stored, organization, channel)
146
+ except DeliveryError as exc:
147
+ self._failed(row, exc)
148
+ except Exception as exc: # noqa: BLE001 - a delivery bug must not stop the worker
149
+ log.error("notification_delivery_crashed", error_type=type(exc).__name__)
150
+ self._failed(row, DeliveryError("internal_error"))
151
+ else:
152
+ self._sent(row, receipt)
153
+
154
+ def _send(
155
+ self,
156
+ row: sqlite3.Row,
157
+ stored: StoredNotificationEvent,
158
+ organization: str,
159
+ channel: str,
160
+ ) -> DeliveryReceipt:
161
+ if channel == NotificationChannel.EMAIL.value:
162
+ if self._email is None:
163
+ raise _Cancelled("email_channel_unavailable")
164
+ return self._email.send(
165
+ render_email(
166
+ stored,
167
+ to=str(row["destination"]),
168
+ organization=organization,
169
+ dashboard_origin=self._origin,
170
+ ),
171
+ idempotency_key=str(row["idempotency_key"]),
172
+ )
173
+ if self._webhook is None:
174
+ raise _Cancelled("webhook_channel_unavailable")
175
+ if row["webhook_url"] is None or row["webhook_removed_at"] is not None:
176
+ raise _Cancelled("webhook_endpoint_removed")
177
+ now = self._now()
178
+ return self._webhook.send(
179
+ url=str(row["webhook_url"]),
180
+ endpoint_id=str(row["destination"]),
181
+ event_type=stored.event.type.value,
182
+ body=render_webhook(
183
+ stored,
184
+ organization=organization,
185
+ repository=row["repository_name"],
186
+ dashboard_origin=self._origin,
187
+ now=now,
188
+ ),
189
+ idempotency_key=str(row["idempotency_key"]),
190
+ timestamp=int(now.timestamp()),
191
+ )
192
+
193
+ # ------------------------------------------------------------------ #
194
+ def _sent(self, row: sqlite3.Row, receipt: DeliveryReceipt) -> None:
195
+ now = self._now().timestamp()
196
+ with self._store.transaction() as db:
197
+ db.execute(
198
+ "UPDATE notification_deliveries SET status = 'sent', attempt_count = "
199
+ "attempt_count + 1, provider = ?, provider_message_id = ?, failure_code = NULL, "
200
+ "last_attempt_at = ?, next_retry_at = NULL, lease_expires_at = NULL, "
201
+ "updated_at = ? WHERE delivery_id = ?",
202
+ (
203
+ receipt.provider,
204
+ (receipt.provider_message_id or "")[:200] or None,
205
+ now,
206
+ now,
207
+ row["delivery_id"],
208
+ ),
209
+ )
210
+ event = self._store.insert_audit_event(
211
+ db,
212
+ self._audit.build(
213
+ AuditEventType.NOTIFICATION_DELIVERED,
214
+ account_id=int(row["account_id"]),
215
+ channel=str(row["channel"]),
216
+ destination=mask_destination(str(row["channel"]), str(row["destination"])),
217
+ notification_delivery=str(row["delivery_id"]),
218
+ notification_event=str(row["event_id"]),
219
+ attempts=int(row["attempt_count"]) + 1,
220
+ ),
221
+ )
222
+ self._audit.log_stored(event)
223
+ self._metrics.increment(NOTIFICATIONS_SENT, channel=str(row["channel"]))
224
+
225
+ def _failed(self, row: sqlite3.Row, error: DeliveryError) -> None:
226
+ now = self._now()
227
+ attempts = int(row["attempt_count"]) + 1
228
+ cancelled = isinstance(error, _Cancelled)
229
+ delay = None if (error.permanent or cancelled) else retry_delay(attempts)
230
+ if cancelled:
231
+ status = DeliveryStatus.CANCELLED
232
+ elif delay is None:
233
+ status = DeliveryStatus.FAILED
234
+ else:
235
+ status = DeliveryStatus.PENDING
236
+ with self._store.transaction() as db:
237
+ db.execute(
238
+ "UPDATE notification_deliveries SET status = ?, attempt_count = ?, "
239
+ "failure_code = ?, last_attempt_at = ?, next_retry_at = ?, "
240
+ "lease_expires_at = NULL, updated_at = ? WHERE delivery_id = ?",
241
+ (
242
+ status.value,
243
+ attempts,
244
+ error.code,
245
+ now.timestamp(),
246
+ (now + delay).timestamp() if delay is not None else None,
247
+ now.timestamp(),
248
+ row["delivery_id"],
249
+ ),
250
+ )
251
+ event = self._store.insert_audit_event(
252
+ db,
253
+ self._audit.build(
254
+ AuditEventType.NOTIFICATION_DELIVERY_FAILED,
255
+ account_id=int(row["account_id"]),
256
+ channel=str(row["channel"]),
257
+ destination=mask_destination(str(row["channel"]), str(row["destination"])),
258
+ notification_delivery=str(row["delivery_id"]),
259
+ notification_event=str(row["event_id"]),
260
+ attempts=attempts,
261
+ failure_code=error.code,
262
+ status=status.value,
263
+ retry_scheduled=delay is not None,
264
+ ),
265
+ )
266
+ self._audit.log_stored(event)
267
+ if status is DeliveryStatus.PENDING:
268
+ self._metrics.increment(NOTIFICATION_RETRIES, channel=str(row["channel"]))
269
+ else:
270
+ self._metrics.increment(NOTIFICATIONS_FAILED, channel=str(row["channel"]))
271
+ log.warning(
272
+ "notification_delivery_failed",
273
+ channel=str(row["channel"]),
274
+ failure_code=error.code,
275
+ attempts=attempts,
276
+ status=status.value,
277
+ )
278
+
279
+
280
+ class _Cancelled(DeliveryError):
281
+ def __init__(self, code: str) -> None:
282
+ super().__init__(code, permanent=True)