graphban-cli 0.8.4__tar.gz → 0.8.6__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.
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/PKG-INFO +12 -2
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/README.md +11 -1
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/pyproject.toml +1 -1
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/cli.py +53 -4
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/client.py +9 -0
- graphban_cli-0.8.6/src/gban/machine.py +91 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/setup.py +46 -1
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-work/SKILL.md +7 -1
- graphban_cli-0.8.6/tests/test_login_pair.py +46 -0
- graphban_cli-0.8.6/tests/test_machine_report.py +81 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_setup.py +126 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/uv.lock +1 -1
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/.gitignore +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/LICENSE +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/__init__.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/config.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/data/stack_sdks.toml +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/doctor.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/gitignore.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-answer/SKILL.md +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-grill/SKILL.md +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-orient/SKILL.md +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-prd/SKILL.md +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-review/SKILL.md +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-scan/SKILL.md +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-wave/SKILL.md +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/watch-wave/SKILL.md +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/stackmap.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/swamp.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_acts.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_doctor.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_packaging.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_session.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_stackmap.py +0 -0
- {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_swamp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: graphban-cli
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.6
|
|
4
4
|
Summary: gban — a Graphban client for the human at a terminal
|
|
5
5
|
Project-URL: Homepage, https://github.com/asc-me/graphban
|
|
6
6
|
Project-URL: Repository, https://github.com/asc-me/graphban
|
|
@@ -87,6 +87,7 @@ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/do
|
|
|
87
87
|
```bash
|
|
88
88
|
gban login --server https://cloud.agentldgr.dev
|
|
89
89
|
gban doctor # both halves: the ledger, and the local fleet
|
|
90
|
+
# (also reports the result to the server; --no-report opts out)
|
|
90
91
|
gban agents # the roster, and why an agent is stuck
|
|
91
92
|
gban agents role SA-A4 planner # what used to need a browser
|
|
92
93
|
gban seats issue worker worker planner # one entry per agent
|
|
@@ -171,6 +172,15 @@ Three properties worth knowing, each of which is a bug this command exists to no
|
|
|
171
172
|
committed. It asks git (`check-ignore`), so an equivalent pattern already in the
|
|
172
173
|
file is left alone.
|
|
173
174
|
|
|
175
|
+
## Machine reports
|
|
176
|
+
|
|
177
|
+
`gban setup` and `gban doctor` send the server a report of their PASS/FAIL/UNKNOWN lines, tagged
|
|
178
|
+
with a random machine id kept in `~/.graphban/machine.json` (delete it and you are a new
|
|
179
|
+
machine). A report whose every line is PASS turns the dashboard's "Connect your agent" step ✓ (an UNKNOWN is never a pass); the list is
|
|
180
|
+
under Settings → Machines. Pass `--no-report` to skip it. When the report cannot be delivered,
|
|
181
|
+
the command prints `UNKNOWN report could not report to <server>: <reason>` and still exits on
|
|
182
|
+
its own checks — an undelivered report never counts as proof.
|
|
183
|
+
|
|
174
184
|
## Wiring a checkout to Swamp
|
|
175
185
|
|
|
176
186
|
```bash
|
|
@@ -221,7 +231,7 @@ A real post needs a git commit (there is nothing to send for a repo with no HEAD
|
|
|
221
231
|
It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
|
|
222
232
|
read when it cannot turn echo off — it warns, but the warning arrives after the person has
|
|
223
233
|
decided to type — so a login through a pipe, a heredoc or an editor's command runner would
|
|
224
|
-
put the password in the scrollback.
|
|
234
|
+
put the password in the scrollback. `gban login --pair` is the alternative with no password prompt: it prints a short code, and a person signed in to the web app approves it in the quick-start dialog (step ③). The session you get is the approver's, and the code is single-use and expires after 10 minutes. There is no unattended login yet (PRD-40 open
|
|
225
235
|
question 2: an API key cannot reach the JWT routes, so CI would need a service session).
|
|
226
236
|
|
|
227
237
|
## Why not `gb`
|
|
@@ -72,6 +72,7 @@ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/do
|
|
|
72
72
|
```bash
|
|
73
73
|
gban login --server https://cloud.agentldgr.dev
|
|
74
74
|
gban doctor # both halves: the ledger, and the local fleet
|
|
75
|
+
# (also reports the result to the server; --no-report opts out)
|
|
75
76
|
gban agents # the roster, and why an agent is stuck
|
|
76
77
|
gban agents role SA-A4 planner # what used to need a browser
|
|
77
78
|
gban seats issue worker worker planner # one entry per agent
|
|
@@ -156,6 +157,15 @@ Three properties worth knowing, each of which is a bug this command exists to no
|
|
|
156
157
|
committed. It asks git (`check-ignore`), so an equivalent pattern already in the
|
|
157
158
|
file is left alone.
|
|
158
159
|
|
|
160
|
+
## Machine reports
|
|
161
|
+
|
|
162
|
+
`gban setup` and `gban doctor` send the server a report of their PASS/FAIL/UNKNOWN lines, tagged
|
|
163
|
+
with a random machine id kept in `~/.graphban/machine.json` (delete it and you are a new
|
|
164
|
+
machine). A report whose every line is PASS turns the dashboard's "Connect your agent" step ✓ (an UNKNOWN is never a pass); the list is
|
|
165
|
+
under Settings → Machines. Pass `--no-report` to skip it. When the report cannot be delivered,
|
|
166
|
+
the command prints `UNKNOWN report could not report to <server>: <reason>` and still exits on
|
|
167
|
+
its own checks — an undelivered report never counts as proof.
|
|
168
|
+
|
|
159
169
|
## Wiring a checkout to Swamp
|
|
160
170
|
|
|
161
171
|
```bash
|
|
@@ -206,7 +216,7 @@ A real post needs a git commit (there is nothing to send for a repo with no HEAD
|
|
|
206
216
|
It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
|
|
207
217
|
read when it cannot turn echo off — it warns, but the warning arrives after the person has
|
|
208
218
|
decided to type — so a login through a pipe, a heredoc or an editor's command runner would
|
|
209
|
-
put the password in the scrollback.
|
|
219
|
+
put the password in the scrollback. `gban login --pair` is the alternative with no password prompt: it prints a short code, and a person signed in to the web app approves it in the quick-start dialog (step ③). The session you get is the approver's, and the code is single-use and expires after 10 minutes. There is no unattended login yet (PRD-40 open
|
|
210
220
|
question 2: an API key cannot reach the JWT routes, so CI would need a service session).
|
|
211
221
|
|
|
212
222
|
## Why not `gb`
|
|
@@ -13,12 +13,14 @@ import os
|
|
|
13
13
|
import shutil
|
|
14
14
|
import subprocess
|
|
15
15
|
import sys
|
|
16
|
+
import time
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
18
|
-
from gban import config, doctor as doctor_mod, setup as setup_mod, stackmap, swamp as swamp_mod
|
|
19
|
+
from gban import config, doctor as doctor_mod, machine as machine_mod, setup as setup_mod, stackmap, swamp as swamp_mod
|
|
19
20
|
from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
|
|
20
21
|
Client, NoSession,
|
|
21
|
-
Refused, Unreachable, authenticated, login
|
|
22
|
+
Refused, Unreachable, authenticated, login, pair_poll,
|
|
23
|
+
pair_start)
|
|
22
24
|
|
|
23
25
|
PROG = "gban"
|
|
24
26
|
|
|
@@ -44,14 +46,20 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
44
46
|
"written to session.json at mode 600; the access token is never stored, "
|
|
45
47
|
"because it is renewed on every invocation anyway."))
|
|
46
48
|
login_cmd.add_argument("--email", default=None)
|
|
49
|
+
login_cmd.add_argument(
|
|
50
|
+
"--pair", action="store_true",
|
|
51
|
+
help=("sign in without typing a password here: prints a short code that a signed-in "
|
|
52
|
+
"person approves in the web app's quick-start dialog"))
|
|
47
53
|
|
|
48
54
|
sub.add_parser("logout", help="end the session, here and on the server")
|
|
49
|
-
sub.add_parser(
|
|
55
|
+
doctor_cmd = sub.add_parser(
|
|
50
56
|
"doctor", help="check both halves: the ledger, and the local fleet",
|
|
51
57
|
description=("Everything that can be checked before anything is spawned. The ledger "
|
|
52
58
|
"half runs here; the local half is `gbfleet doctor`, run as a subprocess. "
|
|
53
59
|
"Neither half silences the other: what could not be checked prints "
|
|
54
60
|
"UNKNOWN with its reason, never a pass."))
|
|
61
|
+
doctor_cmd.add_argument("--no-report", action="store_true",
|
|
62
|
+
help="do not send the result to the server (Settings → Machines)")
|
|
55
63
|
|
|
56
64
|
setup_cmd = sub.add_parser(
|
|
57
65
|
"setup", help="enable delegation on a project",
|
|
@@ -75,6 +83,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
75
83
|
"lookup")
|
|
76
84
|
setup_cmd.add_argument("--no-install", action="store_true",
|
|
77
85
|
help="do not install the supervisor")
|
|
86
|
+
setup_cmd.add_argument("--no-report", action="store_true",
|
|
87
|
+
help="do not send the result to the server (Settings → Machines)")
|
|
78
88
|
|
|
79
89
|
swamp_cmd = sub.add_parser(
|
|
80
90
|
"swamp", help="wire this checkout to Swamp's Graphban adapter",
|
|
@@ -183,6 +193,8 @@ def _server(args) -> str:
|
|
|
183
193
|
|
|
184
194
|
def cmd_login(args) -> int:
|
|
185
195
|
url = _server(args)
|
|
196
|
+
if args.pair:
|
|
197
|
+
return _login_pair(args, url)
|
|
186
198
|
# A TERMINAL, OR NOTHING. `getpass` falls back to a plain echoing read when it cannot
|
|
187
199
|
# turn echo off, and warns about it — which is the wrong trade for a password: the
|
|
188
200
|
# warning arrives after the person has already decided to type. Without a tty the
|
|
@@ -205,7 +217,38 @@ def cmd_login(args) -> int:
|
|
|
205
217
|
# same rule criterion 4 applies to an expired session applies to a cancelled login.
|
|
206
218
|
print(f"\n{PROG}: cancelled", file=sys.stderr)
|
|
207
219
|
return EXIT_REFUSED
|
|
208
|
-
|
|
220
|
+
return _finish_login(args, url, email, login(url, email, password))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _login_pair(args, url: str) -> int:
|
|
224
|
+
"""Device-code sign-in (GRPH-1366): the identity is whoever approves the code in the app."""
|
|
225
|
+
started = pair_start(url)
|
|
226
|
+
code = started.get("user_code") or ""
|
|
227
|
+
print(f"{PROG}: open the quick-start dialog in {url} (step 3) and enter this code:\n\n"
|
|
228
|
+
f" {code}\n\n waiting for approval (expires in "
|
|
229
|
+
f"{int(started.get('expires_in', 0)) // 60} min)…", file=sys.stderr)
|
|
230
|
+
deadline = time.monotonic() + float(started.get("expires_in", 600))
|
|
231
|
+
interval = max(float(started.get("interval", 3)), 0.0)
|
|
232
|
+
try:
|
|
233
|
+
while time.monotonic() < deadline:
|
|
234
|
+
time.sleep(interval)
|
|
235
|
+
try:
|
|
236
|
+
got = pair_poll(url, started.get("device_code", ""))
|
|
237
|
+
except Refused:
|
|
238
|
+
print(f"{PROG}: the code expired or was already used. Run `{PROG} login --pair` "
|
|
239
|
+
f"again.", file=sys.stderr)
|
|
240
|
+
return EXIT_REFUSED
|
|
241
|
+
if got.get("status") == "approved":
|
|
242
|
+
return _finish_login(args, url, got.get("email") or "", got)
|
|
243
|
+
except KeyboardInterrupt:
|
|
244
|
+
print(f"\n{PROG}: cancelled", file=sys.stderr)
|
|
245
|
+
return EXIT_REFUSED
|
|
246
|
+
print(f"{PROG}: the code expired before anyone approved it. Run `{PROG} login --pair` "
|
|
247
|
+
f"again.", file=sys.stderr)
|
|
248
|
+
return EXIT_REFUSED
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _finish_login(args, url: str, email: str, pair: dict) -> int:
|
|
209
252
|
refresh = pair.get("refresh_token") or ""
|
|
210
253
|
if not refresh:
|
|
211
254
|
print(f"{PROG}: the server returned no refresh token", file=sys.stderr)
|
|
@@ -327,6 +370,8 @@ def cmd_doctor(args) -> int:
|
|
|
327
370
|
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
328
371
|
api_key = doctor_mod.resolve_supervisor_key(project, url=url)
|
|
329
372
|
lines, code = doctor_mod.run(url, project, api_key)
|
|
373
|
+
if not getattr(args, "no_report", False):
|
|
374
|
+
lines += machine_mod.send(url, project, lines) # UNKNOWN when undelivered: exit unchanged
|
|
330
375
|
_out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
|
|
331
376
|
return code
|
|
332
377
|
|
|
@@ -354,6 +399,8 @@ def cmd_setup(args) -> int:
|
|
|
354
399
|
return EXIT_REFUSED
|
|
355
400
|
lines, code, made = setup_mod.run(client, url, project, Path.cwd(), scope=args.scope,
|
|
356
401
|
install=not args.no_install)
|
|
402
|
+
if not args.no_report:
|
|
403
|
+
lines += machine_mod.send(url, project, lines)
|
|
357
404
|
human = [doctor_mod.render(lines)]
|
|
358
405
|
if how != "named":
|
|
359
406
|
human.insert(0, f"{PROG}: {project} — {how}")
|
|
@@ -388,6 +435,8 @@ def _setup_auto(args, url: str, client) -> int:
|
|
|
388
435
|
got, code, made = setup_mod.run(client, url, pid, repo, scope=args.scope,
|
|
389
436
|
install=not args.no_install)
|
|
390
437
|
lines += [{**l, "name": f"{pid}: {l['name']}"} for l in got]
|
|
438
|
+
if not args.no_report:
|
|
439
|
+
lines += [{**l, "name": f"{pid}: {l['name']}"} for l in machine_mod.send(url, pid, got)]
|
|
391
440
|
done[pid], worst = {"repo": str(repo), "ok": code == 0, **made}, max(worst, code)
|
|
392
441
|
human = [doctor_mod.render(lines),
|
|
393
442
|
f"\n {sum(1 for d in done.values() if d['ok'])} of {len(done)} enabled. "
|
|
@@ -135,6 +135,15 @@ def login(url: str, email: str, password: str) -> dict:
|
|
|
135
135
|
{"email": email, "password": password})
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
def pair_start(url: str) -> dict:
|
|
139
|
+
"""Begin a device-code sign-in: no password, no credential yet (GRPH-1366)."""
|
|
140
|
+
return Client(url).call("POST", "/api/cli/pair")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def pair_poll(url: str, device_code: str) -> dict:
|
|
144
|
+
return Client(url).call("POST", "/api/cli/pair/token", {"device_code": device_code})
|
|
145
|
+
|
|
146
|
+
|
|
138
147
|
def authenticated(url: str, *, act: str = "") -> Client:
|
|
139
148
|
"""A client for this invocation: one refresh exchange, access token held in memory.
|
|
140
149
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Machine identity and the report `gban setup` / `gban doctor` send (GRPH-1442).
|
|
2
|
+
|
|
3
|
+
The machine id is random and lives in `machine.json` in gban's home. Wiping that directory makes
|
|
4
|
+
a new machine, which is the intent: the id names an installation, not a person or a label.
|
|
5
|
+
|
|
6
|
+
A report that cannot be delivered NEVER counts. It comes back as an UNKNOWN line naming the
|
|
7
|
+
server and the reason, and the caller still exits on its own checks.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess # nosec B404 - fixed argv, no shell
|
|
15
|
+
import uuid
|
|
16
|
+
from importlib import metadata
|
|
17
|
+
|
|
18
|
+
from gban import config
|
|
19
|
+
from gban.client import NoSession, Refused, Unreachable, authenticated
|
|
20
|
+
from gban.doctor import UNKNOWN, _line
|
|
21
|
+
|
|
22
|
+
MACHINE_FILE = "machine.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def machine_id() -> str:
|
|
26
|
+
"""The stored id, minted on first use. An unwritable home still yields a (volatile) id."""
|
|
27
|
+
path = config.home() / MACHINE_FILE
|
|
28
|
+
existing = config._read(path).get("machine_id")
|
|
29
|
+
if isinstance(existing, str) and existing:
|
|
30
|
+
return existing
|
|
31
|
+
fresh = "gbm_" + uuid.uuid4().hex
|
|
32
|
+
try:
|
|
33
|
+
config._write(path, {"machine_id": fresh})
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
return fresh
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _version(dist: str) -> str:
|
|
40
|
+
try:
|
|
41
|
+
return metadata.version(dist)
|
|
42
|
+
except metadata.PackageNotFoundError:
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _gbfleet_version() -> str:
|
|
47
|
+
exe = shutil.which("gbfleet")
|
|
48
|
+
if not exe:
|
|
49
|
+
return ""
|
|
50
|
+
try:
|
|
51
|
+
done = subprocess.run([exe, "--version"], capture_output=True, text=True, # nosec B603
|
|
52
|
+
timeout=5)
|
|
53
|
+
return (done.stdout or "").strip().split()[-1] if done.returncode == 0 and done.stdout.strip() else ""
|
|
54
|
+
except (OSError, subprocess.SubprocessError):
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _harnesses() -> list[str]:
|
|
59
|
+
return [name for name, binary in (("claude", "claude"), ("grok", "grok"), ("codex", "codex"))
|
|
60
|
+
if shutil.which(binary)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def payload(project: str, lines: list[dict]) -> dict:
|
|
64
|
+
return {
|
|
65
|
+
"machine_id": machine_id(), "label": socket.gethostname().split(".")[0],
|
|
66
|
+
"os": f"{platform.system()} {platform.release()}".strip(),
|
|
67
|
+
"gban_version": _version("gban"), "gbfleet_version": _gbfleet_version(),
|
|
68
|
+
"harnesses": _harnesses(), "project": project,
|
|
69
|
+
"lines": [{"status": l["status"], "name": l["name"], "detail": l.get("detail", "")}
|
|
70
|
+
for l in lines],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def send(url: str, project: str, lines: list[dict]) -> list[dict]:
|
|
75
|
+
"""Deliver the report. Returns the line to append when it could not be delivered, else []."""
|
|
76
|
+
try:
|
|
77
|
+
authenticated(url, act="report").call("POST", "/api/machines/report",
|
|
78
|
+
payload(project, lines))
|
|
79
|
+
except NoSession as exc:
|
|
80
|
+
return [_unreported(url, exc.why)]
|
|
81
|
+
except Refused as exc:
|
|
82
|
+
return [_unreported(url, exc.detail)]
|
|
83
|
+
except Unreachable as exc:
|
|
84
|
+
return [_unreported(url, str(exc))]
|
|
85
|
+
except Exception as exc: # noqa: BLE001 - a report must never take the command down
|
|
86
|
+
return [_unreported(url, f"{type(exc).__name__}: {exc}")]
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _unreported(url: str, reason: str) -> dict:
|
|
91
|
+
return _line("report", UNKNOWN, "report", f"could not report to {url}: {reason}")
|
|
@@ -62,8 +62,10 @@ SETUP_AGENTS = ("claude", "grok")
|
|
|
62
62
|
#: flag that moves (e.g. `--server` is a top-level option, not a `login` one) fails a test
|
|
63
63
|
#: rather than a person's terminal.
|
|
64
64
|
QUICKSTART_COMMANDS = (
|
|
65
|
-
"uv tool install graphban-cli",
|
|
65
|
+
"uv tool install graphban-cli && uv tool install graphban-fleet",
|
|
66
66
|
"gban --server {server} login",
|
|
67
|
+
"gban --server {server} login --pair",
|
|
68
|
+
"gban setup",
|
|
67
69
|
"gban --project {project} setup",
|
|
68
70
|
)
|
|
69
71
|
|
|
@@ -770,6 +772,40 @@ def match(projects: list[dict], here: Path) -> tuple[dict, list[dict]]:
|
|
|
770
772
|
return by_project, notes
|
|
771
773
|
|
|
772
774
|
|
|
775
|
+
def normalize_remote(raw: str) -> str:
|
|
776
|
+
"""`host/owner/name`, lowercased, no scheme, credentials, port or `.git`. The same rule as
|
|
777
|
+
backend/app/services/projects.py:normalize_repo_url (a separate distribution cannot import
|
|
778
|
+
it); tests on both sides pin the same table. "" when it does not look like a remote."""
|
|
779
|
+
value = (raw or "").strip()
|
|
780
|
+
m = re.match(r"^(?:[a-z][a-z0-9+.-]*://(?:[^@/]*@)?([^/:]+)(?::\d*)?|(?:[^@/]*@)?([^/:]+):?)/?(.+)$",
|
|
781
|
+
value, re.IGNORECASE)
|
|
782
|
+
if not m or any(ch.isspace() for ch in value):
|
|
783
|
+
return ""
|
|
784
|
+
path = m.group(3).strip("/")
|
|
785
|
+
path = path[:-4] if path.endswith(".git") else path
|
|
786
|
+
return f"{m.group(1) or m.group(2)}/{path}".lower() if "/" in path else ""
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def git_remotes(here: Path) -> list[str]:
|
|
790
|
+
"""Normalized URLs of every remote of the checkout at `here`, `origin` first. [] when it is
|
|
791
|
+
not a checkout or git is absent — which is "no signal", and falls through to the name match."""
|
|
792
|
+
try:
|
|
793
|
+
names_out = subprocess.run(["git", "-C", str(here), "remote"], capture_output=True,
|
|
794
|
+
text=True, timeout=10)
|
|
795
|
+
if names_out.returncode != 0:
|
|
796
|
+
return []
|
|
797
|
+
out = []
|
|
798
|
+
for name in sorted(names_out.stdout.split(), key=lambda n: n != "origin"):
|
|
799
|
+
got = subprocess.run(["git", "-C", str(here), "remote", "get-url", name],
|
|
800
|
+
capture_output=True, text=True, timeout=10)
|
|
801
|
+
norm = normalize_remote(got.stdout) if got.returncode == 0 else ""
|
|
802
|
+
if norm and norm not in out:
|
|
803
|
+
out.append(norm)
|
|
804
|
+
return out
|
|
805
|
+
except (OSError, subprocess.SubprocessError):
|
|
806
|
+
return []
|
|
807
|
+
|
|
808
|
+
|
|
773
809
|
class Unresolved(Exception):
|
|
774
810
|
"""The directory does not name a project on this deployment (GRPH-795)."""
|
|
775
811
|
|
|
@@ -792,6 +828,15 @@ def resolve_project(client, here: Path, asked: str = "", stored: str = "") -> tu
|
|
|
792
828
|
return asked, "named"
|
|
793
829
|
rows = client.call("GET", "/api/projects")
|
|
794
830
|
projects = [p for p in (rows if isinstance(rows, list) else []) if isinstance(p, dict)]
|
|
831
|
+
# The git remote is the stronger signal: a directory can be renamed, a remote names the repo.
|
|
832
|
+
remotes = git_remotes(here)
|
|
833
|
+
by_remote = [p for p in projects if str(p.get("repo_url") or "") in remotes]
|
|
834
|
+
if len(by_remote) == 1:
|
|
835
|
+
return str(by_remote[0].get("id") or ""), f"matched the git remote {by_remote[0].get('repo_url')!r}"
|
|
836
|
+
if len(by_remote) > 1:
|
|
837
|
+
raise Unresolved(
|
|
838
|
+
f"this checkout's remote matches several projects "
|
|
839
|
+
f"({', '.join(sorted(str(p.get('id')) for p in by_remote))}), so none was chosen — name one with --project")
|
|
795
840
|
hits = [p for p in projects if slug(here.name) in names(p)]
|
|
796
841
|
if len(hits) == 1:
|
|
797
842
|
return str(hits[0].get("id") or ""), f"matched the directory name {here.name!r}"
|
|
@@ -15,6 +15,10 @@ Take one item, leave a receipt, hand it to review. Reading the repo is `graphban
|
|
|
15
15
|
2. Fleet: `claim_cluster`. Solo: `claim_next`. `claimed: false` names who holds the area and when it frees. That is the answer. Take different work.
|
|
16
16
|
3. `get_item_details` once. The `brief` suggests `lane` and `tier` with a basis. You still choose both if you hand it to a child.
|
|
17
17
|
4. `heartbeat(id, status=<one line>, files=[the paths you are in])` on the lease interval. A heartbeat is not a `fleet_status` poll.
|
|
18
|
+
Heartbeat before any command that can run longer than the lease: a Postgres suite or `pnpm test` prints nothing, and a silent command drops the claim around 150 seconds.
|
|
19
|
+
After you change paths, `describe_code` those paths so the next child does not grep for them.
|
|
20
|
+
A review-ready receipt is a test line: an `update_item` evidence entry of kind `test` with passed and failed counts.
|
|
21
|
+
Finishing the item is `graphban-prove`.
|
|
18
22
|
5. `update_item` as you go. Evidence appends. Touchpoints union: send the files you actually touched. An empty touchpoint list writes nothing. Status goes to `review`.
|
|
19
23
|
6. Stopping unfinished: `release_item`. A blocker that is still true tomorrow is status `blocked` with `blocker` set. `backlog` is claimable and will be handed out again. A build claim released without `to_status` goes back to `next`.
|
|
20
24
|
|
|
@@ -22,7 +26,9 @@ Test scope follows the project's mode, which your delegation brief states. `agen
|
|
|
22
26
|
|
|
23
27
|
A product decision the item and its PRD framing do not already state: file a wait and stop. `create_item` with tag `wait:decision`, `status=blocked`, this item's `prd_id`, and a description that is the question plus what each answer would make you do. `link_items` with `a` this item, `b` the wait, `type` `dependency`, `reason` `wait:decision`. Leave this item `blocked`. Leave the wait blocked. Answering it is `graphban-answer`.
|
|
24
28
|
|
|
25
|
-
A broken build
|
|
29
|
+
A broken build is either still this item (move it back to `next` with the failure named), or a
|
|
30
|
+
new ticket this item depends on — `create_item`, `link_items` with `type=dependency`, and
|
|
31
|
+
`blocker` set to that item's id. A free-text `blocker` alone is not a wait and is not claimable.
|
|
26
32
|
|
|
27
33
|
Signing off is `graphban-review`. The builder does not set `done`.
|
|
28
34
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""`gban login --pair` signs in without a password prompt or a tty (GRPH-1366)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from gban import cli as cli_mod, config
|
|
7
|
+
from gban.cli import main
|
|
8
|
+
from gban.client import Refused
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture()
|
|
12
|
+
def home(tmp_path, monkeypatch):
|
|
13
|
+
monkeypatch.setenv(config.HOME_ENV, str(tmp_path))
|
|
14
|
+
for var in (config.URL_ENV, config.PROJECT_ENV, config.API_KEY_ENV):
|
|
15
|
+
monkeypatch.delenv(var, raising=False)
|
|
16
|
+
monkeypatch.setattr(cli_mod.time, "sleep", lambda *_: None)
|
|
17
|
+
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: False)
|
|
18
|
+
monkeypatch.setattr(cli_mod, "authenticated", lambda url, act="": type(
|
|
19
|
+
"C", (), {"call": lambda self, *a, **k: []})())
|
|
20
|
+
return tmp_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
START = {"device_code": "dev", "user_code": "ABCD-EFGH", "expires_in": 600, "interval": 0}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_pair_prints_code_then_stores_the_approvers_session(home, monkeypatch, capsys):
|
|
27
|
+
answers = iter([{"status": "pending"},
|
|
28
|
+
{"status": "approved", "refresh_token": "r", "access_token": "a",
|
|
29
|
+
"email": "approver@example.com"}])
|
|
30
|
+
monkeypatch.setattr(cli_mod, "pair_start", lambda url: START)
|
|
31
|
+
monkeypatch.setattr(cli_mod, "pair_poll", lambda url, dev: next(answers))
|
|
32
|
+
assert main(["--server", "http://gb.invalid", "login", "--pair"]) == 0
|
|
33
|
+
err = capsys.readouterr().err
|
|
34
|
+
assert "ABCD-EFGH" in err
|
|
35
|
+
assert config.session()["refresh_token"] == "r"
|
|
36
|
+
assert config.session()["user"] == "approver@example.com"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_pair_refused_code_stores_nothing(home, monkeypatch):
|
|
40
|
+
monkeypatch.setattr(cli_mod, "pair_start", lambda url: START)
|
|
41
|
+
|
|
42
|
+
def boom(url, dev):
|
|
43
|
+
raise Refused(400, "expired")
|
|
44
|
+
monkeypatch.setattr(cli_mod, "pair_poll", boom)
|
|
45
|
+
assert main(["--server", "http://gb.invalid", "login", "--pair"]) == 1
|
|
46
|
+
assert not config.session().get("refresh_token")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Machine reports from `gban doctor` / `gban setup` (GRPH-1442)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from gban import config, doctor, machine
|
|
7
|
+
from gban.cli import main
|
|
8
|
+
from gban.client import NoSession, Unreachable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture()
|
|
12
|
+
def home(tmp_path, monkeypatch):
|
|
13
|
+
monkeypatch.setenv(config.HOME_ENV, str(tmp_path))
|
|
14
|
+
monkeypatch.setenv("CLAUDE_CONFIG_PATH", str(tmp_path / "claude.json"))
|
|
15
|
+
monkeypatch.setenv("GROK_CONFIG_PATH", str(tmp_path / "grok" / "config.toml"))
|
|
16
|
+
monkeypatch.chdir(tmp_path)
|
|
17
|
+
for var in (config.URL_ENV, config.PROJECT_ENV, config.API_KEY_ENV, doctor.SUPERVISOR_KEY_ENV):
|
|
18
|
+
monkeypatch.delenv(var, raising=False)
|
|
19
|
+
config.save_settings(url="http://gb.invalid", project="core")
|
|
20
|
+
return tmp_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Rec:
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.calls = []
|
|
26
|
+
|
|
27
|
+
def call(self, method, path, body=None):
|
|
28
|
+
self.calls.append((method, path, body))
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _doctor(monkeypatch, lines, code, extra=()):
|
|
33
|
+
monkeypatch.setattr("gban.cli.doctor_mod.run", lambda *a, **k: (list(lines), code))
|
|
34
|
+
monkeypatch.setattr("gban.cli.doctor_mod.resolve_supervisor_key", lambda *a, **k: "")
|
|
35
|
+
return main(["doctor", *extra])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_machine_id_is_stable_and_wiping_home_makes_a_new_one(home):
|
|
39
|
+
first = machine.machine_id()
|
|
40
|
+
assert first == machine.machine_id()
|
|
41
|
+
(home / machine.MACHINE_FILE).unlink()
|
|
42
|
+
assert machine.machine_id() != first
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_doctor_posts_its_lines_with_the_machine_id(home, monkeypatch):
|
|
46
|
+
rec = _Rec()
|
|
47
|
+
monkeypatch.setattr("gban.machine.authenticated", lambda url, act="": rec)
|
|
48
|
+
code = _doctor(monkeypatch, [doctor._line("ledger", "PASS", "server", "ok"),
|
|
49
|
+
doctor._line("local", "FAIL", "gbfleet", "bad")], 1)
|
|
50
|
+
assert code == 1
|
|
51
|
+
(method, path, body), = rec.calls
|
|
52
|
+
assert (method, path) == ("POST", "/api/machines/report")
|
|
53
|
+
assert body["machine_id"] == machine.machine_id() and body["project"] == "core"
|
|
54
|
+
assert [l["status"] for l in body["lines"]] == ["PASS", "FAIL"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_no_report_sends_nothing(home, monkeypatch):
|
|
58
|
+
rec = _Rec()
|
|
59
|
+
monkeypatch.setattr("gban.machine.authenticated", lambda url, act="": rec)
|
|
60
|
+
assert _doctor(monkeypatch, [doctor._line("ledger", "PASS", "server")], 0, ["--no-report"]) == 0
|
|
61
|
+
assert rec.calls == []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.parametrize("error", [Unreachable("could not reach http://gb.invalid: down"),
|
|
65
|
+
NoSession("no stored session")])
|
|
66
|
+
def test_an_undeliverable_report_prints_unknown_and_keeps_the_exit_code(home, monkeypatch, capsys, error):
|
|
67
|
+
"""Sabotage: block the server. The command still exits on its own checks."""
|
|
68
|
+
def boom(url, act=""):
|
|
69
|
+
raise error
|
|
70
|
+
monkeypatch.setattr("gban.machine.authenticated", boom)
|
|
71
|
+
code = _doctor(monkeypatch, [doctor._line("ledger", "PASS", "server")], 0)
|
|
72
|
+
out = capsys.readouterr().out
|
|
73
|
+
assert code == 0
|
|
74
|
+
assert "UNKNOWN" in out and "could not report to http://gb.invalid" in out
|
|
75
|
+
code = _doctor(monkeypatch, [doctor._line("ledger", "FAIL", "session")], 1)
|
|
76
|
+
assert code == 1 # the report's own failure never turns a FAIL into success
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_undelivered_line_is_unknown_never_pass(home):
|
|
80
|
+
line = machine._unreported("http://s", "why")
|
|
81
|
+
assert line["status"] == "UNKNOWN" and line["detail"] == "could not report to http://s: why"
|
|
@@ -1210,6 +1210,69 @@ def test_setup_refuses_to_write_a_key_that_would_not_be_ignored(tmp_path, wired,
|
|
|
1210
1210
|
assert any("would not be gitignored" in l["detail"] for l in lines)
|
|
1211
1211
|
|
|
1212
1212
|
|
|
1213
|
+
# ---- resolving by git remote first (GRPH-1365) ---------------------------------------------------
|
|
1214
|
+
|
|
1215
|
+
import subprocess as _sp
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _checkout(path, remote):
|
|
1219
|
+
path.mkdir()
|
|
1220
|
+
_sp.run(["git", "-C", str(path), "init", "-q"], check=True)
|
|
1221
|
+
_sp.run(["git", "-C", str(path), "remote", "add", "origin", remote], check=True)
|
|
1222
|
+
return path
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
@pytest.mark.parametrize("raw,want", [
|
|
1226
|
+
("https://github.com/Acme/App.git", "github.com/acme/app"),
|
|
1227
|
+
("git@github.com:acme/app", "github.com/acme/app"),
|
|
1228
|
+
("ssh://git@github.com:22/acme/app/", "github.com/acme/app"),
|
|
1229
|
+
("github.com/acme/app", "github.com/acme/app"),
|
|
1230
|
+
("git@github.com:1password/cli.git", "github.com/1password/cli"),
|
|
1231
|
+
("https://github.com/1password/cli", "github.com/1password/cli"),
|
|
1232
|
+
("ssh://git@github.com/37signals/x", "github.com/37signals/x"),
|
|
1233
|
+
("git@github.com:37signals/x", "github.com/37signals/x"),
|
|
1234
|
+
("https://user:tok@gitlab.com/a/b/c.git", "gitlab.com/a/b/c"),
|
|
1235
|
+
("app", ""), ("", ""),
|
|
1236
|
+
])
|
|
1237
|
+
def test_normalize_remote_table_matches_the_backend(raw, want):
|
|
1238
|
+
assert setup_mod.normalize_remote(raw) == want
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def test_a_matching_remote_picks_the_project_even_when_names_differ(tmp_path):
|
|
1242
|
+
here = _checkout(tmp_path / "checkout-7", "git@github.com:Acme/App.git")
|
|
1243
|
+
rows = [{"id": "core", "name": "Core", "repo_url": "github.com/acme/app"},
|
|
1244
|
+
{"id": "checkout-7", "name": "Decoy", "repo_url": "github.com/other/thing"}]
|
|
1245
|
+
|
|
1246
|
+
pid, how = setup_mod.resolve_project(Listing(rows), here)
|
|
1247
|
+
|
|
1248
|
+
assert pid == "core" and "git remote" in how
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def test_no_matching_remote_falls_back_to_the_name(tmp_path):
|
|
1252
|
+
here = _checkout(tmp_path / "super-arc", "git@github.com:acme/unrelated.git")
|
|
1253
|
+
rows = [{"id": "super-arc", "name": "Super Arc", "repo_url": "github.com/acme/app"}]
|
|
1254
|
+
|
|
1255
|
+
pid, how = setup_mod.resolve_project(Listing(rows), here)
|
|
1256
|
+
|
|
1257
|
+
assert pid == "super-arc" and "directory name" in how
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def test_a_remote_matching_two_projects_is_refused(tmp_path):
|
|
1261
|
+
here = _checkout(tmp_path / "x", "https://github.com/acme/app")
|
|
1262
|
+
rows = [{"id": "a", "name": "A", "repo_url": "github.com/acme/app"},
|
|
1263
|
+
{"id": "b", "name": "B", "repo_url": "github.com/acme/app"}]
|
|
1264
|
+
|
|
1265
|
+
with pytest.raises(setup_mod.Unresolved):
|
|
1266
|
+
setup_mod.resolve_project(Listing(rows), here)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def test_a_project_without_repo_url_never_matches_an_empty_remote(tmp_path):
|
|
1270
|
+
here = tmp_path / "plain"
|
|
1271
|
+
here.mkdir() # not a checkout: no remotes
|
|
1272
|
+
with pytest.raises(setup_mod.Unresolved):
|
|
1273
|
+
setup_mod.resolve_project(Listing([{"id": "a", "name": "A"}]), here)
|
|
1274
|
+
|
|
1275
|
+
|
|
1213
1276
|
def test_web_agent_list_is_generated_from_setup_agents():
|
|
1214
1277
|
"""The quick-start picker's gban-route list (GRPH-1362 / G9) is exported, not copied."""
|
|
1215
1278
|
import importlib.util
|
|
@@ -1245,6 +1308,50 @@ def test_quickstart_commands_parse_with_the_cli_parser():
|
|
|
1245
1308
|
assert ns.project == "core"
|
|
1246
1309
|
|
|
1247
1310
|
|
|
1311
|
+
def _uv_tool_install_packages(command: str) -> list[list[str]]:
|
|
1312
|
+
"""The positional arguments of each `uv tool install` in a `&&`-chained line.
|
|
1313
|
+
|
|
1314
|
+
`uv tool install [OPTIONS] <PACKAGE>` takes exactly one package; a second positional
|
|
1315
|
+
exits with "unexpected argument". Options (`--with x`, `-p 3.12`) are skipped with
|
|
1316
|
+
their value so only real positionals are counted."""
|
|
1317
|
+
import shlex
|
|
1318
|
+
|
|
1319
|
+
takes_value = {"--with", "-w", "--python", "-p", "--from", "--index", "--index-url"}
|
|
1320
|
+
out = []
|
|
1321
|
+
for part in command.split("&&"):
|
|
1322
|
+
argv = shlex.split(part)
|
|
1323
|
+
assert argv[:3] == ["uv", "tool", "install"], part
|
|
1324
|
+
pos, i = [], 3
|
|
1325
|
+
while i < len(argv):
|
|
1326
|
+
a = argv[i]
|
|
1327
|
+
if a.startswith("-"):
|
|
1328
|
+
i += 2 if a in takes_value else 1
|
|
1329
|
+
continue
|
|
1330
|
+
pos.append(a)
|
|
1331
|
+
i += 1
|
|
1332
|
+
out.append(pos)
|
|
1333
|
+
return out
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def test_quickstart_install_line_installs_both_packages_one_per_uv_call():
|
|
1337
|
+
"""Bounced once: `uv tool install graphban-cli graphban-fleet` exits 2 — uv takes one
|
|
1338
|
+
package per install. The line must install both tools, each in its own `uv tool install`."""
|
|
1339
|
+
from gban.setup import QUICKSTART_COMMANDS
|
|
1340
|
+
|
|
1341
|
+
installs = [c for c in QUICKSTART_COMMANDS if c.startswith("uv ")]
|
|
1342
|
+
assert len(installs) == 1, "the dialog shows exactly one install line"
|
|
1343
|
+
packages = _uv_tool_install_packages(installs[0])
|
|
1344
|
+
assert all(len(p) == 1 for p in packages), f"one package per uv tool install: {packages}"
|
|
1345
|
+
assert sorted(p[0] for p in packages) == ["graphban-cli", "graphban-fleet"]
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def test_the_bounced_install_line_is_rejected_by_the_check():
|
|
1349
|
+
"""Sabotage for the test above: the bounced string must fail it, or it proves nothing."""
|
|
1350
|
+
packages = _uv_tool_install_packages("uv tool install graphban-cli graphban-fleet")
|
|
1351
|
+
assert packages == [["graphban-cli", "graphban-fleet"]]
|
|
1352
|
+
assert not all(len(p) == 1 for p in packages)
|
|
1353
|
+
|
|
1354
|
+
|
|
1248
1355
|
def test_the_old_subcommand_flag_order_is_still_rejected():
|
|
1249
1356
|
"""Sabotage for the test above: the bounced strings must fail to parse, or it proves nothing."""
|
|
1250
1357
|
import pytest
|
|
@@ -1254,3 +1361,22 @@ def test_the_old_subcommand_flag_order_is_still_rejected():
|
|
|
1254
1361
|
for argv in (["login", "--server", "https://x"], ["setup", "--project", "core"]):
|
|
1255
1362
|
with pytest.raises(SystemExit):
|
|
1256
1363
|
_parser().parse_args(argv)
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def test_graphban_work_names_the_lease_the_graph_and_prove():
|
|
1367
|
+
"""The work skill must warn that a silent long command drops the lease, send
|
|
1368
|
+
changed paths to describe_code, call a receipt a test line, and hand
|
|
1369
|
+
finishing to graphban-prove."""
|
|
1370
|
+
body = " ".join(setup_mod.skill_source("graphban-work").read_text(encoding="utf-8").split())
|
|
1371
|
+
assert "Heartbeat before any command that can run longer than the lease" in body
|
|
1372
|
+
assert "`describe_code` those paths" in body
|
|
1373
|
+
assert "A review-ready receipt is a test line" in body
|
|
1374
|
+
assert "Finishing the item is `graphban-prove`" in body
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def test_the_image_manifest_lists_the_shipped_skills():
|
|
1378
|
+
"""backend/scripts/gban_skills.json is a checked-in copy (the image has no cli/)."""
|
|
1379
|
+
root = Path(__file__).resolve().parents[2]
|
|
1380
|
+
manifest = json.loads((root / "backend/scripts/gban_skills.json").read_text())
|
|
1381
|
+
shipped = {p.name for p in (root / "cli/src/gban/skills").iterdir() if p.is_dir()}
|
|
1382
|
+
assert set(manifest["skills"]) == shipped
|
|
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
|