ground-control-cli 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.
- client/__init__.py +1 -0
- client/claude.py +240 -0
- client/cli.py +314 -0
- client/connect.py +128 -0
- client/machine.py +254 -0
- client/probes/__init__.py +57 -0
- client/probes/code/__init__.py +435 -0
- client/probes/code/bash.py +163 -0
- client/probes/code/javascript.js +171 -0
- client/probes/code/javascript.py +193 -0
- client/probes/code/python.py +143 -0
- client/probes/errors.py +67 -0
- client/probes/gh.py +126 -0
- client/probes/git.py +280 -0
- client/probes/health.py +128 -0
- client/probes/settings.py +72 -0
- client/probes/tests.py +532 -0
- client/repos.py +68 -0
- client/service.py +398 -0
- client/system/__init__.py +73 -0
- client/system/macos.py +92 -0
- client/update.py +110 -0
- ground_control_cli-0.2.0.dist-info/METADATA +5 -0
- ground_control_cli-0.2.0.dist-info/RECORD +26 -0
- ground_control_cli-0.2.0.dist-info/WHEEL +4 -0
- ground_control_cli-0.2.0.dist-info/entry_points.txt +3 -0
client/probes/tests.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""The tests probes: the product's tests, listed, run, measured and fingerprinted.
|
|
2
|
+
|
|
3
|
+
`test_tools` in the repo's .claude/method.json names, per test type, the commands that run its
|
|
4
|
+
tests: `run` (with `{tests}` and `{junit}`, writing a JUnit XML report), `collect` (printing one
|
|
5
|
+
test id per line) and `coverage` (with `{tests}` and `{lcov}`, writing an lcov report). The
|
|
6
|
+
plain-string form is read as `run` alone. Commands run through the shell in the folder they are
|
|
7
|
+
given, with `{tests}` the shell-quoted test ids. The service names only the type; the commands
|
|
8
|
+
are the repo's own.
|
|
9
|
+
|
|
10
|
+
A test's file is the longest start of its id that is a file in the repo (`tests/test_a.py` in
|
|
11
|
+
`tests/test_a.py::test_x`); the rest of the id names the test function. A test's fingerprint is
|
|
12
|
+
the hash of the reader's span of that function, or of the whole file when the reader cannot
|
|
13
|
+
name it.
|
|
14
|
+
|
|
15
|
+
What a command prints is read here and never returned: a run says each test's state, and which
|
|
16
|
+
of the names it was asked to look for its report names.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shlex
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import tempfile
|
|
30
|
+
import xml.etree.ElementTree as ET
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import NamedTuple
|
|
33
|
+
from urllib.parse import urlsplit
|
|
34
|
+
|
|
35
|
+
from . import code, errors
|
|
36
|
+
|
|
37
|
+
KEYS = ("run", "collect", "coverage")
|
|
38
|
+
CONFIG = ".claude/method.json"
|
|
39
|
+
BASE = "main"
|
|
40
|
+
ID_PARTS_RE = re.compile(r"::| > |#")
|
|
41
|
+
PARAMS_RE = re.compile(r"\[.*\]$")
|
|
42
|
+
SOURCE_END_RE = re.compile(r"[A-Za-z0-9_.-]")
|
|
43
|
+
# The variable a test run against the environment gets: the scheme, host and port of its health.
|
|
44
|
+
ENV_URL = "METHOD_ENV_URL"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Failed(Exception):
|
|
48
|
+
"""A probe could not run; `error` is which of errors.ERRORS stopped it."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, error: str):
|
|
51
|
+
"""Keep the error."""
|
|
52
|
+
super().__init__(error)
|
|
53
|
+
self.error = error
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Outcome(NamedTuple):
|
|
57
|
+
"""One test's result, read from its JUnit report.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
state: pass, failure, error or skipped.
|
|
61
|
+
text: What the report says about a failure, an error or a skip; never returned.
|
|
62
|
+
|
|
63
|
+
Company: assumes none.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
state: str
|
|
67
|
+
text: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _config(root) -> dict:
|
|
71
|
+
"""The repo's .claude/method.json; Failed with unreadable when it cannot be read."""
|
|
72
|
+
path = Path(root) / CONFIG
|
|
73
|
+
if not path.is_file():
|
|
74
|
+
return {}
|
|
75
|
+
try:
|
|
76
|
+
found = json.loads(path.read_text(encoding="utf-8"))
|
|
77
|
+
except (OSError, json.JSONDecodeError):
|
|
78
|
+
raise Failed(errors.UNREADABLE) from None
|
|
79
|
+
return found if isinstance(found, dict) else {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def tools(root) -> dict:
|
|
83
|
+
"""Read the test tools of each type from the repo's .claude/method.json.
|
|
84
|
+
|
|
85
|
+
A plain string is `run` alone; a type whose entry is neither is left out.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
root: The repo.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Type -> {run, collect, coverage}, in the file's order.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
Failed: With unreadable, if the file or its "test_tools" cannot be read.
|
|
95
|
+
|
|
96
|
+
Company: assumes none.
|
|
97
|
+
"""
|
|
98
|
+
found = _config(root).get("test_tools", {})
|
|
99
|
+
if not isinstance(found, dict):
|
|
100
|
+
raise Failed(errors.UNREADABLE)
|
|
101
|
+
read = {}
|
|
102
|
+
for kind, entry in found.items():
|
|
103
|
+
if isinstance(entry, str):
|
|
104
|
+
read[kind] = {"run": entry}
|
|
105
|
+
elif isinstance(entry, dict) and all(isinstance(entry.get(k, ""), str) for k in KEYS):
|
|
106
|
+
read[kind] = {k: entry[k] for k in KEYS if k in entry}
|
|
107
|
+
return read
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def command(root, kind: str, key: str) -> str:
|
|
111
|
+
"""The `key` command of type `kind`.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
root: The repo.
|
|
115
|
+
kind: The test type.
|
|
116
|
+
key: run, collect or coverage.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
The command.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
Failed: With no_command, if the type has no entry or no such command; with unreadable,
|
|
123
|
+
if the tools cannot be read.
|
|
124
|
+
|
|
125
|
+
Company: assumes none.
|
|
126
|
+
"""
|
|
127
|
+
value = tools(root).get(kind, {}).get(key, "")
|
|
128
|
+
if not value.split():
|
|
129
|
+
raise Failed(errors.NO_COMMAND)
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _program(cmd: str) -> str:
|
|
134
|
+
"""The command's first word."""
|
|
135
|
+
try:
|
|
136
|
+
return (shlex.split(cmd) or [""])[0]
|
|
137
|
+
except ValueError:
|
|
138
|
+
return cmd.split()[0]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _shell(cmd: str, cwd: Path, env=None):
|
|
142
|
+
"""Run the command through the shell in `cwd`, capturing its output as text."""
|
|
143
|
+
return subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, env=env)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fill(cmd: str, **values) -> str:
|
|
147
|
+
"""The command with each `{key}` replaced by its value."""
|
|
148
|
+
for key, value in values.items():
|
|
149
|
+
cmd = cmd.replace("{" + key + "}", value)
|
|
150
|
+
return cmd
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def collect(cmd: str, cwd: Path) -> tuple:
|
|
154
|
+
"""Run the collect command and read the test ids it prints, one per line.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
cmd: The collect command.
|
|
158
|
+
cwd: The folder it runs in.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
(the test ids, whether the command exited with 0).
|
|
162
|
+
|
|
163
|
+
Company: assumes none.
|
|
164
|
+
"""
|
|
165
|
+
out = _shell(cmd, cwd)
|
|
166
|
+
return [line.strip() for line in out.stdout.splitlines() if line.strip()], out.returncode == 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _outcome(report: Path) -> Outcome:
|
|
170
|
+
"""The Outcome the JUnit report says; an error when it holds no test case."""
|
|
171
|
+
try:
|
|
172
|
+
cases = list(ET.parse(report).getroot().iter("testcase"))
|
|
173
|
+
except (OSError, ET.ParseError):
|
|
174
|
+
cases = []
|
|
175
|
+
if not cases:
|
|
176
|
+
return Outcome("error", "")
|
|
177
|
+
for state in ("error", "failure", "skipped"):
|
|
178
|
+
found = [e for case in cases for e in case.findall(state)]
|
|
179
|
+
if found:
|
|
180
|
+
return Outcome(
|
|
181
|
+
state, "\n".join(f"{e.get('message') or ''}\n{e.text or ''}" for e in found)
|
|
182
|
+
)
|
|
183
|
+
return Outcome("pass", "")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_one(cmd: str, cwd: Path, test_id: str, env=None) -> Outcome:
|
|
187
|
+
"""Run one test alone and read its outcome from its JUnit report.
|
|
188
|
+
|
|
189
|
+
The report is written to a temporary folder, removed afterwards.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
cmd: The run command, with `{tests}` and `{junit}`.
|
|
193
|
+
cwd: The folder it runs in.
|
|
194
|
+
test_id: The test's id.
|
|
195
|
+
env: The run's environment; the current one when not given.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The test's Outcome.
|
|
199
|
+
|
|
200
|
+
Company: assumes none.
|
|
201
|
+
"""
|
|
202
|
+
with tempfile.TemporaryDirectory(prefix="method-junit-") as tmp:
|
|
203
|
+
report = Path(tmp) / "junit.xml"
|
|
204
|
+
_shell(_fill(cmd, tests=shlex.quote(test_id), junit=shlex.quote(str(report))), cwd, env)
|
|
205
|
+
return _outcome(report)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def coverage(cmd: str, cwd: Path, ids: list) -> tuple:
|
|
209
|
+
"""Run the coverage command for these tests and read its lcov report.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
cmd: The coverage command, with `{tests}` and `{lcov}`.
|
|
213
|
+
cwd: The folder it runs in.
|
|
214
|
+
ids: The test ids.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
(file relative to cwd -> {line: hits}, whether the report was written, whether the
|
|
218
|
+
command exited with 0).
|
|
219
|
+
|
|
220
|
+
Company: assumes none.
|
|
221
|
+
"""
|
|
222
|
+
with tempfile.TemporaryDirectory(prefix="method-lcov-") as tmp:
|
|
223
|
+
report = Path(tmp) / "coverage.lcov"
|
|
224
|
+
tests = " ".join(shlex.quote(i) for i in ids)
|
|
225
|
+
out = _shell(_fill(cmd, tests=tests, lcov=shlex.quote(str(report))), cwd)
|
|
226
|
+
if not report.is_file():
|
|
227
|
+
return {}, False, out.returncode == 0
|
|
228
|
+
text = report.read_text(encoding="utf-8", errors="replace")
|
|
229
|
+
return read_lcov(text, cwd), True, out.returncode == 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def read_lcov(text: str, root: Path) -> dict:
|
|
233
|
+
"""Read an lcov report's line hits.
|
|
234
|
+
|
|
235
|
+
An absolute source path under `root` is made relative to it. Hits for the same line are
|
|
236
|
+
added up.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
text: The lcov report.
|
|
240
|
+
root: The folder source paths are made relative to.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
File -> {line number: hits}.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
ValueError: If a `DA:` line does not hold numbers.
|
|
247
|
+
|
|
248
|
+
Company: assumes none.
|
|
249
|
+
"""
|
|
250
|
+
found, current = {}, None
|
|
251
|
+
root = Path(root).resolve()
|
|
252
|
+
for line in text.splitlines():
|
|
253
|
+
if line.startswith("SF:"):
|
|
254
|
+
source = Path(line[3:].strip())
|
|
255
|
+
if source.is_absolute():
|
|
256
|
+
with contextlib.suppress(ValueError):
|
|
257
|
+
source = source.resolve().relative_to(root)
|
|
258
|
+
current = found.setdefault(source.as_posix(), {})
|
|
259
|
+
elif line.startswith("DA:") and current is not None:
|
|
260
|
+
number, hits = line[3:].split(",")[:2]
|
|
261
|
+
current[int(number)] = current.get(int(number), 0) + int(float(hits))
|
|
262
|
+
elif line.startswith("end_of_record"):
|
|
263
|
+
current = None
|
|
264
|
+
return found
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def file_of(root: Path, test_id: str) -> str:
|
|
268
|
+
"""Find the test's file: the longest start of the id that is a file in the repo.
|
|
269
|
+
|
|
270
|
+
A start only counts where the next character cannot continue a path.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
root: The repo.
|
|
274
|
+
test_id: The test's id.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The file's repo-relative path, or "".
|
|
278
|
+
|
|
279
|
+
Company: assumes none.
|
|
280
|
+
"""
|
|
281
|
+
for end in range(len(test_id), 0, -1):
|
|
282
|
+
if end < len(test_id) and SOURCE_END_RE.match(test_id[end]):
|
|
283
|
+
continue
|
|
284
|
+
candidate = test_id[:end]
|
|
285
|
+
if (Path(root) / candidate).is_file():
|
|
286
|
+
return candidate
|
|
287
|
+
return ""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _function(test_id: str, name: str) -> str:
|
|
291
|
+
"""The test function's dotted name: the rest of the id after its file, without parameters."""
|
|
292
|
+
parts = [PARAMS_RE.sub("", p).strip() for p in ID_PARTS_RE.split(test_id[len(name) :])]
|
|
293
|
+
return ".".join(p for p in parts if p)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def fingerprint(root: Path, test_id: str) -> str:
|
|
297
|
+
"""Fingerprint a test: the sha256 of the reader's span of its function, or of its file.
|
|
298
|
+
|
|
299
|
+
The function is found by its full dotted name, else by its last part when only one symbol
|
|
300
|
+
has it. The whole file is hashed when the reader cannot name it.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
root: The repo.
|
|
304
|
+
test_id: The test's id.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
`sha256:<hex>`, or "" when the id names no file in the repo.
|
|
308
|
+
|
|
309
|
+
Company: assumes none.
|
|
310
|
+
"""
|
|
311
|
+
name = file_of(root, test_id)
|
|
312
|
+
if not name:
|
|
313
|
+
return ""
|
|
314
|
+
target = Path(root) / name
|
|
315
|
+
lines = target.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
|
|
316
|
+
span = lines
|
|
317
|
+
wanted = _function(test_id, name)
|
|
318
|
+
try:
|
|
319
|
+
found = code.symbols(target, name) if wanted and code.reader(target) else []
|
|
320
|
+
except errors.Refused:
|
|
321
|
+
found = []
|
|
322
|
+
match = next((s for s in found if s.name == wanted), None)
|
|
323
|
+
if match is None:
|
|
324
|
+
last = wanted.split(".")[-1]
|
|
325
|
+
named = [s for s in found if s.name.split(".")[-1] == last]
|
|
326
|
+
match = named[0] if len(named) == 1 else None
|
|
327
|
+
if match is not None:
|
|
328
|
+
span = lines[match.first - 1 : match.last]
|
|
329
|
+
return "sha256:" + hashlib.sha256("".join(span).encode("utf-8")).hexdigest()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@contextlib.contextmanager
|
|
333
|
+
def main_tree(root: Path):
|
|
334
|
+
"""Give a temporary worktree of main, removed afterwards.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
root: The repo.
|
|
338
|
+
|
|
339
|
+
Yields:
|
|
340
|
+
The worktree's folder, detached at main.
|
|
341
|
+
|
|
342
|
+
Raises:
|
|
343
|
+
Failed: If git cannot add the worktree.
|
|
344
|
+
|
|
345
|
+
Company: assumes none.
|
|
346
|
+
"""
|
|
347
|
+
folder = Path(tempfile.mkdtemp(prefix="method-main-"))
|
|
348
|
+
tree = folder / "main"
|
|
349
|
+
try:
|
|
350
|
+
out = subprocess.run(
|
|
351
|
+
["git", "worktree", "add", "-q", "--detach", str(tree), BASE],
|
|
352
|
+
cwd=root,
|
|
353
|
+
capture_output=True,
|
|
354
|
+
text=True,
|
|
355
|
+
)
|
|
356
|
+
if out.returncode != 0:
|
|
357
|
+
raise Failed(errors.failed(out.stderr))
|
|
358
|
+
yield tree
|
|
359
|
+
finally:
|
|
360
|
+
if tree.exists():
|
|
361
|
+
subprocess.run(
|
|
362
|
+
["git", "worktree", "remove", "--force", str(tree)], cwd=root, capture_output=True
|
|
363
|
+
)
|
|
364
|
+
subprocess.run(["git", "worktree", "prune"], cwd=root, capture_output=True)
|
|
365
|
+
shutil.rmtree(folder, ignore_errors=True)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@contextlib.contextmanager
|
|
369
|
+
def _at(root: Path, at: str):
|
|
370
|
+
"""The folder to work in: the repo itself at HEAD, a temporary worktree of main at main."""
|
|
371
|
+
if at == BASE:
|
|
372
|
+
with main_tree(root) as tree:
|
|
373
|
+
yield tree
|
|
374
|
+
else:
|
|
375
|
+
yield Path(root)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _env_url(root) -> str:
|
|
379
|
+
"""The scheme, host and port of the environment's health; Failed with no_command if none."""
|
|
380
|
+
env = _config(root).get("environment")
|
|
381
|
+
health = env.get("health") if isinstance(env, dict) else None
|
|
382
|
+
if not isinstance(health, str) or not health.strip():
|
|
383
|
+
raise Failed(errors.NO_COMMAND)
|
|
384
|
+
parts = urlsplit(health)
|
|
385
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _named(text: str, names: list) -> list:
|
|
389
|
+
"""The names, of those given, that appear as words in the text."""
|
|
390
|
+
return [n for n in names if n and re.search(rf"\b{re.escape(n)}\b", text)]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# --- the probes --------------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def collect_probe(params: dict, root) -> dict:
|
|
397
|
+
"""tests.collect: the tests of every type the repo names, as its `collect` lists them.
|
|
398
|
+
|
|
399
|
+
At main, the commands of the head run in a temporary worktree of main.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
params: {"at"}: HEAD or main.
|
|
403
|
+
root: The repo.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
The CollectFacts fields.
|
|
407
|
+
|
|
408
|
+
Company: assumes none.
|
|
409
|
+
"""
|
|
410
|
+
root = Path(root)
|
|
411
|
+
try:
|
|
412
|
+
read = tools(root)
|
|
413
|
+
except Failed:
|
|
414
|
+
return {"types": []}
|
|
415
|
+
found = []
|
|
416
|
+
try:
|
|
417
|
+
with _at(root, params.get("at") or "HEAD") as cwd:
|
|
418
|
+
for kind, entry in read.items():
|
|
419
|
+
cmd = entry.get("collect", "")
|
|
420
|
+
if not cmd.split():
|
|
421
|
+
found.append({"type": kind, "error": errors.NO_COMMAND})
|
|
422
|
+
continue
|
|
423
|
+
ids, ok = collect(cmd, cwd)
|
|
424
|
+
tests = [{"id": i, "file": file_of(cwd, i) or None} for i in ids]
|
|
425
|
+
found.append(
|
|
426
|
+
dict(
|
|
427
|
+
{"type": kind, "tests": tests},
|
|
428
|
+
**({} if ok else {"error": errors.COMMAND_FAILED}),
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
except Failed as e:
|
|
432
|
+
return {"types": [{"type": kind, "error": e.error} for kind in read]}
|
|
433
|
+
return {"types": found}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def run_probe(params: dict, root) -> dict:
|
|
437
|
+
"""tests.run: run tests of one type, each alone, with the type's `run`.
|
|
438
|
+
|
|
439
|
+
At main, they run in a temporary worktree of main, with the tests' own files copied in from
|
|
440
|
+
the head. With environment, each run gets METHOD_ENV_URL, the scheme, host and port of the
|
|
441
|
+
environment's health. A type whose `run` names a program not found here is tool_missing,
|
|
442
|
+
asked with no ids too, which runs nothing.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
params: {"type", "ids", "at", "look_for", "environment"}.
|
|
446
|
+
root: The repo.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
The RunFacts fields: each test's state, and which of look_for its report names.
|
|
450
|
+
|
|
451
|
+
Company: assumes none.
|
|
452
|
+
"""
|
|
453
|
+
root = Path(root)
|
|
454
|
+
ids, look_for = list(params.get("ids") or []), list(params.get("look_for") or [])
|
|
455
|
+
try:
|
|
456
|
+
cmd = command(root, params["type"], "run")
|
|
457
|
+
if shutil.which(_program(cmd)) is None:
|
|
458
|
+
raise Failed(errors.TOOL_MISSING)
|
|
459
|
+
env = dict(os.environ, **{ENV_URL: _env_url(root)}) if params.get("environment") else None
|
|
460
|
+
if not ids:
|
|
461
|
+
return {"results": []}
|
|
462
|
+
results = []
|
|
463
|
+
at = params.get("at") or "HEAD"
|
|
464
|
+
with _at(root, at) as cwd:
|
|
465
|
+
if at == BASE:
|
|
466
|
+
for name in dict.fromkeys(file_of(root, i) for i in ids):
|
|
467
|
+
if name:
|
|
468
|
+
(cwd / name).parent.mkdir(parents=True, exist_ok=True)
|
|
469
|
+
shutil.copyfile(root / name, cwd / name)
|
|
470
|
+
for test_id in ids:
|
|
471
|
+
outcome = run_one(cmd, cwd, test_id, env)
|
|
472
|
+
results.append(
|
|
473
|
+
{"id": test_id, "state": outcome.state, "found": _named(outcome.text, look_for)}
|
|
474
|
+
)
|
|
475
|
+
except Failed as e:
|
|
476
|
+
return {"error": e.error}
|
|
477
|
+
return {"results": results}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def coverage_probe(params: dict, root) -> dict:
|
|
481
|
+
"""tests.coverage: run tests of one type with its `coverage`, and read the lines it measured.
|
|
482
|
+
|
|
483
|
+
Only files of the repo are kept. A command that exits non-zero, a test having failed, still
|
|
484
|
+
gives the lines it measured, with command_failed; one that writes no report gives none.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
params: {"type", "ids"}.
|
|
488
|
+
root: The repo.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
The CoverageFacts fields.
|
|
492
|
+
|
|
493
|
+
Company: assumes none.
|
|
494
|
+
"""
|
|
495
|
+
root = Path(root)
|
|
496
|
+
try:
|
|
497
|
+
cmd = command(root, params["type"], "coverage")
|
|
498
|
+
except Failed as e:
|
|
499
|
+
return {"error": e.error}
|
|
500
|
+
hits, written, ok = coverage(cmd, root, list(params.get("ids") or []))
|
|
501
|
+
files = [
|
|
502
|
+
{
|
|
503
|
+
"path": name,
|
|
504
|
+
"hit": sorted(n for n, c in lines.items() if c > 0),
|
|
505
|
+
"missed": sorted(n for n, c in lines.items() if c == 0),
|
|
506
|
+
}
|
|
507
|
+
for name, lines in sorted(hits.items())
|
|
508
|
+
if not Path(name).is_absolute() and not name.startswith("../")
|
|
509
|
+
]
|
|
510
|
+
found = {"files": files}
|
|
511
|
+
return found if written and ok else dict(found, error=errors.COMMAND_FAILED)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def fingerprints_probe(params: dict, root) -> dict:
|
|
515
|
+
"""tests.fingerprints: the hash of each test's own lines, at the head or on main.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
params: {"ids", "at"}.
|
|
519
|
+
root: The repo.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
The FingerprintFacts fields; a test whose file is not found has none.
|
|
523
|
+
|
|
524
|
+
Company: assumes none.
|
|
525
|
+
"""
|
|
526
|
+
ids = list(params.get("ids") or [])
|
|
527
|
+
try:
|
|
528
|
+
with _at(Path(root), params.get("at") or "HEAD") as cwd:
|
|
529
|
+
found = [{"id": i, "fingerprint": fingerprint(cwd, i) or None} for i in ids]
|
|
530
|
+
except Failed:
|
|
531
|
+
found = [{"id": i, "fingerprint": None} for i in ids]
|
|
532
|
+
return {"tests": found}
|
client/repos.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Which managed repo a clone is: its remotes matched against every identifier the list gives.
|
|
2
|
+
|
|
3
|
+
A managed repo comes with GitHub's fixed id, its current and earlier names, and its https and
|
|
4
|
+
ssh clone addresses. A clone knows its remotes, written either way, perhaps with an old name;
|
|
5
|
+
and the machine remembers the GitHub id of the repo each remote was found to be, so a rename
|
|
6
|
+
on GitHub still finds it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
# An scp-like ssh address: git@host:owner/name.git.
|
|
15
|
+
SCP = re.compile(r"^(?:[^@/\s]+@)?([^:/\s]+):(?!//)(.+)$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def location(url: str) -> tuple[str, str] | None:
|
|
19
|
+
"""A clone address as (host, owner/name), in lower case, without .git; None if it is none.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
url: An https, ssh or scp-like address.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The host and the path, or None.
|
|
26
|
+
|
|
27
|
+
Company: assumes none.
|
|
28
|
+
"""
|
|
29
|
+
url = url.strip()
|
|
30
|
+
found = SCP.match(url) if "://" not in url else None
|
|
31
|
+
if found:
|
|
32
|
+
host, path = found[1], found[2]
|
|
33
|
+
else:
|
|
34
|
+
parts = urlsplit(url)
|
|
35
|
+
host, path = parts.hostname or "", parts.path
|
|
36
|
+
path = path.strip("/").removesuffix(".git").lower()
|
|
37
|
+
return (host.lower(), path) if host and path.count("/") == 1 else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def match(remotes: list[str], managed: list[dict], known_ids: dict[str, int] | None = None):
|
|
41
|
+
"""The managed repo one of a clone's remotes is, or None.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
remotes: The clone's remote addresses, origin first.
|
|
45
|
+
managed: The company's list: [{github_id, name, previous_names, https_url, ssh_url}].
|
|
46
|
+
known_ids: The GitHub id the machine found before for a remote, by its address.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
(the remote, the managed repo), or None.
|
|
50
|
+
|
|
51
|
+
Company: the one whose list is given.
|
|
52
|
+
"""
|
|
53
|
+
known_ids = known_ids or {}
|
|
54
|
+
for remote in remotes:
|
|
55
|
+
here = location(remote)
|
|
56
|
+
for repo in managed:
|
|
57
|
+
if repo.get("github_id") and known_ids.get(remote) == repo["github_id"]:
|
|
58
|
+
return remote, repo
|
|
59
|
+
if here is None:
|
|
60
|
+
continue
|
|
61
|
+
addresses = {location(repo.get(k) or "") for k in ("https_url", "ssh_url")}
|
|
62
|
+
if here in addresses:
|
|
63
|
+
return remote, repo
|
|
64
|
+
hosts = {a[0] for a in addresses if a}
|
|
65
|
+
names = {n.lower() for n in [repo["name"], *repo.get("previous_names", [])]}
|
|
66
|
+
if here[0] in hosts and here[1] in names:
|
|
67
|
+
return remote, repo
|
|
68
|
+
return None
|