ground-control-cli 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/PKG-INFO +1 -1
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/cli.py +11 -3
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/__init__.py +2 -1
- ground_control_cli-0.5.0/probes/queue.py +31 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/request.py +4 -10
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/pyproject.toml +1 -1
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/queue.py +88 -11
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/service.py +14 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/.gitignore +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/__init__.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/actions.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/claude.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/connect.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/hooks.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/machine.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/__init__.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/bash.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/javascript.js +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/javascript.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/python.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/deploy.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/errors.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/gh.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/git.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/health.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/settings.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/tests.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/workspace.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/repos.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/system/__init__.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/system/macos.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/tools.py +0 -0
- {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/update.py +0 -0
|
@@ -252,9 +252,17 @@ def remove() -> int:
|
|
|
252
252
|
except ServiceError as e:
|
|
253
253
|
say(f"The queue could not all be sent: {e.detail}.")
|
|
254
254
|
if queue.waiting():
|
|
255
|
-
dropped = queue.drop_all()
|
|
256
|
-
if dropped:
|
|
257
|
-
say(
|
|
255
|
+
dropped, untold = queue.drop_all(_service_for)
|
|
256
|
+
if dropped - untold:
|
|
257
|
+
say(
|
|
258
|
+
f"{dropped - untold} rows of the method log could not be sent, and are dropped; "
|
|
259
|
+
"the service was told which."
|
|
260
|
+
)
|
|
261
|
+
if untold:
|
|
262
|
+
say(
|
|
263
|
+
f"{untold} rows of the method log could not be sent, and are dropped; the service "
|
|
264
|
+
"could not be told which, so features they belong to cannot pass their gate."
|
|
265
|
+
)
|
|
258
266
|
for address in machine.load()["companies"]:
|
|
259
267
|
called = machine.service(address, here)
|
|
260
268
|
if called is not None:
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from collections.abc import Callable
|
|
15
15
|
|
|
16
|
-
from . import code, deploy, gh, git, health, request, tests, workspace
|
|
16
|
+
from . import code, deploy, gh, git, health, queue, request, tests, workspace
|
|
17
17
|
|
|
18
18
|
# Every probe the client runs: its name -> the function of (params, root) that runs it.
|
|
19
19
|
RUNS: dict[str, Callable[[dict, object], dict]] = {
|
|
@@ -43,6 +43,7 @@ RUNS: dict[str, Callable[[dict, object], dict]] = {
|
|
|
43
43
|
"workspace.changes": workspace.changes,
|
|
44
44
|
"deploy.commit": deploy.commit,
|
|
45
45
|
"deploy.discover": deploy.discover,
|
|
46
|
+
"queue.waiting": queue.waiting,
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""queue.waiting: how many of a feature's rows still wait in this machine's queue.
|
|
2
|
+
|
|
3
|
+
A count, never a row: the service reads it at the gate, so nothing passes while the machine
|
|
4
|
+
still holds a row of the feature (client/queue.py).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
|
|
11
|
+
from . import errors
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def waiting(params: dict, root) -> dict:
|
|
15
|
+
"""Count the feature's rows in the machine's queue.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
params: {feature, repo}.
|
|
19
|
+
root: Not read: the queue is the machine's.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
{waiting, error}.
|
|
23
|
+
|
|
24
|
+
Company: assumes none.
|
|
25
|
+
"""
|
|
26
|
+
from client import queue # the queue sends through the service, which runs the probes
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
return {"waiting": queue.waiting_for(params["feature"], params["repo"]), "error": None}
|
|
30
|
+
except (sqlite3.Error, OSError):
|
|
31
|
+
return {"waiting": 0, "error": errors.UNREADABLE}
|
|
@@ -4,8 +4,9 @@ Where the company has not connected its GitHub, the service cannot read a reques
|
|
|
4
4
|
developer's machine reads it: through `gh` when it is installed, else with the developer's own
|
|
5
5
|
token (GH_TOKEN or GITHUB_TOKEN) over GitHub's API. Only facts leave the machine: whether
|
|
6
6
|
`## Description` says anything, how many items `## What needs to be done` lists, the design's
|
|
7
|
-
links and flow, the labels and when each was added, and the
|
|
8
|
-
never the request's words. The service reads the same facts alike
|
|
7
|
+
links and flow, the labels and when each was added, and its key (the issue's number), which names
|
|
8
|
+
the feature; never the request's words. The service reads the same facts alike
|
|
9
|
+
(app/method/requests.py).
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from __future__ import annotations
|
|
@@ -25,7 +26,6 @@ GITHUB_WEB = "https://github.com"
|
|
|
25
26
|
TIMEOUT = 10
|
|
26
27
|
LABELS_MAX = 100
|
|
27
28
|
EVENTS_MAX = 100
|
|
28
|
-
NAME_LENGTH = 40
|
|
29
29
|
TOKENS = ("GH_TOKEN", "GITHUB_TOKEN")
|
|
30
30
|
|
|
31
31
|
WHAT_HEADING = "description"
|
|
@@ -120,12 +120,6 @@ def _mermaid(text: str) -> bool:
|
|
|
120
120
|
return False
|
|
121
121
|
|
|
122
122
|
|
|
123
|
-
def slug(title: str) -> str:
|
|
124
|
-
"""The feature's name from a title: lowercase words joined by `-`, at most 40 characters."""
|
|
125
|
-
name = re.sub(r"[^a-z0-9]+", "-", (title or "").lower()).strip("-")
|
|
126
|
-
return name[:NAME_LENGTH].rstrip("-")
|
|
127
|
-
|
|
128
|
-
|
|
129
123
|
def labelled_at(events: list) -> list:
|
|
130
124
|
"""When each label was last added, from GitHub's LabeledEvent nodes, sorted by name."""
|
|
131
125
|
found: dict = {}
|
|
@@ -143,7 +137,7 @@ def _facts(issue: dict) -> dict:
|
|
|
143
137
|
links = [u.rstrip(URL_END) for u in URL_RE.findall(design or "")]
|
|
144
138
|
return {
|
|
145
139
|
"url": issue["url"],
|
|
146
|
-
"
|
|
140
|
+
"key": str(issue["number"]),
|
|
147
141
|
"labels": [label["name"] for label in issue["labels"]["nodes"]],
|
|
148
142
|
"labelled_at": labelled_at(issue["timelineItems"]["nodes"]),
|
|
149
143
|
"what": bool((section(body, WHAT_HEADING) or "").strip()),
|
|
@@ -21,7 +21,8 @@ A refusal that is not about a row is handled by its cause, never by trying again
|
|
|
21
21
|
- the machine's key is refused (it was disconnected, its person removed), or the machine holds
|
|
22
22
|
no key for the company: nothing more is sent there, the person is told once, and the rows
|
|
23
23
|
wait until the machine is connected again;
|
|
24
|
-
- the repo is no longer managed: its rows and events are dropped
|
|
24
|
+
- the repo is no longer managed: its rows and events are dropped, once the service has been told
|
|
25
|
+
which rows, by id and number, so it holds every number this machine gave;
|
|
25
26
|
- the batch is too large: it is split and sent again;
|
|
26
27
|
- the company is read-only, the client is too old for it, or it refuses otherwise: the rows
|
|
27
28
|
wait, nothing more is sent there until the next session's start, and the person is told once.
|
|
@@ -88,8 +89,10 @@ FROM_0 = (
|
|
|
88
89
|
DISCONNECTED, NO_KEY = "disconnected", "no_key"
|
|
89
90
|
READ_ONLY, TOO_OLD, REFUSED = "read_only", "too_old", "refused"
|
|
90
91
|
UNTIL_CONNECTED = (DISCONNECTED, NO_KEY)
|
|
91
|
-
# The refusal of a batch whose repo the company no longer manages.
|
|
92
|
+
# The refusal of a batch whose repo the company no longer manages; also why its rows are dropped.
|
|
92
93
|
NOT_MANAGED = "not_managed"
|
|
94
|
+
# Why rows are dropped when the client is removed from the machine.
|
|
95
|
+
REMOVED = "removed"
|
|
93
96
|
TOLD = {
|
|
94
97
|
DISCONNECTED: (
|
|
95
98
|
"This machine is disconnected from {address}: {detail} Its method-log rows wait on "
|
|
@@ -251,6 +254,26 @@ def write_event(address: str, event: dict) -> dict:
|
|
|
251
254
|
return found
|
|
252
255
|
|
|
253
256
|
|
|
257
|
+
def waiting_for(feature: str, repo: str) -> int:
|
|
258
|
+
"""How many rows of a feature of a repo wait in the queue, whatever their company.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
feature: The feature.
|
|
262
|
+
repo: The repo, as its host writes it; alike in any case.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
How many.
|
|
266
|
+
|
|
267
|
+
Company: each one the queue holds rows for.
|
|
268
|
+
"""
|
|
269
|
+
with _db() as db:
|
|
270
|
+
return db.execute(
|
|
271
|
+
"select count(*) from rows where lower(repo) = lower(?) "
|
|
272
|
+
"and json_extract(body, '$.feature') = ?",
|
|
273
|
+
(repo, feature),
|
|
274
|
+
).fetchone()[0]
|
|
275
|
+
|
|
276
|
+
|
|
254
277
|
def waiting(address: str | None = None) -> int:
|
|
255
278
|
"""How many rows and events wait to be sent, for one company or all."""
|
|
256
279
|
with _db() as db:
|
|
@@ -325,25 +348,61 @@ def told() -> list[str]:
|
|
|
325
348
|
return [TOLD[cause].format(address=address, detail=detail) for address, cause, detail in found]
|
|
326
349
|
|
|
327
350
|
|
|
328
|
-
def drop_all() -> int:
|
|
329
|
-
"""Drop everything that waits
|
|
351
|
+
def drop_all(service_for: Callable[[str], Service | None]) -> tuple[int, int]:
|
|
352
|
+
"""Drop everything that waits, telling each company first which rows it drops.
|
|
353
|
+
|
|
354
|
+
For the client's removal, once it has tried to send them. A row whose company could not be
|
|
355
|
+
told is dropped all the same; the service then lacks its number.
|
|
330
356
|
|
|
331
|
-
|
|
357
|
+
Args:
|
|
358
|
+
service_for: The service of a company's address, with this machine's key; None when
|
|
359
|
+
this machine holds no key for it.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
(how many rows of the method log were dropped, how many of them no company was told of).
|
|
332
363
|
|
|
333
364
|
Company: each one the queue holds rows for.
|
|
334
365
|
"""
|
|
335
366
|
with _db() as db:
|
|
336
367
|
count = db.execute("select count(*) from rows").fetchone()[0]
|
|
368
|
+
untold = 0
|
|
369
|
+
for (address,) in db.execute("select distinct address from rows").fetchall():
|
|
370
|
+
called = service_for(address)
|
|
371
|
+
try:
|
|
372
|
+
if called is None:
|
|
373
|
+
raise ServiceError(None, NO_KEY)
|
|
374
|
+
_tell_dropped(db, address, None, called, REMOVED)
|
|
375
|
+
except ServiceError:
|
|
376
|
+
untold += db.execute(
|
|
377
|
+
"select count(*) from rows where address = ?", (address,)
|
|
378
|
+
).fetchone()[0]
|
|
337
379
|
for table in ("rows", "events", "numbers", "stops"):
|
|
338
380
|
db.execute(f"delete from {table}")
|
|
339
|
-
return count
|
|
381
|
+
return count, untold
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _tell_dropped(db, address: str, repo: str | None, called: Service, why: str) -> None:
|
|
385
|
+
"""Tell the company which of its rows (of one repo, or all) are dropped, a batch at a time.
|
|
386
|
+
|
|
387
|
+
Raises:
|
|
388
|
+
ServiceError: If the service refuses or cannot be reached: nothing is dropped then.
|
|
389
|
+
"""
|
|
390
|
+
found = db.execute(
|
|
391
|
+
"select seq, id from rows where address = ? and (? is null or repo = ?) order by seq",
|
|
392
|
+
(address, repo, repo),
|
|
393
|
+
).fetchall()
|
|
394
|
+
for at in range(0, len(found), BATCH):
|
|
395
|
+
batch = found[at : at + BATCH]
|
|
396
|
+
called.send_dropped([{"id": i, "seq": s} for s, i in batch], why)
|
|
340
397
|
|
|
341
398
|
|
|
342
399
|
class _Stop(Exception):
|
|
343
400
|
"""Sending to this company stops, for its cause; the rest go on."""
|
|
344
401
|
|
|
345
402
|
|
|
346
|
-
def _refused(
|
|
403
|
+
def _refused(
|
|
404
|
+
db, address: str, repo: str, e: ServiceError, called: Service | None = None
|
|
405
|
+
) -> int | None:
|
|
347
406
|
"""Act on a refusal by its cause: the most a batch may hold now, or None when the repo is done.
|
|
348
407
|
|
|
349
408
|
Raises:
|
|
@@ -356,6 +415,15 @@ def _refused(db, address: str, repo: str, e: ServiceError) -> int | None:
|
|
|
356
415
|
_stop(db, address, DISCONNECTED, e.detail)
|
|
357
416
|
raise _Stop()
|
|
358
417
|
if e.status == 409 and e.refusal == NOT_MANAGED:
|
|
418
|
+
if called is not None:
|
|
419
|
+
try:
|
|
420
|
+
_tell_dropped(db, address, repo, called, NOT_MANAGED)
|
|
421
|
+
except ServiceError as told:
|
|
422
|
+
if told.status is None or told.status >= 500:
|
|
423
|
+
raise told from None
|
|
424
|
+
cause = {401: DISCONNECTED, 403: READ_ONLY, 426: TOO_OLD}.get(told.status, REFUSED)
|
|
425
|
+
_stop(db, address, cause, told.detail)
|
|
426
|
+
raise _Stop() from None
|
|
359
427
|
db.execute("delete from rows where address = ? and repo = ?", (address, repo))
|
|
360
428
|
db.execute("delete from events where address = ? and repo = ?", (address, repo))
|
|
361
429
|
return None
|
|
@@ -369,10 +437,19 @@ def _refused(db, address: str, repo: str, e: ServiceError) -> int | None:
|
|
|
369
437
|
raise _Stop()
|
|
370
438
|
|
|
371
439
|
|
|
372
|
-
def _send(
|
|
440
|
+
def _send(
|
|
441
|
+
db,
|
|
442
|
+
table: str,
|
|
443
|
+
key: str,
|
|
444
|
+
address: str,
|
|
445
|
+
repo: str,
|
|
446
|
+
send: Callable,
|
|
447
|
+
called: Service | None = None,
|
|
448
|
+
) -> None:
|
|
373
449
|
"""Send one repo's rows or events, a batch at a time; a batch taken leaves the queue whole.
|
|
374
450
|
|
|
375
|
-
A batch too large for the service is sent again in halves, never above its limit.
|
|
451
|
+
A batch too large for the service is sent again in halves, never above its limit. `called`
|
|
452
|
+
is told which rows are dropped, when the repo is no longer managed.
|
|
376
453
|
"""
|
|
377
454
|
size = BATCH
|
|
378
455
|
while True:
|
|
@@ -386,7 +463,7 @@ def _send(db, table: str, key: str, address: str, repo: str, send: Callable) ->
|
|
|
386
463
|
try:
|
|
387
464
|
send([json.loads(body) for _, body in found])
|
|
388
465
|
except ServiceError as e:
|
|
389
|
-
limit = _refused(db, address, repo, e)
|
|
466
|
+
limit = _refused(db, address, repo, e, called)
|
|
390
467
|
if limit is None:
|
|
391
468
|
return
|
|
392
469
|
size = max(1, min(limit, len(found) // 2))
|
|
@@ -462,7 +539,7 @@ def flush(
|
|
|
462
539
|
passed.add(address)
|
|
463
540
|
continue
|
|
464
541
|
try:
|
|
465
|
-
_send(db, "rows", "seq", address, repo, called.send_rows)
|
|
542
|
+
_send(db, "rows", "seq", address, repo, called.send_rows, called)
|
|
466
543
|
_send(db, "events", "n", address, repo, called.send_sessions)
|
|
467
544
|
except _Stop:
|
|
468
545
|
passed.add(address)
|
|
@@ -276,6 +276,20 @@ class Service:
|
|
|
276
276
|
"""
|
|
277
277
|
return self.call("POST", "/api/plugin/log", {"rows": rows})
|
|
278
278
|
|
|
279
|
+
def send_dropped(self, rows: list[dict], why: str) -> dict:
|
|
280
|
+
"""Say which rows of one repo this machine drops unsent, by id and number: {last_seq}.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
rows: Each {id, seq}.
|
|
284
|
+
why: `not_managed` or `removed`.
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
ServiceError: If the service refuses or cannot be reached.
|
|
288
|
+
|
|
289
|
+
Company: the one at the address.
|
|
290
|
+
"""
|
|
291
|
+
return self.call("POST", "/api/plugin/log/dropped", {"rows": rows, "why": why})
|
|
292
|
+
|
|
279
293
|
def send_sessions(self, events: list[dict]) -> None:
|
|
280
294
|
"""Send what the machine's sessions did.
|
|
281
295
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|