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/probes/gh.py ADDED
@@ -0,0 +1,126 @@
1
+ """The gh probes: a pull request, and its checks once none is pending or the wait ends.
2
+
3
+ Each runs the `gh` CLI in the repo and returns the fact as plain values, the fields of its facts
4
+ in app/method/facts.py. A gh that is missing or fails says only which error stopped it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import subprocess
11
+
12
+ from . import errors
13
+
14
+ PR_FIELDS = "headRefOid,state,mergeable,mergeCommit"
15
+ CHECK_FIELDS = "name,bucket,startedAt,completedAt"
16
+
17
+
18
+ class Failed(Exception):
19
+ """A gh command failed; `error` is which of errors.ERRORS it stands for."""
20
+
21
+ def __init__(self, error: str):
22
+ """Keep the error."""
23
+ super().__init__(error)
24
+ self.error = error
25
+
26
+
27
+ def _gh(root, *args, timeout=None) -> subprocess.CompletedProcess:
28
+ """Run gh in the repo; Failed with tool_missing when gh is not installed."""
29
+ try:
30
+ return subprocess.run(
31
+ ["gh", *args], cwd=root, capture_output=True, text=True, timeout=timeout
32
+ )
33
+ except FileNotFoundError:
34
+ raise Failed(errors.TOOL_MISSING) from None
35
+
36
+
37
+ def _json(out: subprocess.CompletedProcess):
38
+ """Gh's JSON output; Failed with the error its failure stands for."""
39
+ if out.returncode != 0:
40
+ raise Failed(errors.failed(out.stderr))
41
+ try:
42
+ return json.loads(out.stdout or "null")
43
+ except json.JSONDecodeError:
44
+ raise Failed(errors.REFUSED) from None
45
+
46
+
47
+ def pr(params: dict, root) -> dict:
48
+ """gh.pr: a pull request's head, state, mergeability and merge commit.
49
+
50
+ Args:
51
+ params: {"url"}.
52
+ root: The repo, where gh runs.
53
+
54
+ Returns:
55
+ The PrFacts fields.
56
+
57
+ Company: assumes none.
58
+ """
59
+ try:
60
+ found = _json(_gh(root, "pr", "view", params["url"], "--json", PR_FIELDS))
61
+ except Failed as e:
62
+ return {"error": e.error}
63
+ if not isinstance(found, dict):
64
+ return {"error": errors.REFUSED}
65
+ merged = found.get("mergeCommit")
66
+ return {
67
+ "head": found.get("headRefOid") or None,
68
+ "state": found.get("state") or None,
69
+ "mergeable": found.get("mergeable") or None,
70
+ "merge_commit": (merged.get("oid") if isinstance(merged, dict) else None) or None,
71
+ }
72
+
73
+
74
+ def _listed(url: str, root, *flags) -> list:
75
+ """The PR's checks as gh lists them; none when gh says there are none."""
76
+ out = _gh(root, "pr", "checks", url, *flags, "--json", CHECK_FIELDS)
77
+ if out.returncode != 0 and "no " in out.stderr and "checks reported" in out.stderr:
78
+ return []
79
+ found = _json(out)
80
+ if not isinstance(found, list):
81
+ raise Failed(errors.REFUSED)
82
+ return [c for c in found if isinstance(c, dict) and c.get("name")]
83
+
84
+
85
+ def checks(params: dict, root) -> dict:
86
+ """gh.checks: the PR's checks, after waiting up to wait_seconds for none to be pending.
87
+
88
+ The wait is `gh pr checks --watch`, ended when the time is up; how it exits is not read,
89
+ since it exits non-zero when a check failed. Whether a check is required is read with
90
+ `--required`.
91
+
92
+ Args:
93
+ params: {"url", "wait_seconds"}.
94
+ root: The repo, where gh runs.
95
+
96
+ Returns:
97
+ The ChecksFacts fields, the checks sorted by name; waited_out when checks were still
98
+ pending as the wait ended.
99
+
100
+ Company: assumes none.
101
+ """
102
+ url, wait = params["url"], params.get("wait_seconds") or 0
103
+ waited_out = False
104
+ try:
105
+ if wait > 0:
106
+ try:
107
+ _gh(root, "pr", "checks", url, "--watch", timeout=wait)
108
+ except subprocess.TimeoutExpired:
109
+ waited_out = True
110
+ found = _listed(url, root)
111
+ required = {c["name"] for c in _listed(url, root, "--required")}
112
+ except Failed as e:
113
+ return {"waited_out": waited_out, "error": e.error}
114
+ return {
115
+ "checks": [
116
+ {
117
+ "name": c["name"],
118
+ "bucket": c.get("bucket") or "",
119
+ "required": c["name"] in required,
120
+ "started_at": c.get("startedAt") or None,
121
+ "completed_at": c.get("completedAt") or None,
122
+ }
123
+ for c in sorted(found, key=lambda c: c["name"])
124
+ ],
125
+ "waited_out": waited_out,
126
+ }
client/probes/git.py ADDED
@@ -0,0 +1,280 @@
1
+ """The git probes: a branch and its worktree, commits, what changed, and whether two commits merge.
2
+
3
+ Each runs git in the repo and returns the fact as plain values, the fields of its facts in
4
+ app/method/facts.py. A git that fails says only which error stopped it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from . import errors, settings
15
+
16
+ CONFIG = ".claude/method.json"
17
+ # A hunk's header in `git diff -U0`: where its lines start on the new side, and how many.
18
+ HUNK_RE = re.compile(r"^@@ -\S+ \+(\d+)(?:,(\d+))? @@")
19
+
20
+
21
+ class Failed(Exception):
22
+ """A git command failed; `error` is which of errors.ERRORS it stands for."""
23
+
24
+ def __init__(self, error: str):
25
+ """Keep the error."""
26
+ super().__init__(error)
27
+ self.error = error
28
+
29
+
30
+ def _git(root, *args) -> subprocess.CompletedProcess:
31
+ """Run git in the repo; Failed with tool_missing when git is not installed."""
32
+ try:
33
+ return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
34
+ except FileNotFoundError:
35
+ raise Failed(errors.TOOL_MISSING) from None
36
+
37
+
38
+ def _out(root, *args) -> str:
39
+ """Git's output; Failed with the error its failure stands for."""
40
+ out = _git(root, *args)
41
+ if out.returncode != 0:
42
+ raise Failed(errors.failed(out.stderr))
43
+ return out.stdout
44
+
45
+
46
+ def main_checkout(root) -> Path:
47
+ """The repo's main checkout: the folder of git's common .git, wherever root is."""
48
+ root = Path(root).resolve()
49
+ out = _git(root, "rev-parse", "--git-common-dir")
50
+ common = out.stdout.strip() if out.returncode == 0 else ""
51
+ if not common:
52
+ return root
53
+ common = (root / common).resolve()
54
+ return common.parent if common.name == ".git" else root
55
+
56
+
57
+ def logs_dir(root) -> Path:
58
+ """The method's log folder: METHOD_LOGS; else `logs` in the repo's config; else _logs beside.
59
+
60
+ Args:
61
+ root: A folder inside the repo, the main checkout or a linked worktree.
62
+
63
+ Returns:
64
+ The folder, resolved.
65
+
66
+ Company: assumes none.
67
+ """
68
+ found = settings.logs()
69
+ if found:
70
+ return found
71
+ main = main_checkout(root)
72
+ try:
73
+ config = json.loads((main / CONFIG).read_text(encoding="utf-8"))
74
+ except (OSError, json.JSONDecodeError):
75
+ config = {}
76
+ logs = config.get("logs") if isinstance(config, dict) else None
77
+ if logs:
78
+ return (main / Path(logs).expanduser()).resolve()
79
+ return (main.parent / "_logs").resolve()
80
+
81
+
82
+ def _inside(path: Path, folder: Path) -> bool:
83
+ return path == folder or folder in path.parents
84
+
85
+
86
+ def worktree(root, branch: str) -> Path | None:
87
+ """The folder where the branch is checked out, or None."""
88
+ folder = None
89
+ for line in _out(root, "worktree", "list", "--porcelain").splitlines():
90
+ if line.startswith("worktree "):
91
+ folder = Path(line[len("worktree ") :])
92
+ elif line == f"branch refs/heads/{branch}" and folder is not None:
93
+ return folder.resolve()
94
+ return None
95
+
96
+
97
+ def branch(params: dict, root) -> dict:
98
+ """git.branch: whether the branch exists, its worktree, and its paths there.
99
+
100
+ Args:
101
+ params: {"name", "paths"}.
102
+ root: The repo.
103
+
104
+ Returns:
105
+ The BranchFacts fields.
106
+
107
+ Company: assumes none.
108
+ """
109
+ name, paths = params["name"], list(params.get("paths") or [])
110
+ try:
111
+ if _git(root, "rev-parse", "--verify", "-q", f"refs/heads/{name}").returncode != 0:
112
+ return {"exists": False}
113
+ tree = worktree(root, name)
114
+ if tree is None:
115
+ return {"exists": True}
116
+ found = []
117
+ for path in paths:
118
+ present = (tree / path).is_file()
119
+ last = (
120
+ _out(tree, "log", "-1", "--format=%H", name, "--", path).strip() if present else ""
121
+ )
122
+ found.append({"path": path, "present": present, "last_commit": last or None})
123
+ return {
124
+ "exists": True,
125
+ "worktree": str(tree),
126
+ "main_checkout": tree == main_checkout(root),
127
+ "paths": found,
128
+ }
129
+ except Failed as e:
130
+ return {"exists": False, "error": e.error}
131
+
132
+
133
+ def _sha(root, *args) -> dict:
134
+ """The sha git prints, or None when it names none."""
135
+ try:
136
+ out = _git(root, *args)
137
+ except Failed as e:
138
+ return {"sha": None, "error": e.error}
139
+ sha = out.stdout.strip() if out.returncode == 0 else ""
140
+ return {"sha": sha or None}
141
+
142
+
143
+ def commit(params: dict, root) -> dict:
144
+ """git.commit: the full sha a ref names, or None.
145
+
146
+ Args:
147
+ params: {"ref"}.
148
+ root: The repo.
149
+
150
+ Returns:
151
+ The CommitFacts fields.
152
+
153
+ Company: assumes none.
154
+ """
155
+ return _sha(root, "rev-parse", "--verify", "-q", f"{params.get('ref') or 'HEAD'}^{{commit}}")
156
+
157
+
158
+ def merge_base(params: dict, root) -> dict:
159
+ """git.merge_base: the best common ancestor of two commits, or None.
160
+
161
+ Args:
162
+ params: {"a", "b"}.
163
+ root: The repo.
164
+
165
+ Returns:
166
+ The CommitFacts fields.
167
+
168
+ Company: assumes none.
169
+ """
170
+ try:
171
+ out = _git(root, "merge-base", params.get("a") or "main", params.get("b") or "HEAD")
172
+ except Failed as e:
173
+ return {"sha": None, "error": e.error}
174
+ if out.returncode == 0:
175
+ return {"sha": out.stdout.strip() or None}
176
+ if out.returncode == 1 and not out.stderr.strip():
177
+ return {"sha": None}
178
+ return {"sha": None, "error": errors.failed(out.stderr)}
179
+
180
+
181
+ def _untracked(root) -> list:
182
+ return [n for n in _out(root, "ls-files", "--others", "--exclude-standard").splitlines() if n]
183
+
184
+
185
+ def changed(params: dict, root) -> dict:
186
+ """git.changed: the files that differ from a commit, and the untracked ones git keeps.
187
+
188
+ The method's own log folder is left out, wherever it is.
189
+
190
+ Args:
191
+ params: {"since"}.
192
+ root: The repo.
193
+
194
+ Returns:
195
+ The ChangedFacts fields.
196
+
197
+ Company: assumes none.
198
+ """
199
+ try:
200
+ tracked = _out(root, "diff", "--name-only", params["since"], "--").splitlines()
201
+ untracked = _untracked(root)
202
+ except Failed as e:
203
+ return {"error": e.error}
204
+ logs = logs_dir(root)
205
+ keep = [n for n in dict.fromkeys(tracked + untracked) if n]
206
+ keep = [n for n in keep if not _inside((Path(root) / n).resolve(), logs)]
207
+ new = set(untracked)
208
+ return {
209
+ "changed": sorted(n for n in keep if n not in new),
210
+ "untracked": sorted(n for n in keep if n in new),
211
+ }
212
+
213
+
214
+ def lines(params: dict, root) -> dict:
215
+ """git.lines: the changed lines of each file against a commit, untracked files whole.
216
+
217
+ The lines are the working tree's. A deletion marks the lines on both sides of it.
218
+
219
+ Args:
220
+ params: {"since"}.
221
+ root: The repo.
222
+
223
+ Returns:
224
+ The LinesFacts fields.
225
+
226
+ Company: assumes none.
227
+ """
228
+ found, current = {}, None
229
+ try:
230
+ diff = _out(root, "diff", "-U0", "--no-color", "--no-renames", params["since"], "--")
231
+ untracked = _untracked(root)
232
+ except Failed as e:
233
+ return {"error": e.error}
234
+ for line in diff.splitlines():
235
+ if line.startswith("+++ "):
236
+ current = line[6:] if line.startswith("+++ b/") else None
237
+ elif current and (hunk := HUNK_RE.match(line)):
238
+ start, count = int(hunk.group(1)), int(hunk.group(2) or 1)
239
+ marked = range(start, start + count) if count else (start, start + 1)
240
+ found.setdefault(current, set()).update(marked)
241
+ for name in untracked:
242
+ target = Path(root) / name
243
+ if target.is_file():
244
+ total = len(target.read_text(encoding="utf-8", errors="replace").splitlines())
245
+ found[name] = set(range(1, total + 1))
246
+ return {
247
+ "files": [
248
+ {"path": name, "lines": sorted(found[name])} for name in sorted(found) if found[name]
249
+ ]
250
+ }
251
+
252
+
253
+ def conflicts(params: dict, root) -> dict:
254
+ """git.conflicts: whether two commits merge, tried in git's object store only.
255
+
256
+ `git merge-tree --write-tree` writes objects only: no working tree, branch or index is
257
+ touched. A conflict prints the merged tree, then the conflicting paths; a commit that is not
258
+ there prints nothing.
259
+
260
+ Args:
261
+ params: {"a", "b"}.
262
+ root: The repo.
263
+
264
+ Returns:
265
+ The ConflictFacts fields.
266
+
267
+ Company: assumes none.
268
+ """
269
+ a, b = params.get("a") or "main", params.get("b") or "HEAD"
270
+ try:
271
+ out = _git(root, "merge-tree", "--write-tree", "--name-only", "--no-messages", a, b)
272
+ except Failed as e:
273
+ return {"error": e.error}
274
+ if out.returncode == 0:
275
+ return {"conflicts": False}
276
+ if out.returncode == 1 and out.stdout.strip():
277
+ return {"conflicts": True, "paths": sorted(set(out.stdout.splitlines()[1:]) - {""})}
278
+ if "not something we can merge" in out.stderr:
279
+ return {"error": errors.NOT_FOUND}
280
+ return {"error": errors.failed(out.stderr)}
@@ -0,0 +1,128 @@
1
+ """The health probe: what the repo's running environment answers, after bringing it up or down.
2
+
3
+ `environment` in the repo's .claude/method.json is `{"up", "down", "health", "timeout"}`: the
4
+ command that brings the branch up, the one that takes it down, a local URL that says the
5
+ environment is healthy, and how many seconds to wait (settings.ENVIRONMENT_TIMEOUT when not
6
+ given). The service names only what to do; the commands and the address are the repo's own.
7
+ Commands run through the shell in the repo root, in their own session, and are not waited on:
8
+ `up` may keep running as the environment itself.
9
+
10
+ Health answers when it gives any HTTP reply; it does not answer when there is no reply at all.
11
+ It is healthy when the reply is 2xx.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import subprocess
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ from pathlib import Path
22
+
23
+ from . import errors, settings
24
+
25
+ CONFIG = ".claude/method.json"
26
+ KEY = "environment"
27
+ # The keys each way of bringing needs.
28
+ NEEDS = {"none": ("health",), "up": ("up", "health"), "down": ("down", "health")}
29
+
30
+
31
+ def status(health: str):
32
+ """The HTTP status health replies with, or None when nothing replies."""
33
+ try:
34
+ with urllib.request.urlopen(health, timeout=settings.ENVIRONMENT_PROBE) as reply:
35
+ return reply.status
36
+ except urllib.error.HTTPError as e:
37
+ return e.code
38
+ except (urllib.error.URLError, OSError, ValueError):
39
+ return None
40
+
41
+
42
+ def healthy(code) -> bool:
43
+ """Whether a status is 2xx; no reply is not healthy."""
44
+ return code is not None and 200 <= code < 300
45
+
46
+
47
+ def _config(root, keys) -> tuple:
48
+ """`environment` from the repo's config, and the error that stops reading it, or None."""
49
+ try:
50
+ path = Path(root) / CONFIG
51
+ found = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
52
+ except (OSError, json.JSONDecodeError):
53
+ return {}, errors.UNREADABLE
54
+ env = found.get(KEY) if isinstance(found, dict) else None
55
+ if not isinstance(env, dict):
56
+ return {}, errors.NO_COMMAND
57
+ if not all(isinstance(env.get(k), str) and env[k].strip() for k in keys):
58
+ return env, errors.NO_COMMAND
59
+ timeout = env.get("timeout", settings.ENVIRONMENT_TIMEOUT)
60
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
61
+ return env, errors.UNREADABLE
62
+ return env, None
63
+
64
+
65
+ def _start(command: str, root) -> subprocess.Popen:
66
+ """Start a command through the shell in its own session, without waiting on it."""
67
+ return subprocess.Popen(
68
+ command,
69
+ shell=True,
70
+ cwd=root,
71
+ stdin=subprocess.DEVNULL,
72
+ stdout=subprocess.DEVNULL,
73
+ stderr=subprocess.DEVNULL,
74
+ start_new_session=True,
75
+ )
76
+
77
+
78
+ def _wait(until, seconds: float, started) -> str | None:
79
+ """Ask until `until()` holds; the error that ended the wait, or None when it held."""
80
+ end = time.monotonic() + seconds
81
+ while True:
82
+ if until():
83
+ return None
84
+ if started is not None and started.poll():
85
+ return errors.COMMAND_FAILED
86
+ if time.monotonic() >= end:
87
+ return errors.TIMED_OUT
88
+ time.sleep(settings.ENVIRONMENT_POLL)
89
+
90
+
91
+ def health(params: dict, root) -> dict:
92
+ """health: what the health address answers, bringing the environment up or down first.
93
+
94
+ bring=up runs `up` unless something already answers, then waits for 2xx; bring=down runs
95
+ `down`, then waits until nothing answers; bring=none only asks.
96
+
97
+ Args:
98
+ params: {"bring"}: none, up or down.
99
+ root: The repo, whose config names the commands and the address.
100
+
101
+ Returns:
102
+ The HealthFacts fields.
103
+
104
+ Company: assumes none.
105
+ """
106
+ bring = params.get("bring") or "none"
107
+ env, error = _config(root, NEEDS[bring])
108
+ if error:
109
+ return {"error": error}
110
+ address = env["health"]
111
+ before = status(address)
112
+ if bring == "none":
113
+ return {"before": before, "after": before}
114
+ timeout = env.get("timeout", settings.ENVIRONMENT_TIMEOUT)
115
+
116
+ def until() -> bool:
117
+ code = status(address)
118
+ return healthy(code) if bring == "up" else code is None
119
+
120
+ if bring == "up":
121
+ started = _start(env["up"], root) if before is None else None
122
+ else:
123
+ started = _start(env["down"], root)
124
+ start = time.monotonic()
125
+ error = _wait(until, timeout, started)
126
+ waited = timeout if error == errors.TIMED_OUT else round(time.monotonic() - start, 2)
127
+ found = {"before": before, "after": status(address), "seconds": waited}
128
+ return dict(found, error=error) if error else found
@@ -0,0 +1,72 @@
1
+ """The client's own technical numbers, each read once from the environment, with its default.
2
+
3
+ The same variables the server's settings name for the method (app/config.py, .env.example), read
4
+ here because the client imports nothing of the server. A value is a number of seconds or an ISO
5
+ 8601 duration, such as PT60S, as .env.example writes them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+
14
+ DURATION_RE = re.compile(
15
+ r"^P(?:(?P<days>\d+(?:\.\d+)?)D)?"
16
+ r"(?:T(?:(?P<hours>\d+(?:\.\d+)?)H)?(?:(?P<minutes>\d+(?:\.\d+)?)M)?"
17
+ r"(?:(?P<seconds>\d+(?:\.\d+)?)S)?)?$"
18
+ )
19
+ SECONDS_IN = {"days": 86400, "hours": 3600, "minutes": 60, "seconds": 1}
20
+
21
+
22
+ def seconds(name: str, default: float) -> float:
23
+ """A duration from the environment, in seconds.
24
+
25
+ Args:
26
+ name: The variable, such as METHOD_ENVIRONMENT_TIMEOUT.
27
+ default: The seconds when it is unset, empty or not a duration.
28
+
29
+ Returns:
30
+ The seconds.
31
+
32
+ Company: assumes none.
33
+ """
34
+ value = os.environ.get(name, "").strip()
35
+ if not value:
36
+ return default
37
+ try:
38
+ return float(value)
39
+ except ValueError:
40
+ pass
41
+ found = DURATION_RE.match(value.upper())
42
+ if not found or value.upper() in ("P", "PT"):
43
+ return default
44
+ return sum(float(v) * SECONDS_IN[k] for k, v in found.groupdict().items() if v)
45
+
46
+
47
+ def method_data() -> Path:
48
+ """METHOD_DATA, the method's own folder on this machine, where the readers' tools are.
49
+
50
+ Read each time it is asked for, so a change to the environment counts.
51
+
52
+ Returns:
53
+ The folder, resolved; ~/.method when METHOD_DATA is unset, as app/config.py has it.
54
+
55
+ Company: assumes none.
56
+ """
57
+ value = os.environ.get("METHOD_DATA", "").strip() or "~/.method"
58
+ return Path(value).expanduser().resolve()
59
+
60
+
61
+ def logs() -> Path | None:
62
+ """METHOD_LOGS, the folder that holds the method's log, or None when unset."""
63
+ value = os.environ.get("METHOD_LOGS", "").strip()
64
+ return Path(value).expanduser().resolve() if value else None
65
+
66
+
67
+ # How long to wait for the environment to come up or down, unless the repo says otherwise.
68
+ ENVIRONMENT_TIMEOUT = seconds("METHOD_ENVIRONMENT_TIMEOUT", 60)
69
+ # How often to ask whether the environment is up.
70
+ ENVIRONMENT_POLL = seconds("METHOD_ENVIRONMENT_POLL", 0.25)
71
+ # The longest one health question may take.
72
+ ENVIRONMENT_PROBE = seconds("METHOD_ENVIRONMENT_PROBE", 2)