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,448 @@
1
+ """Repository groups: named sets of repositories that policies and exceptions can target.
2
+
3
+ A repository may belong to any number of groups (``Production`` and
4
+ ``Backend``). Where several groups set a *default* for the same rule the most
5
+ restrictive default applies; *mandatory* requirements always combine to the
6
+ strongest (see :mod:`commitguard.policies.governance`).
7
+
8
+ Groups are archived, never deleted: policy versions, exceptions and audit
9
+ events keep referring to them. Membership changes need
10
+ ``repositories:manage``, name only repositories of the same organization that
11
+ the caller can see, are audited, and invalidate the effective policy of exactly
12
+ the repositories added or removed.
13
+ """
14
+
15
+ import sqlite3
16
+ from collections.abc import Callable, Collection, Sequence
17
+ from datetime import UTC, datetime
18
+
19
+ from pydantic import BaseModel, ConfigDict
20
+
21
+ from commitguard.audit.models import Actor, AuditEvent, AuditEventType
22
+ from commitguard.controlplane.access import Permission, Principal
23
+ from commitguard.controlplane.errors import (
24
+ ConfirmationRequiredError,
25
+ ConflictError,
26
+ InputValidationError,
27
+ NotFoundError,
28
+ )
29
+ from commitguard.github.storage import SqliteStateStore
30
+ from commitguard.governance.cache import group_member_ids, invalidate_repositories
31
+ from commitguard.governance.common import (
32
+ MAX_DESCRIPTION_CHARS,
33
+ MAX_NAME_CHARS,
34
+ account_repositories,
35
+ dt,
36
+ is_hex_id,
37
+ new_id,
38
+ req_dt,
39
+ require,
40
+ require_visible_repositories,
41
+ text,
42
+ ts,
43
+ visible_repository_ids,
44
+ )
45
+ from commitguard.services.audit import AuditService
46
+
47
+ MAX_GROUPS_PER_ORGANIZATION = 500
48
+
49
+
50
+ class RepositoryGroupView(BaseModel):
51
+ model_config = ConfigDict(frozen=True, extra="forbid")
52
+
53
+ id: str
54
+ organization_id: int
55
+ name: str
56
+ description: str | None
57
+ repository_count: int
58
+ policy_version: int
59
+ active_exceptions: int
60
+ created_at: datetime
61
+ created_by: str | None
62
+ updated_at: datetime
63
+ archived_at: datetime | None
64
+
65
+
66
+ class GroupMemberView(BaseModel):
67
+ model_config = ConfigDict(frozen=True, extra="forbid")
68
+
69
+ repository_id: int
70
+ full_name: str
71
+ added_at: datetime
72
+ added_by: str | None
73
+
74
+
75
+ class RepositoryGroupDetail(BaseModel):
76
+ model_config = ConfigDict(frozen=True, extra="forbid")
77
+
78
+ group: RepositoryGroupView
79
+ repositories: tuple[GroupMemberView, ...]
80
+ hidden_repositories: int # members GitHub did not report to this session
81
+ can_manage: bool
82
+
83
+
84
+ _GROUP_SELECT = (
85
+ "SELECT g.*, (SELECT COUNT(*) FROM repository_group_members m WHERE m.group_id = g.group_id) "
86
+ "AS repository_count, COALESCE((SELECT MAX(version) FROM scoped_policy_versions p WHERE "
87
+ "p.account_id = g.account_id AND p.target_type = 'group' AND p.target_id = g.group_id), 0) "
88
+ "AS policy_version, (SELECT COUNT(*) FROM policy_exceptions e WHERE e.account_id = "
89
+ "g.account_id AND e.scope_type = 'group' AND e.scope_id = g.group_id AND e.status = 'active') "
90
+ "AS active_exceptions FROM repository_groups g"
91
+ )
92
+
93
+
94
+ def _name_key(name: str) -> str:
95
+ return " ".join(name.casefold().split())
96
+
97
+
98
+ class RepositoryGroupService:
99
+ def __init__(
100
+ self,
101
+ store: SqliteStateStore,
102
+ audit: AuditService,
103
+ *,
104
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
105
+ ) -> None:
106
+ self._store = store
107
+ self._audit = audit
108
+ self._now = now
109
+
110
+ @staticmethod
111
+ def _view(r: sqlite3.Row) -> RepositoryGroupView:
112
+ return RepositoryGroupView(
113
+ id=r["group_id"],
114
+ organization_id=int(r["account_id"]),
115
+ name=r["name"],
116
+ description=r["description"],
117
+ repository_count=int(r["repository_count"]),
118
+ policy_version=int(r["policy_version"]),
119
+ active_exceptions=int(r["active_exceptions"]),
120
+ created_at=req_dt(r["created_at"]),
121
+ created_by=r["created_by"],
122
+ updated_at=req_dt(r["updated_at"]),
123
+ archived_at=dt(r["archived_at"]),
124
+ )
125
+
126
+ def list_groups(
127
+ self, principal: Principal, account_id: int, *, include_archived: bool = False
128
+ ) -> list[RepositoryGroupView]:
129
+ require(principal, Permission.REPOSITORIES_READ, account_id)
130
+ sql = f"{_GROUP_SELECT} WHERE g.account_id = ?"
131
+ if not include_archived:
132
+ sql += " AND g.archived_at IS NULL"
133
+ rows = self._store.query(sql + " ORDER BY g.name_key LIMIT 1000", (account_id,))
134
+ return [self._view(row) for row in rows]
135
+
136
+ def _group_row(self, group_id: str) -> sqlite3.Row:
137
+ if not is_hex_id(group_id):
138
+ raise NotFoundError()
139
+ rows = self._store.query(f"{_GROUP_SELECT} WHERE g.group_id = ?", (group_id,))
140
+ if not rows:
141
+ raise NotFoundError()
142
+ return rows[0]
143
+
144
+ def account_of(self, principal: Principal, group_id: str, permission: Permission) -> int:
145
+ """The group's organization after checking the caller may ``permission`` there."""
146
+ row = self._group_row(group_id)
147
+ account_id = int(row["account_id"])
148
+ require(principal, permission, account_id)
149
+ return account_id
150
+
151
+ def get(self, principal: Principal, group_id: str) -> RepositoryGroupDetail:
152
+ row = self._group_row(group_id)
153
+ account_id = int(row["account_id"])
154
+ require(principal, Permission.REPOSITORIES_READ, account_id)
155
+ visible = visible_repository_ids(self._store, principal, account_id)
156
+ known = account_repositories(self._store, account_id)
157
+ members = self._store.query(
158
+ "SELECT repository_id, added_at, added_by FROM repository_group_members "
159
+ "WHERE group_id = ? ORDER BY added_at",
160
+ (group_id,),
161
+ )
162
+ shown = []
163
+ hidden = 0
164
+ for member in members:
165
+ repository_id = int(member["repository_id"])
166
+ repository = known.get(repository_id)
167
+ if repository is None or repository_id not in visible:
168
+ hidden += 1
169
+ continue
170
+ shown.append(
171
+ GroupMemberView(
172
+ repository_id=repository_id,
173
+ full_name=repository.full_name,
174
+ added_at=req_dt(member["added_at"]),
175
+ added_by=member["added_by"],
176
+ )
177
+ )
178
+ shown.sort(key=lambda m: m.full_name.casefold())
179
+ return RepositoryGroupDetail(
180
+ group=self._view(row),
181
+ repositories=tuple(shown),
182
+ hidden_repositories=hidden,
183
+ can_manage=principal.can(Permission.REPOSITORIES_MANAGE, account_id),
184
+ )
185
+
186
+ def create(
187
+ self, principal: Principal, account_id: int, *, name: object, description: object
188
+ ) -> RepositoryGroupView:
189
+ require(principal, Permission.REPOSITORIES_MANAGE, account_id)
190
+ clean_name = text(name, "name", limit=MAX_NAME_CHARS, required=True)
191
+ assert clean_name is not None # noqa: S101 - required=True
192
+ clean_description = text(description, "description", limit=MAX_DESCRIPTION_CHARS)
193
+ now = self._now()
194
+ group_id = new_id()
195
+ actor = Actor.user(principal.user_id, principal.login)
196
+ with self._store.transaction() as db:
197
+ count = db.execute(
198
+ "SELECT COUNT(*) AS n FROM repository_groups WHERE account_id = ? "
199
+ "AND archived_at IS NULL",
200
+ (account_id,),
201
+ ).fetchone()["n"]
202
+ if int(count) >= MAX_GROUPS_PER_ORGANIZATION:
203
+ raise ConflictError(
204
+ f"An organization can have at most {MAX_GROUPS_PER_ORGANIZATION} groups."
205
+ )
206
+ if db.execute(
207
+ "SELECT 1 FROM repository_groups WHERE account_id = ? AND name_key = ? "
208
+ "AND archived_at IS NULL",
209
+ (account_id, _name_key(clean_name)),
210
+ ).fetchone():
211
+ raise ConflictError("A group with this name already exists.")
212
+ db.execute(
213
+ "INSERT INTO repository_groups (group_id, account_id, name, name_key, "
214
+ "description, created_at, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
215
+ (
216
+ group_id,
217
+ account_id,
218
+ clean_name,
219
+ _name_key(clean_name),
220
+ clean_description,
221
+ ts(now),
222
+ principal.login,
223
+ ts(now),
224
+ ),
225
+ )
226
+ stored = self._store.insert_audit_event(
227
+ db,
228
+ self._audit.build(
229
+ AuditEventType.REPOSITORY_GROUP_CREATED,
230
+ actor=actor,
231
+ account_id=account_id,
232
+ group=group_id,
233
+ name=clean_name,
234
+ ),
235
+ )
236
+ self._audit.log_stored(stored)
237
+ return self._view(self._group_row(group_id))
238
+
239
+ def update(
240
+ self, principal: Principal, group_id: str, *, name: object, description: object
241
+ ) -> RepositoryGroupView:
242
+ row = self._group_row(group_id)
243
+ account_id = int(row["account_id"])
244
+ require(principal, Permission.REPOSITORIES_MANAGE, account_id)
245
+ if row["archived_at"] is not None:
246
+ raise ConflictError("An archived group cannot be changed.")
247
+ new_name = text(name, "name", limit=MAX_NAME_CHARS) if name is not None else row["name"]
248
+ new_description = (
249
+ text(description, "description", limit=MAX_DESCRIPTION_CHARS)
250
+ if description is not None
251
+ else row["description"]
252
+ )
253
+ if new_name == row["name"] and new_description == row["description"]:
254
+ raise InputValidationError("Nothing to change.")
255
+ now = self._now()
256
+ with self._store.transaction() as db:
257
+ if (
258
+ new_name != row["name"]
259
+ and db.execute(
260
+ "SELECT 1 FROM repository_groups WHERE account_id = ? AND name_key = ? "
261
+ "AND archived_at IS NULL AND group_id != ?",
262
+ (account_id, _name_key(str(new_name)), group_id),
263
+ ).fetchone()
264
+ ):
265
+ raise ConflictError("A group with this name already exists.")
266
+ db.execute(
267
+ "UPDATE repository_groups SET name = ?, name_key = ?, description = ?, "
268
+ "updated_at = ? WHERE group_id = ?",
269
+ (new_name, _name_key(str(new_name)), new_description, ts(now), group_id),
270
+ )
271
+ stored = self._store.insert_audit_event(
272
+ db,
273
+ self._audit.build(
274
+ AuditEventType.REPOSITORY_GROUP_UPDATED,
275
+ actor=Actor.user(principal.user_id, principal.login),
276
+ account_id=account_id,
277
+ group=group_id,
278
+ name=str(new_name),
279
+ previous_name=str(row["name"]),
280
+ ),
281
+ )
282
+ self._audit.log_stored(stored)
283
+ return self._view(self._group_row(group_id))
284
+
285
+ def archive(self, principal: Principal, group_id: str, *, confirm: object) -> None:
286
+ row = self._group_row(group_id)
287
+ account_id = int(row["account_id"])
288
+ require(principal, Permission.REPOSITORIES_MANAGE, account_id)
289
+ if row["archived_at"] is not None:
290
+ raise ConflictError("The group is already archived.")
291
+ affects_policy = int(row["policy_version"]) > 0 or int(row["active_exceptions"]) > 0
292
+ if affects_policy and confirm is not True:
293
+ raise ConfirmationRequiredError(
294
+ "This group has a policy or active exceptions; archiving it changes the "
295
+ "effective policy of its repositories and must be confirmed."
296
+ )
297
+ now = self._now()
298
+ with self._store.transaction() as db:
299
+ members = group_member_ids(db, account_id, group_id)
300
+ db.execute(
301
+ "UPDATE repository_groups SET archived_at = ?, archived_by = ?, updated_at = ? "
302
+ "WHERE group_id = ? AND archived_at IS NULL",
303
+ (ts(now), principal.login, ts(now), group_id),
304
+ )
305
+ # Exceptions scoped to an archived group end with it (history is kept).
306
+ db.execute(
307
+ "UPDATE policy_exceptions SET status = CASE status WHEN 'requested' THEN "
308
+ "'cancelled' ELSE 'revoked' END, revoked_at = ?, revoked_by_login = ?, "
309
+ "revoke_reason = 'repository group archived', updated_at = ? WHERE account_id = ? "
310
+ "AND scope_type = 'group' AND scope_id = ? AND status IN ('requested', 'active')",
311
+ (ts(now), principal.login, ts(now), account_id, group_id),
312
+ )
313
+ invalidate_repositories(db, account_id, members, now)
314
+ stored = self._store.insert_audit_event(
315
+ db,
316
+ self._audit.build(
317
+ AuditEventType.REPOSITORY_GROUP_ARCHIVED,
318
+ actor=Actor.user(principal.user_id, principal.login),
319
+ account_id=account_id,
320
+ group=group_id,
321
+ name=str(row["name"]),
322
+ repositories=len(members),
323
+ ),
324
+ )
325
+ self._audit.log_stored(stored)
326
+
327
+ def add_members(
328
+ self, principal: Principal, group_id: str, repository_ids: Sequence[int]
329
+ ) -> RepositoryGroupDetail:
330
+ row = self._group_row(group_id)
331
+ account_id = int(row["account_id"])
332
+ require(principal, Permission.REPOSITORIES_MANAGE, account_id)
333
+ ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
334
+ with self._store.transaction() as db:
335
+ _, added = self.add_members_in(
336
+ db, account_id, group_id, ids, actor=Actor.user(principal.user_id, principal.login)
337
+ )
338
+ if added is not None:
339
+ self._audit.log_stored(added)
340
+ return self.get(principal, group_id)
341
+
342
+ def add_members_in(
343
+ self,
344
+ db: sqlite3.Connection,
345
+ account_id: int,
346
+ group_id: str,
347
+ repository_ids: Sequence[int],
348
+ *,
349
+ actor: Actor,
350
+ known: Collection[int] | None = None,
351
+ ) -> tuple[list[int], AuditEvent | None]:
352
+ """Add members inside a caller's transaction (also used by bulk operations).
353
+
354
+ ``known`` - the organization's repository IDs when the caller already has them.
355
+
356
+ Returns the repositories added and the stored audit event (None when every
357
+ repository was already a member).
358
+ """
359
+ group = db.execute(
360
+ "SELECT archived_at FROM repository_groups WHERE group_id = ? AND account_id = ?",
361
+ (group_id, account_id),
362
+ ).fetchone()
363
+ if group is None:
364
+ raise NotFoundError()
365
+ if group["archived_at"] is not None:
366
+ raise ConflictError("An archived group cannot be changed.")
367
+ now = self._now()
368
+ if known is None:
369
+ known = account_repositories(db, account_id).keys()
370
+ added = []
371
+ for repository_id in repository_ids:
372
+ if repository_id not in known:
373
+ raise NotFoundError("A selected repository was not found in this organization.")
374
+ cursor = db.execute(
375
+ "INSERT OR IGNORE INTO repository_group_members (group_id, account_id, "
376
+ "repository_id, added_at, added_by) VALUES (?, ?, ?, ?, ?)",
377
+ (group_id, account_id, repository_id, ts(now), actor.login),
378
+ )
379
+ if cursor.rowcount:
380
+ added.append(repository_id)
381
+ if not added:
382
+ return [], None
383
+ db.execute(
384
+ "UPDATE repository_groups SET updated_at = ? WHERE group_id = ?", (ts(now), group_id)
385
+ )
386
+ invalidate_repositories(db, account_id, added, now)
387
+ return added, self._store.insert_audit_event(
388
+ db,
389
+ self._audit.build(
390
+ AuditEventType.REPOSITORY_GROUP_MEMBERS_ADDED,
391
+ actor=actor,
392
+ account_id=account_id,
393
+ group=group_id,
394
+ repositories=len(added),
395
+ repository_ids=",".join(str(i) for i in added[:50]),
396
+ ),
397
+ )
398
+
399
+ def remove_members(
400
+ self, principal: Principal, group_id: str, repository_ids: Sequence[int]
401
+ ) -> RepositoryGroupDetail:
402
+ row = self._group_row(group_id)
403
+ account_id = int(row["account_id"])
404
+ require(principal, Permission.REPOSITORIES_MANAGE, account_id)
405
+ ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
406
+ with self._store.transaction() as db:
407
+ _, stored = self.remove_members_in(
408
+ db, account_id, group_id, ids, actor=Actor.user(principal.user_id, principal.login)
409
+ )
410
+ if stored is not None:
411
+ self._audit.log_stored(stored)
412
+ return self.get(principal, group_id)
413
+
414
+ def remove_members_in(
415
+ self,
416
+ db: sqlite3.Connection,
417
+ account_id: int,
418
+ group_id: str,
419
+ repository_ids: Sequence[int],
420
+ *,
421
+ actor: Actor,
422
+ known: Collection[int] | None = None, # accepted for symmetry: removal needs no lookup
423
+ ) -> tuple[list[int], AuditEvent | None]:
424
+ del known
425
+ now = self._now()
426
+ removed = []
427
+ for repository_id in repository_ids:
428
+ cursor = db.execute(
429
+ "DELETE FROM repository_group_members WHERE group_id = ? AND account_id = ? "
430
+ "AND repository_id = ?",
431
+ (group_id, account_id, repository_id),
432
+ )
433
+ if cursor.rowcount:
434
+ removed.append(repository_id)
435
+ if not removed:
436
+ return [], None
437
+ invalidate_repositories(db, account_id, removed, now)
438
+ return removed, self._store.insert_audit_event(
439
+ db,
440
+ self._audit.build(
441
+ AuditEventType.REPOSITORY_GROUP_MEMBERS_REMOVED,
442
+ actor=actor,
443
+ account_id=account_id,
444
+ group=group_id,
445
+ repositories=len(removed),
446
+ repository_ids=",".join(str(i) for i in removed[:50]),
447
+ ),
448
+ )