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/__init__.py +1 -0
- monorepoly/__main__.py +161 -0
- monorepoly/access.py +100 -0
- monorepoly/chunk.py +136 -0
- monorepoly/chunk_config.py +466 -0
- monorepoly/config.py +54 -0
- monorepoly/embed.py +66 -0
- monorepoly/exceptions.py +29 -0
- monorepoly/git.py +394 -0
- monorepoly/gitlab.py +234 -0
- monorepoly/hosted.py +155 -0
- monorepoly/indexer.py +557 -0
- monorepoly/judge.py +157 -0
- monorepoly/licensing.py +201 -0
- monorepoly/mcp_server.py +165 -0
- monorepoly/models.py +329 -0
- monorepoly/search.py +284 -0
- monorepoly/skill.py +23 -0
- monorepoly/skills/search/SKILL.md +28 -0
- monorepoly/store.py +290 -0
- monorepoly/trusted_keys/kimvais.pub +1 -0
- monorepoly/wizard.py +251 -0
- monorepoly-0.2.0.dist-info/METADATA +230 -0
- monorepoly-0.2.0.dist-info/RECORD +27 -0
- monorepoly-0.2.0.dist-info/WHEEL +4 -0
- monorepoly-0.2.0.dist-info/entry_points.txt +3 -0
- monorepoly-0.2.0.dist-info/licenses/LICENSE +22 -0
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
|