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,386 @@
1
+ """Repository onboarding: discovery, onboarding state and enforcement mode.
2
+
3
+ A repository's state has several independent dimensions, and they are never
4
+ merged into one label:
5
+
6
+ ============== ===================================================================
7
+ Dimension Values
8
+ ============== ===================================================================
9
+ connection ``connected`` (installation active, repository listed) /
10
+ ``suspended`` / ``disconnected``
11
+ archived GitHub's ``archived`` flag, from the API
12
+ onboarding ``discovered`` (seen, not yet confirmed by an administrator) /
13
+ ``onboarded`` / ``excluded`` (archived repositories, when the
14
+ organization excludes them)
15
+ mode ``enforce`` (block according to policy) / ``monitor`` (scan, record
16
+ and alert, but report ``block`` as ``warn`` so checks do not fail)
17
+ enforcement ``protected`` / ``at_risk`` / ``unprotected`` / ... (evidence from
18
+ GitHub, :func:`commitguard.controlplane.queries.protection_for`)
19
+ policy effective policy propagation: ``up_to_date`` / ``stale`` /
20
+ ``syncing`` / ``error`` / ``pending``
21
+ ============== ===================================================================
22
+
23
+ So "connected but unprotected, onboarded, monitor" is a valid combination.
24
+
25
+ Organization governance applies to every repository of the organization
26
+ whatever its onboarding state: not onboarding a repository never removes a
27
+ mandatory requirement. A repository with no onboarding row yet (discovered
28
+ before the discovery listener ran) uses the organization's default mode.
29
+
30
+ Discovery: GitHub's installation webhooks and repository synchronisation report
31
+ repositories by immutable ID; :meth:`RepositoryInventory.discovered` records them
32
+ with the organization's defaults. Changing the mode needs ``repositories:manage``
33
+ and ``confirm``: switching to ``enforce`` can make GitHub checks fail and stop
34
+ merges; switching to ``monitor`` stops blocking and also needs a reason.
35
+ """
36
+
37
+ import json
38
+ import sqlite3
39
+ from collections.abc import Callable, Collection, Sequence
40
+ from dataclasses import dataclass
41
+ from datetime import UTC, datetime
42
+
43
+ from commitguard.audit.models import SYSTEM_ACTOR, Actor, AuditEvent, AuditEventType
44
+ from commitguard.controlplane.access import Permission, Principal
45
+ from commitguard.controlplane.errors import ConfirmationRequiredError, InputValidationError
46
+ from commitguard.github.identifiers import RepositoryRef
47
+ from commitguard.github.storage import SqliteStateStore
48
+ from commitguard.governance.cache import invalidate_repositories
49
+ from commitguard.governance.common import (
50
+ MAX_REASON_CHARS,
51
+ account_repositories,
52
+ require,
53
+ require_visible_repositories,
54
+ text,
55
+ ts,
56
+ )
57
+ from commitguard.governance.settings import load_settings
58
+ from commitguard.observability.logging import get_logger
59
+ from commitguard.policies.governance import RepositoryMode
60
+ from commitguard.services.audit import AuditService
61
+
62
+ log = get_logger(__name__)
63
+
64
+ ENFORCE_WARNING = (
65
+ "Enforce mode makes CommitGuard checks fail on policy violations. Where branch "
66
+ "protection requires the check, this prevents merges."
67
+ )
68
+ MONITOR_WARNING = (
69
+ "Monitor mode stops CommitGuard from blocking: violations are recorded and alerted, "
70
+ "but checks report them as warnings."
71
+ )
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class RepositoryGovernance:
76
+ repository_id: int
77
+ onboarding: str
78
+ mode: RepositoryMode
79
+ recorded: bool # False: no row yet, organization defaults shown
80
+
81
+
82
+ def governance_state(
83
+ db: sqlite3.Connection | SqliteStateStore, account_id: int, repository_id: int
84
+ ) -> RepositoryGovernance:
85
+ sql = (
86
+ "SELECT onboarding, mode FROM repository_governance WHERE account_id = ? "
87
+ "AND repository_id = ?"
88
+ )
89
+ params = (int(account_id), int(repository_id))
90
+ if isinstance(db, sqlite3.Connection):
91
+ row = db.execute(sql, params).fetchone()
92
+ else:
93
+ rows = db.query(sql, params)
94
+ row = rows[0] if rows else None
95
+ if row is not None:
96
+ return RepositoryGovernance(
97
+ int(repository_id), str(row["onboarding"]), RepositoryMode(row["mode"]), True
98
+ )
99
+ settings = load_settings(db, account_id).settings
100
+ return RepositoryGovernance(
101
+ int(repository_id), "discovered", settings.default_onboarding_mode, False
102
+ )
103
+
104
+
105
+ def governance_states(store: SqliteStateStore, account_id: int) -> dict[int, RepositoryGovernance]:
106
+ return {
107
+ int(row["repository_id"]): RepositoryGovernance(
108
+ int(row["repository_id"]), str(row["onboarding"]), RepositoryMode(row["mode"]), True
109
+ )
110
+ for row in store.query(
111
+ "SELECT repository_id, onboarding, mode FROM repository_governance "
112
+ "WHERE account_id = ?",
113
+ (int(account_id),),
114
+ )
115
+ }
116
+
117
+
118
+ class RepositoryInventory:
119
+ def __init__(
120
+ self,
121
+ store: SqliteStateStore,
122
+ audit: AuditService,
123
+ *,
124
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
125
+ ) -> None:
126
+ self._store = store
127
+ self._audit = audit
128
+ self._now = now
129
+
130
+ # -- discovery (GitHub events and synchronisation) -------------------- #
131
+ def discovered(
132
+ self, account_id: int, installation_id: int, repositories: Sequence[RepositoryRef]
133
+ ) -> int:
134
+ """Record newly seen repositories with the organization's onboarding defaults."""
135
+ now = self._now()
136
+ stored: list[AuditEvent] = []
137
+ with self._store.transaction() as db:
138
+ settings = load_settings(db, account_id).settings
139
+ known = account_repositories(db, account_id)
140
+ new_ids: list[int] = []
141
+ onboarded: list[int] = []
142
+ excluded: list[int] = []
143
+ for repository in repositories:
144
+ onboarding = "onboarded" if settings.auto_onboard_new_repositories else "discovered"
145
+ info = known.get(repository.id)
146
+ exclude_archived = settings.archived_repositories == "exclude"
147
+ if info is not None and info.archived and exclude_archived:
148
+ onboarding = "excluded"
149
+ cursor = db.execute(
150
+ "INSERT OR IGNORE INTO repository_governance (account_id, repository_id, "
151
+ "onboarding, mode, discovered_at, onboarded_at, onboarded_by, updated_at) "
152
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
153
+ (
154
+ account_id,
155
+ repository.id,
156
+ onboarding,
157
+ settings.default_onboarding_mode.value,
158
+ ts(now),
159
+ ts(now) if onboarding == "onboarded" else None,
160
+ "organization default" if onboarding == "onboarded" else None,
161
+ ts(now),
162
+ ),
163
+ )
164
+ if cursor.rowcount:
165
+ new_ids.append(repository.id)
166
+ if onboarding == "onboarded":
167
+ onboarded.append(repository.id)
168
+ elif onboarding == "excluded":
169
+ excluded.append(repository.id)
170
+ if new_ids:
171
+ stored.append(
172
+ self._store.insert_audit_event(
173
+ db,
174
+ self._audit.build(
175
+ AuditEventType.REPOSITORY_DISCOVERED,
176
+ actor=SYSTEM_ACTOR,
177
+ account_id=account_id,
178
+ installation_id=installation_id,
179
+ repositories=len(new_ids),
180
+ repository_ids=",".join(str(i) for i in new_ids[:50]),
181
+ mode=settings.default_onboarding_mode.value,
182
+ ),
183
+ )
184
+ )
185
+ if onboarded:
186
+ stored.append(
187
+ self._store.insert_audit_event(
188
+ db,
189
+ self._audit.build(
190
+ AuditEventType.REPOSITORY_ONBOARDED,
191
+ actor=SYSTEM_ACTOR,
192
+ account_id=account_id,
193
+ installation_id=installation_id,
194
+ repositories=len(onboarded),
195
+ mode=settings.default_onboarding_mode.value,
196
+ source="organization default",
197
+ ),
198
+ )
199
+ )
200
+ if excluded:
201
+ stored.append(
202
+ self._store.insert_audit_event(
203
+ db,
204
+ self._audit.build(
205
+ AuditEventType.REPOSITORY_EXCLUDED,
206
+ actor=SYSTEM_ACTOR,
207
+ account_id=account_id,
208
+ repositories=len(excluded),
209
+ reason="archived repository",
210
+ ),
211
+ )
212
+ )
213
+ for event in stored:
214
+ self._audit.log_stored(event)
215
+ return len(new_ids)
216
+
217
+ # -- administrator actions -------------------------------------------- #
218
+ def onboard(
219
+ self,
220
+ principal: Principal,
221
+ account_id: int,
222
+ repository_ids: Sequence[int],
223
+ *,
224
+ mode: object,
225
+ confirm: object,
226
+ reason: object = None,
227
+ ) -> list[int]:
228
+ """Onboard repositories in ``mode``. Returns the repositories that changed."""
229
+ require(principal, Permission.REPOSITORIES_MANAGE, account_id)
230
+ target = self.parse_mode(mode)
231
+ ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
232
+ reason_text = text(reason, "reason", limit=MAX_REASON_CHARS)
233
+ self._confirm_mode(account_id, ids, target, confirm, reason_text)
234
+ actor = Actor.user(principal.user_id, principal.login)
235
+ with self._store.transaction() as db:
236
+ changed, events = self.apply_in(
237
+ db, account_id, ids, mode=target, actor=actor, onboard=True, reason=reason_text
238
+ )
239
+ for event in events:
240
+ self._audit.log_stored(event)
241
+ return changed
242
+
243
+ @staticmethod
244
+ def parse_mode(mode: object) -> RepositoryMode:
245
+ if not isinstance(mode, str) or mode not in {m.value for m in RepositoryMode}:
246
+ raise InputValidationError("mode must be monitor or enforce", field="mode")
247
+ return RepositoryMode(mode)
248
+
249
+ def _confirm_mode(
250
+ self,
251
+ account_id: int,
252
+ ids: Sequence[int],
253
+ target: RepositoryMode,
254
+ confirm: object,
255
+ reason: str | None,
256
+ ) -> None:
257
+ current = {i: governance_state(self._store, account_id, i).mode for i in ids}
258
+ changing = [i for i, m in current.items() if m is not target]
259
+ if not changing:
260
+ return
261
+ if confirm is not True:
262
+ raise ConfirmationRequiredError(
263
+ ENFORCE_WARNING if target is RepositoryMode.ENFORCE else MONITOR_WARNING
264
+ )
265
+ if target is RepositoryMode.MONITOR and reason is None:
266
+ raise InputValidationError(
267
+ "A reason is required to stop blocking (monitor mode).", field="reason"
268
+ )
269
+
270
+ def set_mode(
271
+ self,
272
+ principal: Principal,
273
+ account_id: int,
274
+ repository_ids: Sequence[int],
275
+ *,
276
+ mode: object,
277
+ confirm: object,
278
+ reason: object = None,
279
+ ) -> list[int]:
280
+ require(principal, Permission.REPOSITORIES_MANAGE, account_id)
281
+ target = self.parse_mode(mode)
282
+ ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
283
+ reason_text = text(reason, "reason", limit=MAX_REASON_CHARS)
284
+ self._confirm_mode(account_id, ids, target, confirm, reason_text)
285
+ actor = Actor.user(principal.user_id, principal.login)
286
+ with self._store.transaction() as db:
287
+ changed, events = self.apply_in(
288
+ db, account_id, ids, mode=target, actor=actor, onboard=False, reason=reason_text
289
+ )
290
+ for event in events:
291
+ self._audit.log_stored(event)
292
+ return changed
293
+
294
+ def apply_in(
295
+ self,
296
+ db: sqlite3.Connection,
297
+ account_id: int,
298
+ repository_ids: Sequence[int],
299
+ *,
300
+ mode: RepositoryMode | None,
301
+ actor: Actor,
302
+ onboard: bool,
303
+ reason: str | None = None,
304
+ known: Collection[int] | None = None,
305
+ ) -> tuple[list[int], list[AuditEvent]]:
306
+ """Onboard and/or set the mode inside a caller's transaction (bulk operations too)."""
307
+ now = self._now()
308
+ if known is None:
309
+ known = account_repositories(db, account_id).keys()
310
+ changed_mode: list[int] = []
311
+ newly_onboarded: list[int] = []
312
+ for repository_id in repository_ids:
313
+ if repository_id not in known:
314
+ continue
315
+ current = governance_state(db, account_id, repository_id)
316
+ target_mode = mode or current.mode
317
+ onboarding = "onboarded" if onboard else current.onboarding
318
+ if (
319
+ current.recorded
320
+ and current.mode is target_mode
321
+ and current.onboarding == onboarding
322
+ ):
323
+ continue
324
+ db.execute(
325
+ "INSERT INTO repository_governance (account_id, repository_id, onboarding, mode, "
326
+ "discovered_at, onboarded_at, onboarded_by, mode_changed_at, mode_changed_by, "
327
+ "updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
328
+ "ON CONFLICT (account_id, repository_id) DO UPDATE SET "
329
+ "onboarding = excluded.onboarding, mode = excluded.mode, "
330
+ "onboarded_at = COALESCE(excluded.onboarded_at, "
331
+ "repository_governance.onboarded_at), "
332
+ "onboarded_by = COALESCE(excluded.onboarded_by, "
333
+ "repository_governance.onboarded_by), "
334
+ "mode_changed_at = COALESCE(excluded.mode_changed_at, "
335
+ "repository_governance.mode_changed_at), mode_changed_by = "
336
+ "COALESCE(excluded.mode_changed_by, repository_governance.mode_changed_by), "
337
+ "updated_at = excluded.updated_at",
338
+ (
339
+ account_id,
340
+ repository_id,
341
+ onboarding,
342
+ target_mode.value,
343
+ ts(now),
344
+ ts(now) if onboard and current.onboarding != "onboarded" else None,
345
+ actor.login if onboard and current.onboarding != "onboarded" else None,
346
+ ts(now) if current.mode is not target_mode else None,
347
+ actor.login if current.mode is not target_mode else None,
348
+ ts(now),
349
+ ),
350
+ )
351
+ if current.mode is not target_mode:
352
+ changed_mode.append(repository_id)
353
+ if onboard and current.onboarding != "onboarded":
354
+ newly_onboarded.append(repository_id)
355
+ events: list[AuditEvent] = []
356
+ if changed_mode:
357
+ invalidate_repositories(db, account_id, changed_mode, now)
358
+ events.append(
359
+ self._store.insert_audit_event(
360
+ db,
361
+ self._audit.build(
362
+ AuditEventType.REPOSITORY_MODE_CHANGED,
363
+ actor=actor,
364
+ account_id=account_id,
365
+ mode=(mode or RepositoryMode.ENFORCE).value,
366
+ repositories=len(changed_mode),
367
+ repository_ids=json.dumps(changed_mode[:50]),
368
+ reason=reason,
369
+ ),
370
+ )
371
+ )
372
+ if newly_onboarded:
373
+ events.append(
374
+ self._store.insert_audit_event(
375
+ db,
376
+ self._audit.build(
377
+ AuditEventType.REPOSITORY_ONBOARDED,
378
+ actor=actor,
379
+ account_id=account_id,
380
+ repositories=len(newly_onboarded),
381
+ repository_ids=json.dumps(newly_onboarded[:50]),
382
+ mode=mode.value if mode else None,
383
+ ),
384
+ )
385
+ )
386
+ return sorted(set(changed_mode) | set(newly_onboarded)), events