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.
Files changed (33) hide show
  1. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/PKG-INFO +1 -1
  2. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/cli.py +11 -3
  3. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/__init__.py +2 -1
  4. ground_control_cli-0.5.0/probes/queue.py +31 -0
  5. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/request.py +4 -10
  6. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/pyproject.toml +1 -1
  7. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/queue.py +88 -11
  8. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/service.py +14 -0
  9. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/.gitignore +0 -0
  10. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/__init__.py +0 -0
  11. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/actions.py +0 -0
  12. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/claude.py +0 -0
  13. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/connect.py +0 -0
  14. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/hooks.py +0 -0
  15. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/machine.py +0 -0
  16. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/__init__.py +0 -0
  17. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/bash.py +0 -0
  18. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/javascript.js +0 -0
  19. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/javascript.py +0 -0
  20. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/code/python.py +0 -0
  21. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/deploy.py +0 -0
  22. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/errors.py +0 -0
  23. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/gh.py +0 -0
  24. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/git.py +0 -0
  25. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/health.py +0 -0
  26. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/settings.py +0 -0
  27. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/tests.py +0 -0
  28. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/probes/workspace.py +0 -0
  29. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/repos.py +0 -0
  30. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/system/__init__.py +0 -0
  31. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/system/macos.py +0 -0
  32. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/tools.py +0 -0
  33. {ground_control_cli-0.4.0 → ground_control_cli-0.5.0}/update.py +0 -0
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ground-control-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: What runs on a developer's machine: the command line and the agent's plugin.
5
5
  Requires-Python: >=3.12
@@ -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(f"{dropped} rows of the method log could not be sent, and are dropped.")
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 feature's name made from the title;
8
- never the request's words. The service reads the same facts alike (app/method/requests.py).
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
- "name": slug(issue.get("title") or ""),
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()),
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ground-control-cli"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "What runs on a developer's machine: the command line and the agent's plugin."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = []
@@ -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; how many rows of the method log were dropped.
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
- For the client's removal, once it has tried to send them.
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(db, address: str, repo: str, e: ServiceError) -> int | None:
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(db, table: str, key: str, address: str, repo: str, send: Callable) -> None:
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