monorepoly 0.2.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.
monorepoly/git.py ADDED
@@ -0,0 +1,394 @@
1
+ """Bare clones and history reads through the git CLI."""
2
+
3
+ import base64
4
+ import dataclasses
5
+ import datetime
6
+ import hashlib
7
+ import io
8
+ import logging
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import tempfile
13
+ import urllib.parse
14
+ from collections.abc import Iterable, Iterator, Sequence
15
+ from pathlib import Path
16
+ from types import TracebackType
17
+
18
+ from monorepoly.exceptions import (
19
+ MonorepolyGitError,
20
+ MonorepolyGitTransientError,
21
+ MonorepolyValueError,
22
+ )
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+
26
+ _FIELD = '\x1f'
27
+ _RECORD = '\x1e'
28
+ _LOG_FORMAT = f'{_RECORD}%H{_FIELD}%P{_FIELD}%an{_FIELD}%ae{_FIELD}%aI{_FIELD}%cI{_FIELD}%B{_FIELD}'
29
+ _MERGE_REQUEST_REFS = 'refs/mr'
30
+ # Message lines can start with any byte; only the marker plus a full commit id starts a record.
31
+ _RECORD_START = re.compile(f'{_RECORD}[0-9a-f]{{40,64}}{_FIELD}')
32
+ # Read limits per line and kept text per commit, so one huge or minified file cannot exhaust
33
+ # memory; both are far above what the indexer stores of a diff.
34
+ _MAX_LINE_CHARS = 64 * 1024
35
+ _MAX_PATCH_CHARS = 1_000_000
36
+ # Patch lines `_parse_patch` reads; kept past `_MAX_PATCH_CHARS` so line ranges stay complete.
37
+ _PATCH_STRUCTURE = ('diff --git ', '+++ ', '@@ ')
38
+ _HUNK = re.compile(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@')
39
+ _TIMEOUT_SECONDS = 600
40
+ # Every run clones into an empty container; the big repositories take 3-5 minutes, sometimes 10+.
41
+ _FETCH_TIMEOUT_SECONDS = 1_200
42
+ # Server errors and connection failures as git and curl word them; 4xx (auth, missing) are not.
43
+ _TRANSIENT = re.compile(
44
+ r'(?:HTTP|error:) 5\d\d\b|Could not resolve host|Failed to connect|Connection (?:refused|reset'
45
+ r'|timed out)|early EOF|transfer closed|unexpected disconnect',
46
+ re.IGNORECASE,
47
+ )
48
+ # These can print the Authorization header into stderr, which ends up in error messages.
49
+ _LEAKY_TRACE_VARIABLES = ('GIT_CURL_VERBOSE', 'GIT_TRACE', 'GIT_TRACE_CURL')
50
+
51
+
52
+ @dataclasses.dataclass(frozen=True, kw_only=True)
53
+ class RawCommit:
54
+ author_email: str
55
+ author_name: str
56
+ authored_at: datetime.datetime
57
+ committed_at: datetime.datetime
58
+ diff: str
59
+ line_ranges: dict[str, tuple[tuple[int, int], ...]]
60
+ message: str
61
+ parents: tuple[str, ...]
62
+ sha: str
63
+
64
+
65
+ @dataclasses.dataclass(frozen=True, kw_only=True)
66
+ class LogEntry:
67
+ author_email: str
68
+ author_name: str
69
+ authored_at: datetime.datetime
70
+ subject: str
71
+
72
+
73
+ def fetch(*, cache_dir: Path, token: str, url: str) -> Path:
74
+ """Returns the path of an up-to-date bare clone of `url`."""
75
+ # Fail closed: credentials in the url would be echoed in git errors and stored in the clone.
76
+ if '@' in urllib.parse.urlsplit(url).netloc:
77
+ raise MonorepolyValueError('Git url must not embed credentials!')
78
+ repo = cache_dir / f'{hashlib.sha256(url.encode()).hexdigest()[:16]}.git'
79
+ if not repo.exists():
80
+ cache_dir.mkdir(parents=True, exist_ok=True)
81
+ # The files backend cannot store refs that differ only in case on case-insensitive
82
+ # filesystems (Windows, macOS), which fails the fetch of such a repository.
83
+ _run('init', '--quiet', '--bare', '--ref-format=reftable', str(repo), repo=None)
84
+ elif _run('rev-parse', '--show-ref-format', repo=repo).strip() == 'files':
85
+ _LOGGER.info("Migrating '%s' to reftable refs.", repo)
86
+ _run('refs', 'migrate', '--ref-format=reftable', repo=repo)
87
+ _run(
88
+ 'fetch',
89
+ '--quiet',
90
+ '--prune',
91
+ '--force',
92
+ url,
93
+ '+refs/heads/*:refs/heads/*',
94
+ # GitLab keeps every merge request's head here, even after a squash merge or once the
95
+ # source branch is deleted. Matching nothing is fine.
96
+ f'+refs/merge-requests/*/head:{_MERGE_REQUEST_REFS}/*/head',
97
+ env=_auth_env(token),
98
+ repo=repo,
99
+ timeout=_FETCH_TIMEOUT_SECONDS,
100
+ )
101
+ return repo
102
+
103
+
104
+ def branches(repo: Path) -> dict[str, str]:
105
+ """Returns branch name to head sha."""
106
+ output = _run(
107
+ 'for-each-ref', '--format=%(refname:lstrip=2)%00%(objectname)', 'refs/heads', repo=repo
108
+ )
109
+ return dict(line.split('\0') for line in output.splitlines())
110
+
111
+
112
+ def ancestors(*, head: str, repo: Path) -> set[str]:
113
+ """Returns the shas of every commit reachable from `head`, including it."""
114
+ return set(_run('rev-list', head, repo=repo).split())
115
+
116
+
117
+ def merged_branches(*, head: str, repo: Path) -> set[str]:
118
+ """Returns the branches whose head is reachable from `head`."""
119
+ output = _run(
120
+ 'for-each-ref', '--format=%(refname:lstrip=2)', f'--merged={head}', 'refs/heads', repo=repo
121
+ )
122
+ return set(output.splitlines())
123
+
124
+
125
+ def merge_request_heads(repo: Path) -> dict[str, str]:
126
+ """Returns merge request head ref to sha."""
127
+ output = _run(
128
+ 'for-each-ref', '--format=%(refname)%00%(objectname)', _MERGE_REQUEST_REFS, repo=repo
129
+ )
130
+ return dict(line.split('\0') for line in output.splitlines())
131
+
132
+
133
+ def has_commit(*, repo: Path, sha: str) -> bool:
134
+ try:
135
+ _run('cat-file', '-e', f'{sha}^{{commit}}', repo=repo)
136
+ except MonorepolyGitError:
137
+ return False
138
+ return True
139
+
140
+
141
+ def list_files(*, ref: str, repo: Path) -> list[str]:
142
+ return _run('ls-tree', '-r', '-z', '--name-only', ref, repo=repo).split('\0')[:-1]
143
+
144
+
145
+ def changed_paths(*, base: str, head: str, repo: Path) -> list[str]:
146
+ output = _run('diff', '--name-only', '-z', '--no-renames', base, head, repo=repo)
147
+ return output.split('\0')[:-1]
148
+
149
+
150
+ def merge_base(*, a: str, b: str, repo: Path) -> str | None:
151
+ """Returns the best common ancestor, or None for unrelated histories."""
152
+ try:
153
+ return _run('merge-base', a, b, repo=repo).strip()
154
+ except MonorepolyGitError:
155
+ return None
156
+
157
+
158
+ def log_entries(*, limit: int, repo: Path, revs: Sequence[str]) -> list[LogEntry]:
159
+ """Returns up to `limit` commits reachable from `revs`, newest first."""
160
+ output = _run(
161
+ 'log',
162
+ f'--max-count={limit}',
163
+ f'--format=%an{_FIELD}%ae{_FIELD}%aI{_FIELD}%s',
164
+ *revs,
165
+ '--',
166
+ repo=repo,
167
+ )
168
+ entries = []
169
+ for line in output.splitlines():
170
+ name, email, at, subject = line.split(_FIELD, 3)
171
+ entries.append(
172
+ LogEntry(
173
+ author_email=email,
174
+ author_name=name,
175
+ authored_at=datetime.datetime.fromisoformat(at),
176
+ subject=subject,
177
+ )
178
+ )
179
+ return entries
180
+
181
+
182
+ def iter_commits(
183
+ *, exclude: Iterable[str], include: Iterable[str], repo: Path
184
+ ) -> Iterator[RawCommit]:
185
+ """Yields commits reachable from `include` but not from `exclude`, with their patches."""
186
+ revs = [*include, *(f'^{sha}' for sha in exclude)]
187
+ if not revs:
188
+ return
189
+ command = [
190
+ 'git',
191
+ '-c',
192
+ 'core.quotePath=false',
193
+ 'log',
194
+ '--patch',
195
+ '--unified=0',
196
+ '--no-color',
197
+ '--no-renames',
198
+ '--no-ext-diff',
199
+ # Git for Windows' system attributes textconv *.pdf/*.rtf via temp files named after the
200
+ # file, which fails on names invalid on Windows ("unable to create temp-file").
201
+ '--no-textconv',
202
+ f'--format={_LOG_FORMAT}',
203
+ # Revisions go through stdin: thousands of merge request heads overflow Windows'
204
+ # 32767-character command line (WinError 206).
205
+ '--stdin',
206
+ ]
207
+ with tempfile.TemporaryFile() as stderr:
208
+ process = subprocess.Popen(
209
+ command, cwd=repo, stderr=stderr, stdin=subprocess.PIPE, stdout=subprocess.PIPE
210
+ )
211
+ try:
212
+ # git reads all of stdin before it writes anything, so this cannot deadlock.
213
+ process.stdin.write(''.join(f'{rev}\n' for rev in revs).encode())
214
+ process.stdin.close()
215
+ # newline='\n' keeps CRLF intact and, unlike newline='', does not split on a lone
216
+ # '\r' inside file content.
217
+ lines = io.TextIOWrapper(
218
+ process.stdout, encoding='utf-8', errors='replace', newline='\n'
219
+ )
220
+ record: _Record | None = None
221
+ line_start = True
222
+ # Pieces of at most `_MAX_LINE_CHARS`: a minified file is one enormous line.
223
+ while piece := lines.readline(_MAX_LINE_CHARS):
224
+ if line_start and _RECORD_START.match(piece):
225
+ if record:
226
+ yield record.parse()
227
+ record = _Record(piece[1:])
228
+ elif record:
229
+ record.add(line_start=line_start, piece=piece)
230
+ line_start = piece.endswith('\n')
231
+ if record:
232
+ yield record.parse()
233
+ except BaseException:
234
+ # The consumer abandoned the generator (GeneratorExit) or parsing raised.
235
+ process.kill()
236
+ raise
237
+ finally:
238
+ process.stdout.close()
239
+ returncode = process.wait(timeout=_TIMEOUT_SECONDS)
240
+ if returncode:
241
+ stderr.seek(0)
242
+ message = stderr.read().decode(errors='replace').strip()
243
+ raise MonorepolyGitError(f"`git log` failed in '{repo}': {message}")
244
+
245
+
246
+ class BlobReader:
247
+ """Reads files at revisions through one long-running `git cat-file --batch`."""
248
+
249
+ def __init__(self, repo: Path) -> None:
250
+ self._process = subprocess.Popen(
251
+ ['git', 'cat-file', '--batch'],
252
+ cwd=repo,
253
+ stderr=subprocess.DEVNULL,
254
+ stdin=subprocess.PIPE,
255
+ stdout=subprocess.PIPE,
256
+ )
257
+
258
+ def __enter__(self) -> BlobReader:
259
+ return self
260
+
261
+ def __exit__(
262
+ self,
263
+ exc_type: type[BaseException] | None,
264
+ exc: BaseException | None,
265
+ tb: TracebackType | None,
266
+ ) -> None:
267
+ self._process.stdin.close()
268
+ self._process.stdout.close()
269
+ self._process.wait(timeout=_TIMEOUT_SECONDS)
270
+
271
+ def read(self, *, path: str, rev: str) -> str | None:
272
+ """Returns `rev:path` decoded as UTF-8, or None if it is not a file there."""
273
+ self._process.stdin.write(f'{rev}:{path}\n'.encode())
274
+ self._process.stdin.flush()
275
+ header = self._process.stdout.readline().decode().split()
276
+ if header[-1:] == ['missing']:
277
+ return None
278
+ if len(header) != 3:
279
+ raise MonorepolyValueError(f"Unexpected cat-file header {header!r} for '{rev}:{path}'!")
280
+ _, kind, size = header
281
+ data = self._process.stdout.read(int(size) + 1)[:-1]
282
+ return data.decode('utf-8', errors='replace') if kind == 'blob' else None
283
+
284
+
285
+ class _Record:
286
+ """One `git log` record: its text up to `_MAX_PATCH_CHARS`, then only patch structure."""
287
+
288
+ def __init__(self, first: str) -> None:
289
+ self._cut = False
290
+ self._kept = [first]
291
+ self._size = len(first)
292
+ self._structure: list[str] = []
293
+
294
+ def add(self, *, line_start: bool, piece: str) -> None:
295
+ if not self._cut and self._size + len(piece) <= _MAX_PATCH_CHARS:
296
+ self._kept.append(piece)
297
+ self._size += len(piece)
298
+ return
299
+ # Once cut, nothing more is kept, even a later piece that would still fit.
300
+ self._cut = True
301
+ if line_start and piece.startswith(_PATCH_STRUCTURE):
302
+ self._structure.append(piece.removesuffix('\n'))
303
+
304
+ def parse(self) -> RawCommit:
305
+ return _parse_record(structure=self._structure, text=''.join(self._kept))
306
+
307
+
308
+ def _parse_record(*, structure: Sequence[str] = (), text: str) -> RawCommit:
309
+ sha, parents, name, email, authored_at, committed_at, body, patch = text.split(_FIELD, 7)
310
+ return RawCommit(
311
+ author_email=email,
312
+ author_name=name,
313
+ authored_at=datetime.datetime.fromisoformat(authored_at),
314
+ committed_at=datetime.datetime.fromisoformat(committed_at),
315
+ diff=patch.lstrip('\n'),
316
+ # The kept text may end mid-line, so the structure starts on a line of its own.
317
+ line_ranges=_parse_patch('\n'.join([patch, *structure])),
318
+ message=body.rstrip('\n'),
319
+ parents=tuple(parents.split()),
320
+ sha=sha,
321
+ )
322
+
323
+
324
+ def _parse_patch(patch: str) -> dict[str, tuple[tuple[int, int], ...]]:
325
+ """Returns new-side changed line ranges per path; deleted and hunkless files get none."""
326
+ ranges: dict[str, list[tuple[int, int]]] = {}
327
+ in_header = False
328
+ path = None
329
+ # Not splitlines(): it would also split on form feeds and the like inside content.
330
+ for line in patch.split('\n'):
331
+ if line.startswith('diff --git '):
332
+ # With --no-renames both paths are equal, and unlike `---`/`+++` this line has no
333
+ # trailing TAB after paths containing spaces.
334
+ rest = line.removeprefix('diff --git ')
335
+ path = rest[2 : 2 + (len(rest) - 5) // 2]
336
+ ranges[path] = []
337
+ in_header = True
338
+ elif in_header and line.startswith('+++ /dev/null'):
339
+ path = None
340
+ elif match := _HUNK.match(line):
341
+ in_header = False
342
+ if path is None:
343
+ continue
344
+ start = int(match[1])
345
+ count = 1 if match[2] is None else int(match[2])
346
+ # A pure deletion has count 0 and points at the line before it.
347
+ ranges[path].append((start, start + count - 1) if count else (max(start, 1),) * 2)
348
+ return {name: tuple(spans) for name, spans in ranges.items()}
349
+
350
+
351
+ def _auth_env(token: str) -> dict[str, str]:
352
+ # The header travels in the environment, never in argv, so errors cannot leak it.
353
+ credentials = base64.b64encode(f'oauth2:{token}'.encode()).decode()
354
+ return {
355
+ 'GIT_CONFIG_COUNT': '2',
356
+ 'GIT_CONFIG_KEY_0': 'http.extraHeader',
357
+ 'GIT_CONFIG_VALUE_0': f'Authorization: Basic {credentials}',
358
+ # An empty value clears the helper list: on a 401, Git Credential Manager would
359
+ # otherwise open a login window.
360
+ 'GIT_CONFIG_KEY_1': 'credential.helper',
361
+ 'GIT_CONFIG_VALUE_1': '',
362
+ 'GCM_INTERACTIVE': 'never',
363
+ 'GIT_TERMINAL_PROMPT': '0',
364
+ }
365
+
366
+
367
+ def _run(
368
+ *args: str,
369
+ env: dict[str, str] | None = None,
370
+ repo: Path | None,
371
+ timeout: float = _TIMEOUT_SECONDS,
372
+ ) -> str:
373
+ environment = os.environ | (env or {})
374
+ if env:
375
+ for name in _LEAKY_TRACE_VARIABLES:
376
+ environment.pop(name, None)
377
+ try:
378
+ result = subprocess.run(
379
+ ['git', *args],
380
+ capture_output=True,
381
+ check=True,
382
+ cwd=repo,
383
+ encoding='utf-8',
384
+ env=environment,
385
+ errors='replace',
386
+ timeout=timeout,
387
+ )
388
+ except subprocess.CalledProcessError as err:
389
+ stderr = err.stderr.strip()
390
+ error = MonorepolyGitTransientError if _TRANSIENT.search(stderr) else MonorepolyGitError
391
+ raise error(f"`git {args[0]}` failed in '{repo}': {stderr}") from err
392
+ except subprocess.TimeoutExpired as err:
393
+ raise MonorepolyGitError(f"`git {args[0]}` timed out in '{repo}'!") from err
394
+ return result.stdout
monorepoly/gitlab.py ADDED
@@ -0,0 +1,234 @@
1
+ """Read-only GitLab API access, translated into models."""
2
+
3
+ import dataclasses
4
+ import datetime
5
+ import logging
6
+ from collections.abc import Iterator, Mapping
7
+
8
+ import gitlab
9
+ from gitlab.exceptions import GitlabAuthenticationError, GitlabGetError
10
+
11
+ from monorepoly.exceptions import MonorepolyAccessError, MonorepolyValueError
12
+ from monorepoly.models import ActivityKind, Discussion, Issue, MergeRequest, Project, State
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+ _PER_PAGE = 100
17
+ _TIMEOUT_SECONDS = 30
18
+
19
+
20
+ @dataclasses.dataclass(frozen=True, kw_only=True)
21
+ class User:
22
+ emails: frozenset[str]
23
+ id: int
24
+
25
+
26
+ class GitLab:
27
+ def __init__(self, *, client: gitlab.Gitlab) -> None:
28
+ self._client = client
29
+
30
+ def current_user(self) -> User:
31
+ """Returns the token's user, raising if GitLab rejects the token."""
32
+ try:
33
+ self._client.auth()
34
+ except GitlabAuthenticationError as err:
35
+ raise MonorepolyAccessError(
36
+ f"GitLab at '{self._client.url}' rejected the token!"
37
+ ) from err
38
+ attributes = self._client.user.attributes
39
+ emails = {attributes.get(name) for name in ('email', 'commit_email', 'public_email')}
40
+ return User(emails=frozenset(email for email in emails if email), id=attributes['id'])
41
+
42
+ def group_path(self, group_id: int | str) -> str:
43
+ """Returns the group's full path, raising if the token cannot see the group."""
44
+ try:
45
+ group = self._client.groups.get(group_id)
46
+ except GitlabGetError as err:
47
+ raise MonorepolyAccessError(
48
+ f"GitLab group '{group_id}' is not visible to the token!"
49
+ ) from err
50
+ return group.attributes['full_path']
51
+
52
+ def group_projects(
53
+ self,
54
+ *,
55
+ group_id: int | str,
56
+ include_archived: bool = True,
57
+ min_access_level: int | None = None,
58
+ ) -> list[Project]:
59
+ """Returns the group's visible projects, archived too unless `include_archived` is False."""
60
+ group = self._client.groups.get(group_id, lazy=True)
61
+ filters: dict[str, object] = {}
62
+ if not include_archived:
63
+ filters['archived'] = False
64
+ if min_access_level is not None:
65
+ filters['min_access_level'] = min_access_level
66
+ # Projects shared into the group from elsewhere are not part of this group's index.
67
+ listing = group.projects.list(
68
+ include_subgroups=True,
69
+ iterator=True,
70
+ per_page=_PER_PAGE,
71
+ with_shared=False,
72
+ **filters,
73
+ )
74
+ return [_project(item.attributes) for item in listing]
75
+
76
+ def project(self, path: str) -> Project:
77
+ return _project(self._client.projects.get(path).attributes)
78
+
79
+ def iter_issues(
80
+ self, *, project_id: int, updated_after: datetime.datetime | None
81
+ ) -> Iterator[Issue]:
82
+ manager = self._client.projects.get(project_id, lazy=True).issues
83
+ for item in manager.list(**_list_filters(updated_after)):
84
+ yield _issue(attributes=item.attributes, project_id=project_id)
85
+
86
+ def iter_merge_requests(
87
+ self,
88
+ *,
89
+ project_id: int,
90
+ updated_after: datetime.datetime | None,
91
+ ) -> Iterator[MergeRequest]:
92
+ manager = self._client.projects.get(project_id, lazy=True).mergerequests
93
+ for item in manager.list(**_list_filters(updated_after)):
94
+ yield _merge_request(attributes=item.attributes, project_id=project_id)
95
+
96
+ def iter_discussions(self, parent: Issue | MergeRequest) -> Iterator[Discussion]:
97
+ """Yields the parent's threads that contain human notes."""
98
+ project = self._client.projects.get(parent.project_id, lazy=True)
99
+ match parent:
100
+ case Issue():
101
+ kind, manager = ActivityKind.ISSUE, project.issues
102
+ case MergeRequest():
103
+ kind, manager = ActivityKind.MERGE_REQUEST, project.mergerequests
104
+ case _:
105
+ raise MonorepolyValueError(f'Unexpected discussion parent {parent!r}!')
106
+ listing = manager.get(parent.iid, lazy=True).discussions.list(
107
+ iterator=True, per_page=_PER_PAGE
108
+ )
109
+ for item in listing:
110
+ notes = [note for note in item.attributes['notes'] if not note.get('system')]
111
+ if not notes:
112
+ continue
113
+ # Internal notes are visible to Reporter and above only, like confidential issues.
114
+ internal = any(note.get('internal') or note.get('confidential') for note in notes)
115
+ yield Discussion(
116
+ authors=tuple(dict.fromkeys(note['author']['username'] for note in notes)),
117
+ confidential=getattr(parent, 'confidential', False) or internal,
118
+ discussion_id=item.attributes['id'],
119
+ notes=tuple(
120
+ f'{note["author"]["username"]}: {note.get("body") or ""}' for note in notes
121
+ ),
122
+ parent_iid=parent.iid,
123
+ parent_kind=kind,
124
+ parent_title=parent.title,
125
+ project_id=parent.project_id,
126
+ state=parent.state,
127
+ updated_at=_datetime(notes[-1].get('updated_at')),
128
+ web_url=f'{parent.web_url}#note_{notes[0]["id"]}',
129
+ )
130
+
131
+ def merge_request_commit_shas(self, *, iid: int, project_id: int) -> list[str]:
132
+ merge_request = self._client.projects.get(project_id, lazy=True).mergerequests.get(
133
+ iid, lazy=True
134
+ )
135
+ return [commit.id for commit in merge_request.commits()]
136
+
137
+
138
+ def make_gitlab(*, token: str, url: str) -> GitLab:
139
+ return GitLab(client=_client(private_token=token, url=url))
140
+
141
+
142
+ def make_oauth_gitlab(*, token: str, url: str) -> GitLab:
143
+ """Returns the API for a user's OAuth access token."""
144
+ return GitLab(client=_client(oauth_token=token, url=url))
145
+
146
+
147
+ def _client(*, url: str, **auth: str) -> gitlab.Gitlab:
148
+ return gitlab.Gitlab(url, retry_transient_errors=True, timeout=_TIMEOUT_SECONDS, **auth)
149
+
150
+
151
+ def _list_filters(updated_after: datetime.datetime | None) -> dict[str, object]:
152
+ filters = {
153
+ 'iterator': True,
154
+ 'order_by': 'updated_at',
155
+ 'per_page': _PER_PAGE,
156
+ 'scope': 'all',
157
+ 'sort': 'asc',
158
+ }
159
+ if updated_after:
160
+ filters['updated_after'] = updated_after.isoformat()
161
+ return filters
162
+
163
+
164
+ def _project(attributes: Mapping) -> Project:
165
+ # Fail closed: a missing visibility or feature level counts as members-only.
166
+ return Project(
167
+ default_branch=attributes.get('default_branch'),
168
+ empty_repo=bool(attributes.get('empty_repo')),
169
+ http_url_to_repo=attributes['http_url_to_repo'],
170
+ id=attributes['id'],
171
+ issues_access_level=attributes.get('issues_access_level') or 'private',
172
+ merge_requests_access_level=attributes.get('merge_requests_access_level') or 'private',
173
+ path_with_namespace=attributes['path_with_namespace'],
174
+ repository_access_level=attributes.get('repository_access_level') or 'private',
175
+ visibility=attributes.get('visibility') or 'private',
176
+ web_url=attributes['web_url'],
177
+ )
178
+
179
+
180
+ def _issue(*, attributes: Mapping, project_id: int) -> Issue:
181
+ return Issue(
182
+ assignee_ids=_ids(attributes.get('assignees')),
183
+ author_id=(attributes.get('author') or {}).get('id'),
184
+ closed_at=_datetime(attributes.get('closed_at')),
185
+ confidential=bool(attributes.get('confidential')),
186
+ created_at=_datetime(attributes.get('created_at')),
187
+ description=attributes.get('description') or '',
188
+ iid=attributes['iid'],
189
+ labels=tuple(attributes.get('labels') or ()),
190
+ project_id=project_id,
191
+ state=_state(attributes['state']),
192
+ title=attributes['title'],
193
+ updated_at=_datetime(attributes.get('updated_at')),
194
+ web_url=attributes['web_url'],
195
+ )
196
+
197
+
198
+ def _merge_request(*, attributes: Mapping, project_id: int) -> MergeRequest:
199
+ return MergeRequest(
200
+ assignee_ids=_ids(attributes.get('assignees')),
201
+ author_id=(attributes.get('author') or {}).get('id'),
202
+ created_at=_datetime(attributes.get('created_at')),
203
+ description=attributes.get('description') or '',
204
+ iid=attributes['iid'],
205
+ merged_at=_datetime(attributes.get('merged_at')),
206
+ project_id=project_id,
207
+ reviewer_ids=_ids(attributes.get('reviewers')),
208
+ source_branch=attributes['source_branch'],
209
+ state=_state(attributes['state']),
210
+ symbol_keys=(),
211
+ target_branch=attributes['target_branch'],
212
+ title=attributes['title'],
213
+ updated_at=_datetime(attributes.get('updated_at')),
214
+ web_url=attributes['web_url'],
215
+ )
216
+
217
+
218
+ def _ids(users: list[Mapping] | None) -> tuple[int, ...]:
219
+ return tuple(user['id'] for user in users or ())
220
+
221
+
222
+ def _state(value: str) -> State:
223
+ try:
224
+ return State(value)
225
+ except ValueError as err:
226
+ raise MonorepolyValueError(f"Unknown GitLab state '{value}'!") from err
227
+
228
+
229
+ def _datetime(value: object) -> datetime.datetime | None:
230
+ """Returns the parsed timestamp, or None for anything unparsable."""
231
+ try:
232
+ return datetime.datetime.fromisoformat(value)
233
+ except TypeError, ValueError:
234
+ return None