ground-control-cli 0.2.0__tar.gz → 0.4.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.2.0 → ground_control_cli-0.4.0}/PKG-INFO +1 -1
- ground_control_cli-0.4.0/actions.py +271 -0
- ground_control_cli-0.4.0/claude.py +436 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/cli.py +225 -15
- ground_control_cli-0.4.0/hooks.py +522 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/machine.py +17 -3
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/__init__.py +11 -1
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/code/bash.py +5 -22
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/code/javascript.py +15 -18
- ground_control_cli-0.4.0/probes/deploy.py +261 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/git.py +153 -37
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/health.py +58 -2
- ground_control_cli-0.4.0/probes/request.py +262 -0
- ground_control_cli-0.4.0/probes/workspace.py +341 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/pyproject.toml +10 -2
- ground_control_cli-0.4.0/queue.py +556 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/service.py +192 -16
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/system/__init__.py +6 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/system/macos.py +9 -0
- ground_control_cli-0.4.0/tools.py +232 -0
- ground_control_cli-0.2.0/claude.py +0 -240
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/.gitignore +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/__init__.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/connect.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/code/__init__.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/code/javascript.js +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/code/python.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/errors.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/gh.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/settings.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/probes/tests.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/repos.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.4.0}/update.py +0 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""The actions: what the method changes on the developer's machine, each with its fixed command.
|
|
2
|
+
|
|
3
|
+
The service names an action from the closed set (app/method/facts.py, ACTIONS) with typed values;
|
|
4
|
+
the client runs it here with its own command, so the service never sends a command line. Git and
|
|
5
|
+
gh run in the repo, as the developer, with their own access. An action that cannot be done says
|
|
6
|
+
which of the probes' errors stopped it, never what a command printed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .probes import errors, request, workspace
|
|
17
|
+
from .probes.git import main_checkout, worktree
|
|
18
|
+
|
|
19
|
+
MAIN = "main"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Failed(Exception):
|
|
23
|
+
"""A command failed; `error` is which of errors.ERRORS it stands for."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, error: str):
|
|
26
|
+
"""Keep the error."""
|
|
27
|
+
super().__init__(error)
|
|
28
|
+
self.error = error
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run(tool: str, root, *args, data: str | None = None) -> str:
|
|
32
|
+
"""Run git or gh in the folder; its output, or Failed with what its failure stands for."""
|
|
33
|
+
try:
|
|
34
|
+
out = subprocess.run([tool, *args], cwd=root, input=data, capture_output=True, text=True)
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
raise Failed(errors.TOOL_MISSING) from None
|
|
37
|
+
if out.returncode != 0:
|
|
38
|
+
raise Failed(errors.failed(out.stderr))
|
|
39
|
+
return out.stdout.strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _git(root, *args) -> str:
|
|
43
|
+
return _run("git", root, *args)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _gh(root, *args, data: str | None = None) -> str:
|
|
47
|
+
return _run("gh", root, *args, data=data)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _done(work: Callable[[], dict | None]) -> dict:
|
|
51
|
+
try:
|
|
52
|
+
return work() or {}
|
|
53
|
+
except Failed as e:
|
|
54
|
+
return {"error": e.error}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def worktree_add(params: dict, root) -> dict:
|
|
58
|
+
"""git.worktree_add: the branch and its worktree, beside the repo in `<repo>.worktrees`."""
|
|
59
|
+
main = main_checkout(root)
|
|
60
|
+
branch, base = params["branch"], params.get("base") or MAIN
|
|
61
|
+
tree = main.parent / f"{main.name}.worktrees" / branch
|
|
62
|
+
|
|
63
|
+
def work():
|
|
64
|
+
if _git_ok(main, "rev-parse", "--verify", "-q", f"refs/heads/{branch}"):
|
|
65
|
+
raise Failed(errors.REFUSED)
|
|
66
|
+
_git(main, "worktree", "add", "-q", "-b", branch, str(tree), base)
|
|
67
|
+
return {"path": str(tree)}
|
|
68
|
+
|
|
69
|
+
return _done(work)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _git_ok(root, *args) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
_git(root, *args)
|
|
75
|
+
except Failed:
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def worktree_remove(params: dict, root) -> dict:
|
|
81
|
+
"""git.worktree_remove: the branch's worktree, when it has one; the main checkout's folder."""
|
|
82
|
+
main, branch = main_checkout(root), params["branch"]
|
|
83
|
+
|
|
84
|
+
def work():
|
|
85
|
+
tree = worktree(main, branch)
|
|
86
|
+
if tree is not None and tree != main:
|
|
87
|
+
_git(main, "worktree", "remove", "--force", str(tree))
|
|
88
|
+
return {"path": str(main)}
|
|
89
|
+
|
|
90
|
+
return _done(work)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def branch_delete(params: dict, root) -> dict:
|
|
94
|
+
"""git.branch_delete: the local branch, unless it is gone or the main checkout's."""
|
|
95
|
+
main, branch = main_checkout(root), params["branch"]
|
|
96
|
+
|
|
97
|
+
def work():
|
|
98
|
+
if not _git_ok(main, "rev-parse", "--verify", "-q", f"refs/heads/{branch}"):
|
|
99
|
+
return
|
|
100
|
+
if worktree(main, branch) == main:
|
|
101
|
+
return
|
|
102
|
+
_git(main, "branch", "-D", branch)
|
|
103
|
+
|
|
104
|
+
return _done(work)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fetch(params: dict, root) -> dict:
|
|
108
|
+
"""git.fetch: from origin, everything, main only, or the tags."""
|
|
109
|
+
what = params.get("what") or "all"
|
|
110
|
+
args = {"all": (), "main": ("main",), "tags": ("--tags",)}[what]
|
|
111
|
+
return _done(lambda: _git(root, "fetch", "-q", "origin", *args) and None)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def tag(params: dict, root) -> dict:
|
|
115
|
+
"""git.tag: tag the commit and push the tag to origin."""
|
|
116
|
+
|
|
117
|
+
def work():
|
|
118
|
+
_git(root, "tag", params["tag"], params["commit"])
|
|
119
|
+
_git(root, "push", "-q", "origin", f"refs/tags/{params['tag']}")
|
|
120
|
+
|
|
121
|
+
return _done(work)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def delete_remote_branch(params: dict, root) -> dict:
|
|
125
|
+
"""git.delete_remote_branch: the branch on origin."""
|
|
126
|
+
return _done(lambda: _git(root, "push", "-q", "origin", "--delete", params["branch"]) and None)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def move_onto_main(params: dict, root) -> dict:
|
|
130
|
+
"""git.move_onto_main: the branch checked out onto origin's main: fast-forward, or reset."""
|
|
131
|
+
if params["method"] == "merge":
|
|
132
|
+
return _done(lambda: _git(root, "merge", "-q", "--ff-only", "origin/main") and None)
|
|
133
|
+
return _done(lambda: _git(root, "reset", "-q", "--hard", "origin/main") and None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def comment(params: dict, root) -> dict:
|
|
137
|
+
"""gh.comment: comment on the request, as the developer; the comment's address."""
|
|
138
|
+
|
|
139
|
+
def work():
|
|
140
|
+
out = _gh(root, "issue", "comment", params["url"], "--body-file", "-", data=params["text"])
|
|
141
|
+
links = [line.strip() for line in out.splitlines() if line.strip().startswith("https://")]
|
|
142
|
+
if not links:
|
|
143
|
+
raise Failed(errors.REFUSED)
|
|
144
|
+
return {"url": links[-1]}
|
|
145
|
+
|
|
146
|
+
return _done(work)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def label(params: dict, root) -> dict:
|
|
150
|
+
"""gh.label: add a label to the request."""
|
|
151
|
+
url, name = params["url"], params["label"]
|
|
152
|
+
return _done(lambda: _gh(root, "issue", "edit", url, "--add-label", name) and None)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def close(params: dict, root) -> dict:
|
|
156
|
+
"""gh.close: close the request."""
|
|
157
|
+
return _done(lambda: _gh(root, "issue", "close", params["url"]) and None)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def issue(params: dict, root) -> dict:
|
|
161
|
+
"""gh.issue: open an issue in the repo, as the developer; its address."""
|
|
162
|
+
|
|
163
|
+
def work():
|
|
164
|
+
out = _gh(
|
|
165
|
+
root,
|
|
166
|
+
"issue",
|
|
167
|
+
"create",
|
|
168
|
+
"--title",
|
|
169
|
+
params["title"],
|
|
170
|
+
"--body-file",
|
|
171
|
+
"-",
|
|
172
|
+
data=params["body"],
|
|
173
|
+
)
|
|
174
|
+
return {"url": out.splitlines()[-1].strip()}
|
|
175
|
+
|
|
176
|
+
return _done(work)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _title(params: dict, root) -> str:
|
|
180
|
+
"""The PR's title: the one given, else the request's own, read here."""
|
|
181
|
+
if params.get("title"):
|
|
182
|
+
return params["title"]
|
|
183
|
+
found = request.read_title(params["request"], root)
|
|
184
|
+
if not found:
|
|
185
|
+
raise Failed(errors.NOT_FOUND)
|
|
186
|
+
return found
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def pr_open(params: dict, root) -> dict:
|
|
190
|
+
"""gh.pr_open: push the branch and open its PR, or update the body of the one open for it."""
|
|
191
|
+
branch, base = params["branch"], params["base"]
|
|
192
|
+
body = Path(params["body_file"]).expanduser()
|
|
193
|
+
if not body.is_absolute():
|
|
194
|
+
body = Path(root).resolve() / body
|
|
195
|
+
if not body.is_file():
|
|
196
|
+
return {"no_body": True}
|
|
197
|
+
|
|
198
|
+
def work():
|
|
199
|
+
if branch == base:
|
|
200
|
+
raise Failed(errors.REFUSED)
|
|
201
|
+
_git(root, "push", "-q", "-u", "origin", f"refs/heads/{branch}:refs/heads/{branch}")
|
|
202
|
+
try:
|
|
203
|
+
found = json.loads(_gh(root, "pr", "view", branch, "--json", "url,state") or "{}")
|
|
204
|
+
except Failed:
|
|
205
|
+
found = {}
|
|
206
|
+
if found.get("state") == "OPEN":
|
|
207
|
+
_gh(root, "pr", "edit", found["url"], "--body-file", str(body))
|
|
208
|
+
return {"url": found["url"], "opened": False}
|
|
209
|
+
title = _title(params, root) + (params.get("suffix") or "")
|
|
210
|
+
out = _gh(
|
|
211
|
+
root,
|
|
212
|
+
"pr",
|
|
213
|
+
"create",
|
|
214
|
+
"--base",
|
|
215
|
+
base,
|
|
216
|
+
"--head",
|
|
217
|
+
branch,
|
|
218
|
+
"--title",
|
|
219
|
+
title,
|
|
220
|
+
"--body-file",
|
|
221
|
+
str(body),
|
|
222
|
+
)
|
|
223
|
+
return {"url": out.splitlines()[-1].strip(), "opened": True}
|
|
224
|
+
|
|
225
|
+
return _done(work)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def pr_merge(params: dict, root) -> dict:
|
|
229
|
+
"""gh.pr_merge: merge the PR, by merge or squash."""
|
|
230
|
+
return _done(lambda: _gh(root, "pr", "merge", params["url"], f"--{params['method']}") and None)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Every action the client runs: its name -> the function of (params, root) that runs it.
|
|
234
|
+
RUNS: dict[str, Callable[[dict, object], dict]] = {
|
|
235
|
+
"git.worktree_add": worktree_add,
|
|
236
|
+
"git.worktree_remove": worktree_remove,
|
|
237
|
+
"git.branch_delete": branch_delete,
|
|
238
|
+
"git.fetch": fetch,
|
|
239
|
+
"git.tag": tag,
|
|
240
|
+
"git.delete_remote_branch": delete_remote_branch,
|
|
241
|
+
"git.move_onto_main": move_onto_main,
|
|
242
|
+
"gh.comment": comment,
|
|
243
|
+
"gh.label": label,
|
|
244
|
+
"gh.close": close,
|
|
245
|
+
"gh.issue": issue,
|
|
246
|
+
"gh.pr_open": pr_open,
|
|
247
|
+
"gh.pr_merge": pr_merge,
|
|
248
|
+
"workspace.record": workspace.record,
|
|
249
|
+
"workspace.put_back": workspace.put_back,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run(name: str, params: dict, root) -> dict:
|
|
254
|
+
"""Run one action and return what it did, as plain values.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
name: The action, one of RUNS.
|
|
258
|
+
params: Its values.
|
|
259
|
+
root: Where on the machine it runs.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
What it did: the fields of its result.
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
ValueError: If the client runs no action of that name.
|
|
266
|
+
|
|
267
|
+
Company: assumes none.
|
|
268
|
+
"""
|
|
269
|
+
if name not in RUNS:
|
|
270
|
+
raise ValueError(f"the client runs no action `{name}`; it runs {', '.join(RUNS)}")
|
|
271
|
+
return RUNS[name](dict(params or {}), root)
|