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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Effective policy cache invalidation (targeted, transactional).
|
|
2
|
+
|
|
3
|
+
``repository_effective_policies`` holds one row per repository: the resolved
|
|
4
|
+
governance inputs, their fingerprint and a propagation ``state``:
|
|
5
|
+
|
|
6
|
+
============== ===================================================================
|
|
7
|
+
``up_to_date`` resolved from the current policy versions, group memberships,
|
|
8
|
+
exceptions and mode; valid until ``valid_until`` (the earliest
|
|
9
|
+
exception expiry it includes)
|
|
10
|
+
``stale`` something that affects this repository changed; the next scan or
|
|
11
|
+
the propagation job resolves it again
|
|
12
|
+
``syncing`` the propagation job is resolving it
|
|
13
|
+
``error`` resolving failed; scans resolve directly and fail closed if that
|
|
14
|
+
fails too, and administrators are notified
|
|
15
|
+
============== ===================================================================
|
|
16
|
+
|
|
17
|
+
Every governance change marks exactly the affected rows stale **in the same
|
|
18
|
+
transaction** as the change:
|
|
19
|
+
|
|
20
|
+
================================= ==========================================
|
|
21
|
+
Change Affected repositories
|
|
22
|
+
================================= ==========================================
|
|
23
|
+
organization policy, baseline, every repository of the organization
|
|
24
|
+
organization-wide exception
|
|
25
|
+
group policy, group exception, the group's members
|
|
26
|
+
group archived
|
|
27
|
+
group membership the repositories added or removed
|
|
28
|
+
repository policy, repository that repository
|
|
29
|
+
exception, onboarding mode
|
|
30
|
+
================================= ==========================================
|
|
31
|
+
|
|
32
|
+
A row is therefore never ``up_to_date`` for inputs that no longer apply, and a
|
|
33
|
+
scan that finds its row anything but ``up_to_date`` resolves from the database.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import sqlite3
|
|
38
|
+
from collections.abc import Iterable
|
|
39
|
+
from datetime import datetime
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def invalidate_repositories(
|
|
43
|
+
db: sqlite3.Connection,
|
|
44
|
+
account_id: int,
|
|
45
|
+
repository_ids: Iterable[int] | None,
|
|
46
|
+
now: datetime,
|
|
47
|
+
) -> int:
|
|
48
|
+
"""Mark effective policies stale; ``None`` means every repository of the account."""
|
|
49
|
+
if repository_ids is None:
|
|
50
|
+
return db.execute(
|
|
51
|
+
"UPDATE repository_effective_policies SET state = 'stale', invalidated_at = ? "
|
|
52
|
+
"WHERE account_id = ?",
|
|
53
|
+
(now.timestamp(), int(account_id)),
|
|
54
|
+
).rowcount
|
|
55
|
+
ids = sorted({int(i) for i in repository_ids})
|
|
56
|
+
if not ids:
|
|
57
|
+
return 0
|
|
58
|
+
return db.execute(
|
|
59
|
+
"UPDATE repository_effective_policies SET state = 'stale', invalidated_at = ? "
|
|
60
|
+
"WHERE account_id = ? AND repository_id IN (SELECT value FROM json_each(?))",
|
|
61
|
+
(now.timestamp(), int(account_id), json.dumps(ids)),
|
|
62
|
+
).rowcount
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def group_member_ids(db: sqlite3.Connection, account_id: int, group_id: str) -> list[int]:
|
|
66
|
+
return [
|
|
67
|
+
int(row["repository_id"])
|
|
68
|
+
for row in db.execute(
|
|
69
|
+
"SELECT repository_id FROM repository_group_members WHERE account_id = ? "
|
|
70
|
+
"AND group_id = ?",
|
|
71
|
+
(int(account_id), group_id),
|
|
72
|
+
).fetchall()
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def invalidate_scope(
|
|
77
|
+
db: sqlite3.Connection, account_id: int, scope_type: str, scope_id: str, now: datetime
|
|
78
|
+
) -> int:
|
|
79
|
+
"""Invalidate the repositories a policy target or exception scope covers."""
|
|
80
|
+
if scope_type == "organization":
|
|
81
|
+
return invalidate_repositories(db, account_id, None, now)
|
|
82
|
+
if scope_type == "group":
|
|
83
|
+
return invalidate_repositories(
|
|
84
|
+
db, account_id, group_member_ids(db, account_id, scope_id), now
|
|
85
|
+
)
|
|
86
|
+
if scope_id.isdigit():
|
|
87
|
+
return invalidate_repositories(db, account_id, [int(scope_id)], now)
|
|
88
|
+
return 0
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Shared helpers for the organization governance services.
|
|
2
|
+
|
|
3
|
+
Tenant scoping
|
|
4
|
+
==============
|
|
5
|
+
|
|
6
|
+
Every governance row carries the ``account_id`` of the organization (the GitHub
|
|
7
|
+
account that owns the installation) and, for repository-level rows, GitHub's
|
|
8
|
+
immutable repository ID. Services never accept a repository or group from a
|
|
9
|
+
request without resolving it *within* that account:
|
|
10
|
+
|
|
11
|
+
* :func:`account_repositories` lists the repositories the account's
|
|
12
|
+
installations have seen, one row per repository ID (a repository seen through
|
|
13
|
+
an older installation and again after a reinstallation is one repository);
|
|
14
|
+
* :func:`visible_repository_ids` narrows that to the repositories GitHub
|
|
15
|
+
reported to the signed-in session - the same rule as every other dashboard
|
|
16
|
+
view, so an organization role never reveals a repository its holder could not
|
|
17
|
+
open on GitHub.
|
|
18
|
+
|
|
19
|
+
A resource of another account is answered exactly like a missing one.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import sqlite3
|
|
25
|
+
import uuid
|
|
26
|
+
from collections.abc import Iterable, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
|
|
30
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
31
|
+
from commitguard.controlplane.errors import (
|
|
32
|
+
InputValidationError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
PermissionDeniedError,
|
|
35
|
+
)
|
|
36
|
+
from commitguard.controlplane.results import clean_text
|
|
37
|
+
from commitguard.github.storage import SqliteStateStore
|
|
38
|
+
|
|
39
|
+
MAX_NAME_CHARS = 100
|
|
40
|
+
MAX_DESCRIPTION_CHARS = 500
|
|
41
|
+
MAX_REASON_CHARS = 500
|
|
42
|
+
#: Upper bound on repositories named in one request (bulk operations use jobs).
|
|
43
|
+
MAX_REPOSITORIES_PER_REQUEST = 5000
|
|
44
|
+
_HEX_ID = re.compile(r"\A[0-9a-f]{32}\Z")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def new_id() -> str:
|
|
48
|
+
return uuid.uuid4().hex
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ts(value: datetime) -> float:
|
|
52
|
+
return value.timestamp()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def dt(value: float | None) -> datetime | None:
|
|
56
|
+
return None if value is None else datetime.fromtimestamp(float(value), UTC)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def req_dt(value: float) -> datetime:
|
|
60
|
+
return datetime.fromtimestamp(float(value), UTC)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_hex_id(value: object) -> bool:
|
|
64
|
+
return isinstance(value, str) and bool(_HEX_ID.match(value))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def require(principal: Principal, permission: Permission, account_id: int) -> None:
|
|
68
|
+
"""404 for a non-member (no disclosure), 403 for a member without the permission."""
|
|
69
|
+
if account_id not in principal.memberships:
|
|
70
|
+
raise NotFoundError()
|
|
71
|
+
if not principal.can(permission, account_id):
|
|
72
|
+
raise PermissionDeniedError()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def text(value: object, field: str, *, limit: int, required: bool = False) -> str | None:
|
|
76
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
77
|
+
if required:
|
|
78
|
+
raise InputValidationError(f"{field} is required", field=field)
|
|
79
|
+
return None
|
|
80
|
+
if not isinstance(value, str) or len(value) > limit:
|
|
81
|
+
raise InputValidationError(
|
|
82
|
+
f"{field} must be text of at most {limit} characters", field=field
|
|
83
|
+
)
|
|
84
|
+
return clean_text(value.strip(), limit)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def repository_ids(raw: object, field: str = "repository_ids") -> list[int]:
|
|
88
|
+
if not isinstance(raw, list) or not raw:
|
|
89
|
+
raise InputValidationError(
|
|
90
|
+
f"{field} must be a non-empty list of repository IDs", field=field
|
|
91
|
+
)
|
|
92
|
+
if len(raw) > MAX_REPOSITORIES_PER_REQUEST:
|
|
93
|
+
raise InputValidationError(
|
|
94
|
+
f"{field} may name at most {MAX_REPOSITORIES_PER_REQUEST} repositories", field=field
|
|
95
|
+
)
|
|
96
|
+
ids = []
|
|
97
|
+
for item in raw:
|
|
98
|
+
if not isinstance(item, int) or isinstance(item, bool) or not 1 <= item < 10**16:
|
|
99
|
+
raise InputValidationError(f"{field} must contain repository IDs", field=field)
|
|
100
|
+
ids.append(item)
|
|
101
|
+
return sorted(set(ids))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class AccountRepository:
|
|
106
|
+
repository_id: int
|
|
107
|
+
installation_id: int
|
|
108
|
+
owner: str
|
|
109
|
+
name: str
|
|
110
|
+
default_branch: str | None
|
|
111
|
+
private: bool | None
|
|
112
|
+
archived: bool
|
|
113
|
+
listed: bool
|
|
114
|
+
installation_state: str
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def full_name(self) -> str:
|
|
118
|
+
return f"{self.owner}/{self.name}"
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def connected(self) -> bool:
|
|
122
|
+
return self.installation_state == "active" and self.listed
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_ACCOUNT_REPOSITORIES = (
|
|
126
|
+
"SELECT repository_id, installation_id, owner, name, default_branch, private, archived, "
|
|
127
|
+
"listed, installation_state FROM (SELECT k.repository_id, k.installation_id, k.owner, k.name, "
|
|
128
|
+
"k.default_branch, k.private, k.archived, i.state AS installation_state, EXISTS (SELECT 1 FROM "
|
|
129
|
+
"installation_repositories ir WHERE ir.installation_id = k.installation_id AND "
|
|
130
|
+
"ir.repository_id = k.repository_id) AS listed, ROW_NUMBER() OVER (PARTITION BY "
|
|
131
|
+
"k.repository_id ORDER BY (i.state = 'active') DESC, k.last_seen_at DESC) AS rank "
|
|
132
|
+
"FROM known_repositories k JOIN installations i ON i.installation_id = k.installation_id "
|
|
133
|
+
"WHERE i.account_id = ? AND i.state != 'deleted') WHERE rank = 1"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _repository(row: sqlite3.Row) -> AccountRepository:
|
|
138
|
+
return AccountRepository(
|
|
139
|
+
repository_id=int(row["repository_id"]),
|
|
140
|
+
installation_id=int(row["installation_id"]),
|
|
141
|
+
owner=str(row["owner"]),
|
|
142
|
+
name=str(row["name"]),
|
|
143
|
+
default_branch=row["default_branch"],
|
|
144
|
+
private=None if row["private"] is None else bool(row["private"]),
|
|
145
|
+
archived=bool(row["archived"]),
|
|
146
|
+
listed=bool(row["listed"]),
|
|
147
|
+
installation_state=str(row["installation_state"]),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def account_repositories(
|
|
152
|
+
store_or_db: SqliteStateStore | sqlite3.Connection, account_id: int
|
|
153
|
+
) -> dict[int, AccountRepository]:
|
|
154
|
+
"""Every repository of the account (including disconnected ones), by repository ID."""
|
|
155
|
+
params = (int(account_id),)
|
|
156
|
+
if isinstance(store_or_db, sqlite3.Connection):
|
|
157
|
+
rows = store_or_db.execute(_ACCOUNT_REPOSITORIES, params).fetchall()
|
|
158
|
+
else:
|
|
159
|
+
rows = store_or_db.query(_ACCOUNT_REPOSITORIES, params)
|
|
160
|
+
return {int(row["repository_id"]): _repository(row) for row in rows}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def account_repository(
|
|
164
|
+
store_or_db: SqliteStateStore | sqlite3.Connection, account_id: int, repository_id: int
|
|
165
|
+
) -> AccountRepository | None:
|
|
166
|
+
# _ACCOUNT_REPOSITORIES is a module constant; nothing from the caller enters the SQL.
|
|
167
|
+
sql = f"SELECT * FROM ({_ACCOUNT_REPOSITORIES}) WHERE repository_id = ?" # noqa: S608 # nosec B608
|
|
168
|
+
params = (int(account_id), int(repository_id))
|
|
169
|
+
if isinstance(store_or_db, sqlite3.Connection):
|
|
170
|
+
row = store_or_db.execute(sql, params).fetchone()
|
|
171
|
+
else:
|
|
172
|
+
rows = store_or_db.query(sql, params)
|
|
173
|
+
row = rows[0] if rows else None
|
|
174
|
+
return _repository(row) if row is not None else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def visible_repository_ids(
|
|
178
|
+
store: SqliteStateStore, principal: Principal, account_id: int
|
|
179
|
+
) -> set[int]:
|
|
180
|
+
"""Repository IDs of the account that GitHub reported to this session."""
|
|
181
|
+
installations = sorted(i for i, a in principal.installations.items() if a == account_id)
|
|
182
|
+
if not installations:
|
|
183
|
+
return set()
|
|
184
|
+
rows = store.query(
|
|
185
|
+
"SELECT DISTINCT repository_id FROM session_repositories WHERE session_hash = ? "
|
|
186
|
+
"AND installation_id IN (SELECT value FROM json_each(?))",
|
|
187
|
+
(principal.session_hash, json.dumps(installations)),
|
|
188
|
+
)
|
|
189
|
+
return {int(row["repository_id"]) for row in rows}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def require_visible_repositories(
|
|
193
|
+
store: SqliteStateStore, principal: Principal, account_id: int, ids: Iterable[int]
|
|
194
|
+
) -> list[int]:
|
|
195
|
+
"""All of ``ids`` must be the account's and visible to the caller, else 404."""
|
|
196
|
+
wanted = sorted(set(ids))
|
|
197
|
+
visible = visible_repository_ids(store, principal, account_id)
|
|
198
|
+
known = account_repositories(store, account_id)
|
|
199
|
+
missing = [i for i in wanted if i not in visible or i not in known]
|
|
200
|
+
if missing:
|
|
201
|
+
raise NotFoundError(
|
|
202
|
+
f"{len(missing)} of the selected repositories were not found in this organization."
|
|
203
|
+
)
|
|
204
|
+
return wanted
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def json_list(value: str | None) -> list[object]:
|
|
208
|
+
if not value:
|
|
209
|
+
return []
|
|
210
|
+
parsed = json.loads(value)
|
|
211
|
+
return parsed if isinstance(parsed, list) else []
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def chunks[T](items: Sequence[T], size: int) -> Iterable[Sequence[T]]:
|
|
215
|
+
for start in range(0, len(items), size):
|
|
216
|
+
yield items[start : start + size]
|