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,765 @@
1
+ """Bulk repository operations: authorized, audited, bounded, idempotent - and never inline.
2
+
3
+ ::
4
+
5
+ Admin ─ POST bulk operation ─► validate (permission, repositories visible, limits)
6
+ │ one row + one item per repository (202, "queued")
7
+
8
+ maintenance loop ─ claim (lease) ─► next batch of pending items
9
+ │ BATCH_SIZE items per transaction; a failing
10
+ │ batch is retried item by item
11
+
12
+ completed / failed (with a reason) / skipped
13
+
14
+
15
+ status: queued -> running -> completed | partial | failed | cancelled
16
+
17
+ Operations
18
+ ==========
19
+
20
+ ===================== =================================== ==========================
21
+ Type Parameters Permission
22
+ ===================== =================================== ==========================
23
+ ``add_to_group`` ``group_id`` ``repositories:manage``
24
+ ``remove_from_group`` ``group_id`` ``repositories:manage``
25
+ ``onboard`` ``mode`` (``monitor``/``enforce``) ``repositories:manage``
26
+ ``set_mode`` ``mode``, ``reason`` for monitor ``repositories:manage``
27
+ ``set_monitoring`` ``enabled`` ``repositories:manage``
28
+ ``schedule_scan`` - ``scans:trigger``
29
+ ===================== =================================== ==========================
30
+
31
+ "Apply a policy" to many repositories is done by adding them to a repository
32
+ group that has the policy: the group's policy then applies through normal
33
+ resolution, with its versioning, approval and rollback.
34
+
35
+ Safety:
36
+
37
+ * at most :data:`MAX_ITEMS` repositories per operation and
38
+ :data:`MAX_OPEN_OPERATIONS` open operations per organization; the request
39
+ only validates and stores the work;
40
+ * the request's ``idempotency_key`` (or a hash of the operation) is unique per
41
+ organization: submitting the same operation twice returns the first one;
42
+ * every item operation is idempotent (adding a member twice is one member), so
43
+ a crash mid-batch is retried safely after the lease expires;
44
+ * changing modes to ``enforce`` or ``monitor`` needs ``confirm`` at request time,
45
+ like the single-repository action;
46
+ * failed items can be retried; an operation can be cancelled while items are
47
+ pending. One audit event records the request, one the outcome.
48
+ """
49
+
50
+ import json
51
+ import sqlite3
52
+ from collections.abc import Callable, Mapping, Sequence
53
+ from datetime import UTC, datetime, timedelta
54
+ from typing import Any
55
+
56
+ from pydantic import BaseModel, ConfigDict
57
+
58
+ from commitguard.audit.models import Actor, AuditEvent, AuditEventType
59
+ from commitguard.controlplane.access import Permission, Principal
60
+ from commitguard.controlplane.errors import (
61
+ ConfirmationRequiredError,
62
+ ConflictError,
63
+ ControlPlaneError,
64
+ InputValidationError,
65
+ NotFoundError,
66
+ PermissionDeniedError,
67
+ )
68
+ from commitguard.exceptions.base import CommitGuardError
69
+ from commitguard.github.storage import SqliteStateStore
70
+ from commitguard.governance.common import (
71
+ MAX_REASON_CHARS,
72
+ account_repositories,
73
+ dt,
74
+ is_hex_id,
75
+ new_id,
76
+ req_dt,
77
+ require,
78
+ require_visible_repositories,
79
+ text,
80
+ ts,
81
+ visible_repository_ids,
82
+ )
83
+ from commitguard.governance.groups import RepositoryGroupService
84
+ from commitguard.governance.inventory import (
85
+ ENFORCE_WARNING,
86
+ MONITOR_WARNING,
87
+ RepositoryInventory,
88
+ )
89
+ from commitguard.governance.schedules import DefaultBranchScanner
90
+ from commitguard.observability.logging import get_logger
91
+ from commitguard.policies.governance import RepositoryMode
92
+ from commitguard.security.hashing import fingerprint
93
+ from commitguard.services.audit import AuditService
94
+
95
+ log = get_logger(__name__)
96
+
97
+ MAX_ITEMS = 5000
98
+ MAX_OPEN_OPERATIONS = 10
99
+ ITEMS_PER_TICK = 200
100
+ #: Items applied per database transaction (a failure is isolated per batch, then per item).
101
+ BATCH_SIZE = 100
102
+ LEASE = timedelta(minutes=5)
103
+ TYPES = (
104
+ "add_to_group",
105
+ "remove_from_group",
106
+ "onboard",
107
+ "set_mode",
108
+ "set_monitoring",
109
+ "schedule_scan",
110
+ )
111
+
112
+
113
+ class BulkItemView(BaseModel):
114
+ model_config = ConfigDict(frozen=True, extra="forbid")
115
+
116
+ repository_id: int
117
+ full_name: str | None
118
+ status: str
119
+ attempts: int
120
+ detail: str | None
121
+
122
+
123
+ class BulkOperationView(BaseModel):
124
+ model_config = ConfigDict(frozen=True, extra="forbid")
125
+
126
+ id: str
127
+ organization_id: int
128
+ type: str
129
+ parameters: dict[str, Any]
130
+ status: str
131
+ total: int
132
+ completed: int
133
+ failed: int
134
+ skipped: int
135
+ pending: int
136
+ requested_by: str | None
137
+ created_at: datetime
138
+ started_at: datetime | None
139
+ completed_at: datetime | None
140
+ cancelled_by: str | None
141
+ items: tuple[BulkItemView, ...]
142
+ can_manage: bool
143
+
144
+
145
+ class BulkOperationService:
146
+ def __init__(
147
+ self,
148
+ store: SqliteStateStore,
149
+ audit: AuditService,
150
+ groups: RepositoryGroupService,
151
+ inventory: RepositoryInventory,
152
+ scanner: DefaultBranchScanner | None,
153
+ *,
154
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
155
+ ) -> None:
156
+ self._store = store
157
+ self._audit = audit
158
+ self._groups = groups
159
+ self._inventory = inventory
160
+ self._scanner = scanner
161
+ self._now = now
162
+
163
+ # -- requests --------------------------------------------------------- #
164
+ def create(
165
+ self,
166
+ principal: Principal,
167
+ account_id: int,
168
+ *,
169
+ operation_type: object,
170
+ repository_ids: Sequence[int],
171
+ parameters: object,
172
+ idempotency_key: object,
173
+ confirm: object,
174
+ ) -> BulkOperationView:
175
+ if operation_type not in TYPES:
176
+ raise InputValidationError(f"type must be one of {', '.join(TYPES)}", field="type")
177
+ assert isinstance(operation_type, str) # noqa: S101 - checked above
178
+ permission = (
179
+ Permission.SCANS_TRIGGER
180
+ if operation_type == "schedule_scan"
181
+ else Permission.REPOSITORIES_MANAGE
182
+ )
183
+ require(principal, permission, account_id)
184
+ if len(repository_ids) > MAX_ITEMS:
185
+ raise InputValidationError(
186
+ f"a bulk operation covers at most {MAX_ITEMS} repositories",
187
+ field="repository_ids",
188
+ )
189
+ ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
190
+ params = self._parameters(principal, account_id, operation_type, parameters, confirm)
191
+ if idempotency_key is not None and (
192
+ not isinstance(idempotency_key, str) or not 8 <= len(idempotency_key) <= 128
193
+ ):
194
+ raise InputValidationError(
195
+ "idempotency_key must be 8-128 characters", field="idempotency_key"
196
+ )
197
+ key = idempotency_key or fingerprint(
198
+ [operation_type, json.dumps(params, sort_keys=True), *map(str, ids)]
199
+ )
200
+ now = self._now()
201
+ operation_id = new_id()
202
+ with self._store.transaction() as db:
203
+ existing = db.execute(
204
+ "SELECT operation_id FROM bulk_operations WHERE account_id = ? "
205
+ "AND idempotency_key = ?",
206
+ (account_id, key),
207
+ ).fetchone()
208
+ if existing is not None:
209
+ existing_id = str(existing["operation_id"])
210
+ else:
211
+ existing_id = None
212
+ open_count = db.execute(
213
+ "SELECT COUNT(*) AS n FROM bulk_operations WHERE account_id = ? "
214
+ "AND status IN ('queued', 'running')",
215
+ (account_id,),
216
+ ).fetchone()["n"]
217
+ if int(open_count) >= MAX_OPEN_OPERATIONS:
218
+ raise ConflictError(
219
+ "Too many bulk operations are in progress for this organization. "
220
+ "Wait for one to finish."
221
+ )
222
+ db.execute(
223
+ "INSERT INTO bulk_operations (operation_id, account_id, type, parameters, "
224
+ "idempotency_key, requested_by_id, requested_by_login, created_at, status, "
225
+ "total, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)",
226
+ (
227
+ operation_id,
228
+ account_id,
229
+ operation_type,
230
+ json.dumps(params, sort_keys=True),
231
+ key,
232
+ principal.user_id,
233
+ principal.login,
234
+ ts(now),
235
+ len(ids),
236
+ ts(now),
237
+ ),
238
+ )
239
+ db.executemany(
240
+ "INSERT INTO bulk_operation_items (operation_id, repository_id, status, "
241
+ "updated_at) VALUES (?, ?, 'pending', ?)",
242
+ [(operation_id, repository_id, ts(now)) for repository_id in ids],
243
+ )
244
+ stored = self._store.insert_audit_event(
245
+ db,
246
+ self._audit.build(
247
+ AuditEventType.BULK_OPERATION_REQUESTED,
248
+ actor=Actor.user(principal.user_id, principal.login),
249
+ account_id=account_id,
250
+ operation=operation_id,
251
+ operation_type=operation_type,
252
+ repositories=len(ids),
253
+ parameters=json.dumps(params, sort_keys=True)[:400],
254
+ ),
255
+ )
256
+ if existing_id is not None:
257
+ return self.get(principal, existing_id)
258
+ self._audit.log_stored(stored)
259
+ return self.get(principal, operation_id)
260
+
261
+ def _parameters(
262
+ self,
263
+ principal: Principal,
264
+ account_id: int,
265
+ operation_type: str,
266
+ raw: object,
267
+ confirm: object,
268
+ ) -> dict[str, Any]:
269
+ params = raw if isinstance(raw, dict) else {}
270
+ if raw is not None and not isinstance(raw, dict):
271
+ raise InputValidationError("parameters must be an object", field="parameters")
272
+ if operation_type in ("add_to_group", "remove_from_group"):
273
+ group_id = params.get("group_id")
274
+ if not is_hex_id(group_id):
275
+ raise InputValidationError("group_id is required", field="parameters.group_id")
276
+ assert isinstance(group_id, str) # noqa: S101 - checked above
277
+ if self._groups.account_of(principal, group_id, Permission.REPOSITORIES_MANAGE) != (
278
+ account_id
279
+ ):
280
+ raise NotFoundError()
281
+ return {"group_id": group_id}
282
+ if operation_type in ("onboard", "set_mode"):
283
+ mode = RepositoryInventory.parse_mode(params.get("mode"))
284
+ reason = text(params.get("reason"), "reason", limit=MAX_REASON_CHARS)
285
+ if confirm is not True:
286
+ raise ConfirmationRequiredError(
287
+ ENFORCE_WARNING if mode is RepositoryMode.ENFORCE else MONITOR_WARNING
288
+ )
289
+ if mode is RepositoryMode.MONITOR and reason is None:
290
+ raise InputValidationError(
291
+ "A reason is required to stop blocking (monitor mode).",
292
+ field="parameters.reason",
293
+ )
294
+ return {"mode": mode.value, "reason": reason}
295
+ if operation_type == "set_monitoring":
296
+ enabled = params.get("enabled")
297
+ if not isinstance(enabled, bool):
298
+ raise InputValidationError(
299
+ "enabled must be true or false", field="parameters.enabled"
300
+ )
301
+ if not enabled and confirm is not True:
302
+ raise ConfirmationRequiredError(
303
+ "Pausing monitoring stops CommitGuard checks for these repositories."
304
+ )
305
+ return {"enabled": enabled}
306
+ return {}
307
+
308
+ # -- reads ------------------------------------------------------------ #
309
+ def _row(self, operation_id: str) -> sqlite3.Row:
310
+ if not is_hex_id(operation_id):
311
+ raise NotFoundError()
312
+ rows = self._store.query(
313
+ "SELECT * FROM bulk_operations WHERE operation_id = ?", (operation_id,)
314
+ )
315
+ if not rows:
316
+ raise NotFoundError()
317
+ return rows[0]
318
+
319
+ def get(self, principal: Principal, operation_id: str) -> BulkOperationView:
320
+ row = self._row(operation_id)
321
+ account_id = int(row["account_id"])
322
+ require(principal, Permission.REPOSITORIES_READ, account_id)
323
+ return self._view(row, principal, with_items=True)
324
+
325
+ def list_operations(self, principal: Principal, account_id: int) -> list[BulkOperationView]:
326
+ require(principal, Permission.REPOSITORIES_READ, account_id)
327
+ rows = self._store.query(
328
+ "SELECT * FROM bulk_operations WHERE account_id = ? ORDER BY created_at DESC LIMIT 50",
329
+ (account_id,),
330
+ )
331
+ return [self._view(row, principal, with_items=False) for row in rows]
332
+
333
+ def _view(
334
+ self, row: sqlite3.Row, principal: Principal, *, with_items: bool
335
+ ) -> BulkOperationView:
336
+ account_id = int(row["account_id"])
337
+ operation_id = str(row["operation_id"])
338
+ counts = {
339
+ str(r["status"]): int(r["n"])
340
+ for r in self._store.query(
341
+ "SELECT status, COUNT(*) AS n FROM bulk_operation_items WHERE operation_id = ? "
342
+ "GROUP BY status",
343
+ (operation_id,),
344
+ )
345
+ }
346
+ items: tuple[BulkItemView, ...] = ()
347
+ if with_items:
348
+ names = {
349
+ repository_id: repository.full_name
350
+ for repository_id, repository in account_repositories(
351
+ self._store, account_id
352
+ ).items()
353
+ }
354
+ visible = visible_repository_ids(self._store, principal, account_id)
355
+ items = tuple(
356
+ BulkItemView(
357
+ repository_id=int(r["repository_id"]),
358
+ full_name=names.get(int(r["repository_id"]))
359
+ if int(r["repository_id"]) in visible
360
+ else None,
361
+ status=str(r["status"]),
362
+ attempts=int(r["attempts"]),
363
+ detail=r["detail"],
364
+ )
365
+ for r in self._store.query(
366
+ "SELECT * FROM bulk_operation_items WHERE operation_id = ? "
367
+ "ORDER BY CASE status WHEN 'failed' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, "
368
+ "repository_id LIMIT 500",
369
+ (operation_id,),
370
+ )
371
+ )
372
+ permission = (
373
+ Permission.SCANS_TRIGGER
374
+ if row["type"] == "schedule_scan"
375
+ else Permission.REPOSITORIES_MANAGE
376
+ )
377
+ return BulkOperationView(
378
+ id=operation_id,
379
+ organization_id=account_id,
380
+ type=str(row["type"]),
381
+ parameters=json.loads(str(row["parameters"])),
382
+ status=str(row["status"]),
383
+ total=int(row["total"]),
384
+ completed=counts.get("completed", 0),
385
+ failed=counts.get("failed", 0),
386
+ skipped=counts.get("skipped", 0),
387
+ pending=counts.get("pending", 0),
388
+ requested_by=row["requested_by_login"],
389
+ created_at=req_dt(row["created_at"]),
390
+ started_at=dt(row["started_at"]),
391
+ completed_at=dt(row["completed_at"]),
392
+ cancelled_by=row["cancelled_by_login"],
393
+ items=items,
394
+ can_manage=principal.can(permission, account_id),
395
+ )
396
+
397
+ # -- control ---------------------------------------------------------- #
398
+ def _manageable(self, principal: Principal, operation_id: str) -> sqlite3.Row:
399
+ row = self._row(operation_id)
400
+ account_id = int(row["account_id"])
401
+ require(principal, Permission.REPOSITORIES_READ, account_id)
402
+ permission = (
403
+ Permission.SCANS_TRIGGER
404
+ if row["type"] == "schedule_scan"
405
+ else Permission.REPOSITORIES_MANAGE
406
+ )
407
+ if not principal.can(permission, account_id):
408
+ raise PermissionDeniedError()
409
+ return row
410
+
411
+ def cancel(self, principal: Principal, operation_id: str) -> BulkOperationView:
412
+ row = self._manageable(principal, operation_id)
413
+ if str(row["status"]) not in ("queued", "running"):
414
+ raise ConflictError(f"A {row['status']} operation cannot be cancelled.")
415
+ now = self._now()
416
+ with self._store.transaction() as db:
417
+ db.execute(
418
+ "UPDATE bulk_operation_items SET status = 'cancelled', updated_at = ? "
419
+ "WHERE operation_id = ? AND status = 'pending'",
420
+ (ts(now), operation_id),
421
+ )
422
+ db.execute(
423
+ "UPDATE bulk_operations SET status = 'cancelled', completed_at = ?, "
424
+ "cancelled_by_login = ?, lease_expires_at = NULL, updated_at = ? "
425
+ "WHERE operation_id = ?",
426
+ (ts(now), principal.login, ts(now), operation_id),
427
+ )
428
+ stored = self._store.insert_audit_event(
429
+ db,
430
+ self._audit.build(
431
+ AuditEventType.BULK_OPERATION_CANCELLED,
432
+ actor=Actor.user(principal.user_id, principal.login),
433
+ account_id=int(row["account_id"]),
434
+ operation=operation_id,
435
+ ),
436
+ )
437
+ self._audit.log_stored(stored)
438
+ return self.get(principal, operation_id)
439
+
440
+ def retry_failed(self, principal: Principal, operation_id: str) -> BulkOperationView:
441
+ row = self._manageable(principal, operation_id)
442
+ if str(row["status"]) not in ("partial", "failed"):
443
+ raise ConflictError("Only an operation with failed items can be retried.")
444
+ now = self._now()
445
+ with self._store.transaction() as db:
446
+ retried = db.execute(
447
+ "UPDATE bulk_operation_items SET status = 'pending', updated_at = ? "
448
+ "WHERE operation_id = ? AND status = 'failed'",
449
+ (ts(now), operation_id),
450
+ ).rowcount
451
+ if retried:
452
+ db.execute(
453
+ "UPDATE bulk_operations SET status = 'queued', completed_at = NULL, "
454
+ "updated_at = ? WHERE operation_id = ?",
455
+ (ts(now), operation_id),
456
+ )
457
+ return self.get(principal, operation_id)
458
+
459
+ # -- processing ------------------------------------------------------- #
460
+ def run_pending(self, budget: int = ITEMS_PER_TICK) -> int:
461
+ """Process pending items of queued/running operations. Returns items processed."""
462
+ now = self._now()
463
+ processed = 0
464
+ operations = self._store.query(
465
+ "SELECT * FROM bulk_operations WHERE status = 'queued' OR (status = 'running' "
466
+ "AND (lease_expires_at IS NULL OR lease_expires_at < ?)) ORDER BY created_at LIMIT 5",
467
+ (ts(now),),
468
+ )
469
+ for operation in operations:
470
+ if processed >= budget:
471
+ break
472
+ operation_id = str(operation["operation_id"])
473
+ with self._store.transaction() as db:
474
+ claimed = db.execute(
475
+ "UPDATE bulk_operations SET status = 'running', started_at = "
476
+ "COALESCE(started_at, ?), lease_expires_at = ?, updated_at = ? "
477
+ "WHERE operation_id = ? AND (status = 'queued' OR (status = 'running' AND "
478
+ "(lease_expires_at IS NULL OR lease_expires_at < ?)))",
479
+ (ts(now), ts(now + LEASE), ts(now), operation_id, ts(now)),
480
+ ).rowcount
481
+ if claimed != 1:
482
+ continue
483
+ items = self._store.query(
484
+ "SELECT repository_id FROM bulk_operation_items WHERE operation_id = ? "
485
+ "AND status = 'pending' ORDER BY repository_id LIMIT ?",
486
+ (operation_id, budget - processed),
487
+ )
488
+ ids = [int(item["repository_id"]) for item in items]
489
+ if str(operation["type"]) == "schedule_scan":
490
+ for repository_id in ids: # each one calls GitHub: one at a time
491
+ self._process_item(operation, repository_id)
492
+ else:
493
+ # The organization's repositories, once per pass (not per batch).
494
+ known = account_repositories(self._store, int(operation["account_id"]))
495
+ for start in range(0, len(ids), BATCH_SIZE):
496
+ self._process_batch(operation, ids[start : start + BATCH_SIZE], known)
497
+ processed += len(ids)
498
+ self._finish_if_done(operation)
499
+ return processed
500
+
501
+ def _process_batch(
502
+ self, operation: sqlite3.Row, ids: list[int], known: Mapping[int, object]
503
+ ) -> None:
504
+ """Apply a batch in one transaction; on an unexpected database error, item by item."""
505
+ account_id = int(operation["account_id"])
506
+ operation_id = str(operation["operation_id"])
507
+ params = json.loads(str(operation["parameters"]))
508
+ kind = str(operation["type"])
509
+ actor = Actor.user(
510
+ int(operation["requested_by_id"] or 0),
511
+ str(operation["requested_by_login"] or "bulk operation"),
512
+ )
513
+ now = self._now()
514
+ events: list[AuditEvent] = []
515
+ try:
516
+ with self._store.transaction() as db:
517
+ results: dict[int, tuple[str, str | None]] = {
518
+ i: ("failed", "repository not found in this organization")
519
+ for i in ids
520
+ if i not in known
521
+ }
522
+ valid = [i for i in ids if i in known]
523
+ changed: set[int] = set()
524
+ db.execute("SAVEPOINT bulk_batch")
525
+ try:
526
+ if kind in ("add_to_group", "remove_from_group"):
527
+ method = (
528
+ self._groups.add_members_in
529
+ if kind == "add_to_group"
530
+ else self._groups.remove_members_in
531
+ )
532
+ done, event = method(
533
+ db,
534
+ account_id,
535
+ str(params["group_id"]),
536
+ valid,
537
+ actor=actor,
538
+ known=known.keys(),
539
+ )
540
+ changed.update(done)
541
+ events.extend([event] if event is not None else [])
542
+ elif kind in ("onboard", "set_mode"):
543
+ done, mode_events = self._inventory.apply_in(
544
+ db,
545
+ account_id,
546
+ valid,
547
+ mode=RepositoryMode(params["mode"]),
548
+ actor=actor,
549
+ onboard=kind == "onboard",
550
+ reason=params.get("reason"),
551
+ known=known.keys(),
552
+ )
553
+ changed.update(done)
554
+ events.extend(mode_events)
555
+ else:
556
+ for repository_id in valid:
557
+ if self._apply_in(db, kind, account_id, repository_id, params, actor):
558
+ continue
559
+ changed.add(repository_id)
560
+ db.execute("RELEASE bulk_batch")
561
+ except (ControlPlaneError, CommitGuardError) as exc:
562
+ db.execute("ROLLBACK TO bulk_batch")
563
+ db.execute("RELEASE bulk_batch")
564
+ events.clear()
565
+ changed.clear()
566
+ message = (
567
+ str(exc)[:200]
568
+ if isinstance(exc, ControlPlaneError)
569
+ else (type(exc).__name__)
570
+ )
571
+ results.update({i: ("failed", message) for i in valid})
572
+ for repository_id in valid:
573
+ results.setdefault(
574
+ repository_id,
575
+ ("completed", None)
576
+ if repository_id in changed
577
+ else ("skipped", "unchanged"),
578
+ )
579
+ db.executemany(
580
+ "UPDATE bulk_operation_items SET status = ?, attempts = attempts + 1, "
581
+ "detail = ?, updated_at = ? WHERE operation_id = ? AND repository_id = ? "
582
+ "AND status = 'pending'",
583
+ [
584
+ (status, detail, ts(now), operation_id, repository_id)
585
+ for repository_id, (status, detail) in results.items()
586
+ ],
587
+ )
588
+ except CommitGuardError:
589
+ log.warning("bulk_batch_failed_retrying_items", operation=operation_id)
590
+ for repository_id in ids:
591
+ self._process_item(operation, repository_id)
592
+ return
593
+ for event in events:
594
+ self._audit.log_stored(event)
595
+
596
+ def _process_item(self, operation: sqlite3.Row, repository_id: int) -> None:
597
+ account_id = int(operation["account_id"])
598
+ operation_id = str(operation["operation_id"])
599
+ params = json.loads(str(operation["parameters"]))
600
+ actor = Actor.user(
601
+ int(operation["requested_by_id"] or 0),
602
+ str(operation["requested_by_login"] or "bulk operation"),
603
+ )
604
+ status, detail = "completed", None
605
+ now = self._now()
606
+ try:
607
+ kind = str(operation["type"])
608
+ if kind == "schedule_scan":
609
+ outcome = (
610
+ self._scanner.queue(
611
+ account_id,
612
+ repository_id,
613
+ key=("bulk", operation_id),
614
+ requested_by=actor.login,
615
+ )
616
+ if self._scanner is not None
617
+ else "skipped:no_scanner"
618
+ )
619
+ if outcome != "queued":
620
+ status, detail = "skipped", outcome.split(":", 1)[-1]
621
+ else:
622
+ with self._store.transaction() as db:
623
+ detail = self._apply_in(db, kind, account_id, repository_id, params, actor)
624
+ if detail is not None:
625
+ status = "skipped"
626
+ except (ControlPlaneError, CommitGuardError, OSError) as exc:
627
+ status = "failed"
628
+ detail = str(exc)[:200] if isinstance(exc, ControlPlaneError) else type(exc).__name__
629
+ with self._store.transaction() as db:
630
+ db.execute(
631
+ "UPDATE bulk_operation_items SET status = ?, attempts = attempts + 1, detail = ?, "
632
+ "updated_at = ? WHERE operation_id = ? AND repository_id = ? AND status = "
633
+ "'pending'",
634
+ (status, detail, ts(now), operation_id, repository_id),
635
+ )
636
+
637
+ def _apply_in(
638
+ self,
639
+ db: sqlite3.Connection,
640
+ kind: str,
641
+ account_id: int,
642
+ repository_id: int,
643
+ params: dict[str, Any],
644
+ actor: Actor,
645
+ ) -> str | None:
646
+ """Apply one item in a transaction. Returns a skip reason, or None when applied."""
647
+ if repository_id not in account_repositories(db, account_id):
648
+ return "not_found"
649
+ if kind in ("add_to_group", "remove_from_group"):
650
+ method = (
651
+ self._groups.add_members_in
652
+ if kind == "add_to_group"
653
+ else self._groups.remove_members_in
654
+ )
655
+ _, event = method(db, account_id, str(params["group_id"]), [repository_id], actor=actor)
656
+ return None if event is not None else "unchanged"
657
+ if kind in ("onboard", "set_mode"):
658
+ changed, _ = self._inventory.apply_in(
659
+ db,
660
+ account_id,
661
+ [repository_id],
662
+ mode=RepositoryMode(params["mode"]),
663
+ actor=actor,
664
+ onboard=kind == "onboard",
665
+ reason=params.get("reason"),
666
+ )
667
+ return None if changed else "unchanged"
668
+ if kind == "set_monitoring":
669
+ installations = [
670
+ int(r["installation_id"])
671
+ for r in db.execute(
672
+ "SELECT k.installation_id FROM known_repositories k JOIN installations i ON "
673
+ "i.installation_id = k.installation_id WHERE i.account_id = ? "
674
+ "AND k.repository_id = ?",
675
+ (account_id, repository_id),
676
+ ).fetchall()
677
+ ]
678
+ for installation_id in installations:
679
+ self._store.insert_audit_event(
680
+ db,
681
+ self._audit.build(
682
+ AuditEventType.REPOSITORY_MONITORING_ENABLED
683
+ if params["enabled"]
684
+ else AuditEventType.REPOSITORY_MONITORING_DISABLED,
685
+ actor=actor,
686
+ account_id=account_id,
687
+ installation_id=installation_id,
688
+ repository_id=repository_id,
689
+ source="bulk operation",
690
+ ),
691
+ )
692
+ db.execute(
693
+ "INSERT INTO repository_settings (installation_id, repository_id, "
694
+ "monitoring_enabled, updated_at, updated_by_login) VALUES (?, ?, ?, ?, ?) "
695
+ "ON CONFLICT (installation_id, repository_id) DO UPDATE SET "
696
+ "monitoring_enabled = excluded.monitoring_enabled, "
697
+ "updated_at = excluded.updated_at, updated_by_login = "
698
+ "excluded.updated_by_login",
699
+ (
700
+ installation_id,
701
+ repository_id,
702
+ 1 if params["enabled"] else 0,
703
+ ts(self._now()),
704
+ actor.login,
705
+ ),
706
+ )
707
+ return None
708
+ raise InputValidationError("unknown bulk operation") # pragma: no cover
709
+
710
+ def _finish_if_done(self, operation: sqlite3.Row) -> None:
711
+ operation_id = str(operation["operation_id"])
712
+ counts = {
713
+ str(r["status"]): int(r["n"])
714
+ for r in self._store.query(
715
+ "SELECT status, COUNT(*) AS n FROM bulk_operation_items WHERE operation_id = ? "
716
+ "GROUP BY status",
717
+ (operation_id,),
718
+ )
719
+ }
720
+ now = self._now()
721
+ if counts.get("pending", 0):
722
+ with self._store.transaction() as db:
723
+ db.execute(
724
+ "UPDATE bulk_operations SET completed = ?, failed = ?, lease_expires_at = "
725
+ "NULL, "
726
+ "updated_at = ? WHERE operation_id = ?",
727
+ (
728
+ counts.get("completed", 0) + counts.get("skipped", 0),
729
+ counts.get("failed", 0),
730
+ ts(now),
731
+ operation_id,
732
+ ),
733
+ )
734
+ return
735
+ failed = counts.get("failed", 0)
736
+ done = counts.get("completed", 0) + counts.get("skipped", 0)
737
+ status = "completed" if not failed else ("failed" if not done else "partial")
738
+ with self._store.transaction() as db:
739
+ changed = db.execute(
740
+ "UPDATE bulk_operations SET status = ?, completed = ?, failed = ?, completed_at = "
741
+ "?, "
742
+ "lease_expires_at = NULL, updated_at = ? WHERE operation_id = ? "
743
+ "AND status = 'running'",
744
+ (status, done, failed, ts(now), ts(now), operation_id),
745
+ ).rowcount
746
+ if changed != 1:
747
+ return
748
+ stored = self._store.insert_audit_event(
749
+ db,
750
+ self._audit.build(
751
+ AuditEventType.BULK_OPERATION_FINISHED,
752
+ actor=Actor.user(
753
+ int(operation["requested_by_id"] or 0),
754
+ str(operation["requested_by_login"] or "bulk operation"),
755
+ ),
756
+ account_id=int(operation["account_id"]),
757
+ operation=operation_id,
758
+ operation_type=str(operation["type"]),
759
+ result=status,
760
+ completed=counts.get("completed", 0),
761
+ skipped=counts.get("skipped", 0),
762
+ failed=failed,
763
+ ),
764
+ )
765
+ self._audit.log_stored(stored)