autopilot-code 2.1.1 → 2.2.1
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.
package/dist/cli.js
CHANGED
|
@@ -538,9 +538,16 @@ program
|
|
|
538
538
|
});
|
|
539
539
|
program
|
|
540
540
|
.command("status")
|
|
541
|
-
.description("Show
|
|
542
|
-
.
|
|
543
|
-
|
|
541
|
+
.description("Show active issues and their current state")
|
|
542
|
+
.option("--service", "Show systemd service status instead of issues")
|
|
543
|
+
.action((opts) => {
|
|
544
|
+
if (opts.service) {
|
|
545
|
+
statusSystemdService();
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
const statusScriptPath = node_path_1.default.join(repoRoot, "scripts", "issue_status.py");
|
|
549
|
+
run("python3", [statusScriptPath]);
|
|
550
|
+
}
|
|
544
551
|
});
|
|
545
552
|
program
|
|
546
553
|
.command("logs")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autopilot-code",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Repo-issue–driven autopilot runner",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@changesets/cli": "^2.29.8",
|
|
31
|
-
"@types/node": "^22.
|
|
31
|
+
"@types/node": "^22.19.7",
|
|
32
32
|
"typescript": "^5.7.2"
|
|
33
33
|
}
|
|
34
34
|
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Show autopilot status for active issues."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
STATE_DIR = ".autopilot"
|
|
13
|
+
STATE_FILE = "state.json"
|
|
14
|
+
DEFAULT_MAX_DEPTH = 3
|
|
15
|
+
IGNORED_DIRS = {
|
|
16
|
+
"node_modules",
|
|
17
|
+
".venv",
|
|
18
|
+
"venv",
|
|
19
|
+
".env",
|
|
20
|
+
"env",
|
|
21
|
+
".git",
|
|
22
|
+
".idea",
|
|
23
|
+
".vscode",
|
|
24
|
+
"dist",
|
|
25
|
+
"build",
|
|
26
|
+
"target",
|
|
27
|
+
"bin",
|
|
28
|
+
"obj",
|
|
29
|
+
"__pycache__",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sh(cmd: list[str], cwd: Path | None = None, check: bool = True) -> str:
|
|
34
|
+
p = subprocess.run(
|
|
35
|
+
cmd,
|
|
36
|
+
cwd=str(cwd) if cwd else None,
|
|
37
|
+
text=True,
|
|
38
|
+
stdout=subprocess.PIPE,
|
|
39
|
+
stderr=subprocess.STDOUT,
|
|
40
|
+
)
|
|
41
|
+
if check and p.returncode != 0:
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
f"command failed ({p.returncode}): {' '.join(cmd)}\n{p.stdout}"
|
|
44
|
+
)
|
|
45
|
+
return p.stdout
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_git_repo(dir_path: Path) -> bool:
|
|
49
|
+
return (dir_path / ".git").exists()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_repo_name(dir: Path) -> Optional[str]:
|
|
53
|
+
try:
|
|
54
|
+
res = subprocess.run(
|
|
55
|
+
["git", "remote", "get-url", "origin"],
|
|
56
|
+
cwd=str(dir),
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
check=True,
|
|
60
|
+
)
|
|
61
|
+
url = res.stdout.strip()
|
|
62
|
+
|
|
63
|
+
if "@github.com:" in url:
|
|
64
|
+
return url.split("@github.com:")[1].replace(".git", "")
|
|
65
|
+
|
|
66
|
+
if "github.com/" in url:
|
|
67
|
+
return url.split("github.com/")[1].replace(".git", "")
|
|
68
|
+
|
|
69
|
+
return None
|
|
70
|
+
except Exception:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config(repo_root: Path) -> Optional[dict[str, Any]]:
|
|
75
|
+
cfg_path = repo_root / ".autopilot" / "autopilot.json"
|
|
76
|
+
if not cfg_path.exists():
|
|
77
|
+
return None
|
|
78
|
+
data = json.loads(cfg_path.read_text(encoding="utf8"))
|
|
79
|
+
if not data.get("enabled", False):
|
|
80
|
+
return None
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def discover_repos(
|
|
85
|
+
root: Path, max_depth: int = DEFAULT_MAX_DEPTH
|
|
86
|
+
) -> list[dict[str, Any]]:
|
|
87
|
+
out: list[dict[str, Any]] = []
|
|
88
|
+
_discover_repos_recursive(root, 0, max_depth, out)
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _discover_repos_recursive(
|
|
93
|
+
current_dir: Path, current_depth: int, max_depth: int, out: list[dict[str, Any]]
|
|
94
|
+
) -> None:
|
|
95
|
+
if current_depth > max_depth:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if (current_dir / ".git").exists():
|
|
99
|
+
if (current_dir / ".autopilot" / "autopilot.json").exists():
|
|
100
|
+
try:
|
|
101
|
+
cfg = load_config(current_dir)
|
|
102
|
+
if cfg:
|
|
103
|
+
out.append(
|
|
104
|
+
{"root": current_dir, "repo": cfg["repo"], "config": cfg}
|
|
105
|
+
)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
print(f"Error loading config in {current_dir}: {e}", file=sys.stderr)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
for child in sorted(current_dir.iterdir(), key=lambda p: p.name.lower()):
|
|
112
|
+
if not child.is_dir():
|
|
113
|
+
continue
|
|
114
|
+
if child.name in IGNORED_DIRS or child.name.startswith("."):
|
|
115
|
+
continue
|
|
116
|
+
_discover_repos_recursive(child, current_depth + 1, max_depth, out)
|
|
117
|
+
except (PermissionError, OSError):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_state_comment(repo: str, issue_number: int) -> Optional[dict[str, Any]]:
|
|
122
|
+
try:
|
|
123
|
+
result = sh(
|
|
124
|
+
[
|
|
125
|
+
"gh",
|
|
126
|
+
"issue",
|
|
127
|
+
"view",
|
|
128
|
+
str(issue_number),
|
|
129
|
+
"--repo",
|
|
130
|
+
repo,
|
|
131
|
+
"--json",
|
|
132
|
+
"comments",
|
|
133
|
+
"--jq",
|
|
134
|
+
'.comments[] | select(.body | contains("<!-- autopilot-state"))',
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if not result.strip():
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
comments = json.loads("[" + result + "]")
|
|
142
|
+
for comment in comments:
|
|
143
|
+
body = comment.get("body", "")
|
|
144
|
+
if "<!-- autopilot-state" in body:
|
|
145
|
+
import re
|
|
146
|
+
|
|
147
|
+
match = re.search(
|
|
148
|
+
r"<!-- autopilot-state\s*\n({.*?})\s*\n-->", body, re.DOTALL
|
|
149
|
+
)
|
|
150
|
+
if match:
|
|
151
|
+
return json.loads(match.group(1))
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_pr_info(repo: str, branch: str) -> Optional[dict[str, Any]]:
|
|
158
|
+
try:
|
|
159
|
+
cmd = [
|
|
160
|
+
"gh",
|
|
161
|
+
"pr",
|
|
162
|
+
"list",
|
|
163
|
+
"--repo",
|
|
164
|
+
repo,
|
|
165
|
+
"--head",
|
|
166
|
+
branch,
|
|
167
|
+
"--json",
|
|
168
|
+
"number,state,mergeable,mergeStateStatus,statusCheckRollup",
|
|
169
|
+
"--limit",
|
|
170
|
+
"1",
|
|
171
|
+
]
|
|
172
|
+
raw = sh(cmd, check=False)
|
|
173
|
+
prs = json.loads(raw)
|
|
174
|
+
if prs:
|
|
175
|
+
return prs[0]
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def format_duration(seconds: int) -> str:
|
|
182
|
+
hours = seconds // 3600
|
|
183
|
+
minutes = (seconds % 3600) // 60
|
|
184
|
+
secs = seconds % 60
|
|
185
|
+
|
|
186
|
+
if hours > 0:
|
|
187
|
+
return f"{hours}h {minutes}m {secs}s"
|
|
188
|
+
if minutes > 0:
|
|
189
|
+
return f"{minutes}m {secs}s"
|
|
190
|
+
return f"{secs}s"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_step_from_label(labels: list[str]) -> Optional[str]:
|
|
194
|
+
for label in labels:
|
|
195
|
+
if label.startswith("autopilot:") and label != "autopilot:in-progress":
|
|
196
|
+
return label
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def display_status(repo_statuses: dict[str, list[dict[str, Any]]]) -> None:
|
|
201
|
+
if not repo_statuses:
|
|
202
|
+
print("No active issues found.")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
for repo, issues in repo_statuses.items():
|
|
206
|
+
print(f"\nStatus for {repo}")
|
|
207
|
+
|
|
208
|
+
if not issues:
|
|
209
|
+
print(" No active issues")
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
for issue in issues:
|
|
213
|
+
print(f"\n#{issue['number']} - {issue['title']}")
|
|
214
|
+
print(f" State: {issue.get('state', 'unknown')}")
|
|
215
|
+
|
|
216
|
+
step_label = issue.get("step_label")
|
|
217
|
+
if step_label:
|
|
218
|
+
step_name = step_label.replace("autopilot:", "")
|
|
219
|
+
step_name = step_name.replace("-", " ").title()
|
|
220
|
+
print(f" Step: {step_name}")
|
|
221
|
+
|
|
222
|
+
duration = issue.get("duration", 0)
|
|
223
|
+
if duration > 0:
|
|
224
|
+
print(f" Duration: {format_duration(duration)}")
|
|
225
|
+
|
|
226
|
+
branch = issue.get("branch")
|
|
227
|
+
if branch:
|
|
228
|
+
print(f" Branch: {branch}")
|
|
229
|
+
|
|
230
|
+
pr_info = issue.get("pr_info")
|
|
231
|
+
if pr_info:
|
|
232
|
+
pr_num = pr_info.get("number")
|
|
233
|
+
state = pr_info.get("state", "unknown")
|
|
234
|
+
mergeable = pr_info.get("mergeable", "unknown")
|
|
235
|
+
|
|
236
|
+
if mergeable == "CONFLICTING":
|
|
237
|
+
mergeable = "conflicts"
|
|
238
|
+
elif mergeable == "MERGEABLE":
|
|
239
|
+
mergeable = "ready"
|
|
240
|
+
|
|
241
|
+
checks = pr_info.get("statusCheckRollup", [])
|
|
242
|
+
if isinstance(checks, dict):
|
|
243
|
+
check_status = checks.get("state", "unknown")
|
|
244
|
+
elif checks:
|
|
245
|
+
passing = sum(1 for c in checks if c.get("conclusion") == "SUCCESS")
|
|
246
|
+
failing = sum(1 for c in checks if c.get("conclusion") == "FAILURE")
|
|
247
|
+
pending = sum(
|
|
248
|
+
1
|
|
249
|
+
for c in checks
|
|
250
|
+
if c.get("conclusion") in ("PENDING", "QUEUED", None)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if failing > 0:
|
|
254
|
+
check_status = f"{passing} passing, {failing} failing"
|
|
255
|
+
elif pending > 0:
|
|
256
|
+
check_status = f"{passing} passing, {pending} pending"
|
|
257
|
+
else:
|
|
258
|
+
check_status = f"{passing} passing"
|
|
259
|
+
else:
|
|
260
|
+
check_status = "none"
|
|
261
|
+
|
|
262
|
+
print(
|
|
263
|
+
f" PR: #{pr_num} (state: {state}, mergeable: {mergeable}, checks: {check_status})"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def main() -> int:
|
|
268
|
+
ap = argparse.ArgumentParser()
|
|
269
|
+
ap.add_argument("--root", nargs="+", default=None, help="Root folders to scan")
|
|
270
|
+
args = ap.parse_args()
|
|
271
|
+
|
|
272
|
+
cwd = Path.cwd()
|
|
273
|
+
repo_configs = []
|
|
274
|
+
|
|
275
|
+
if is_git_repo(cwd):
|
|
276
|
+
repo_name = get_repo_name(cwd)
|
|
277
|
+
if repo_name:
|
|
278
|
+
cfg = load_config(cwd)
|
|
279
|
+
if cfg:
|
|
280
|
+
repo_configs.append({"root": cwd, "repo": repo_name, "config": cfg})
|
|
281
|
+
print(f"Showing status for current repo: {repo_name}")
|
|
282
|
+
else:
|
|
283
|
+
root_paths = args.root if args.root else [Path.home()]
|
|
284
|
+
for root_str in root_paths:
|
|
285
|
+
root = Path(root_str).resolve()
|
|
286
|
+
if root.exists() and root.is_dir():
|
|
287
|
+
repo_configs.extend(discover_repos(root))
|
|
288
|
+
|
|
289
|
+
if not repo_configs:
|
|
290
|
+
print("No autopilot-enabled repos found.")
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
repo_statuses: dict[str, list[dict[str, Any]]] = {}
|
|
294
|
+
now = int(time.time())
|
|
295
|
+
|
|
296
|
+
for repo_cfg in repo_configs:
|
|
297
|
+
repo = repo_cfg["repo"]
|
|
298
|
+
repo_root = repo_cfg["root"]
|
|
299
|
+
config = repo_cfg.get("config", {})
|
|
300
|
+
|
|
301
|
+
labels_config = config.get("issueLabels", {})
|
|
302
|
+
in_progress_label = labels_config.get("inProgress", "autopilot:in-progress")
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
cmd = [
|
|
306
|
+
"gh",
|
|
307
|
+
"issue",
|
|
308
|
+
"list",
|
|
309
|
+
"--repo",
|
|
310
|
+
repo,
|
|
311
|
+
"--limit",
|
|
312
|
+
"50",
|
|
313
|
+
"--search",
|
|
314
|
+
f"is:open label:{in_progress_label}",
|
|
315
|
+
"--json",
|
|
316
|
+
"number,title,labels,updatedAt,createdAt",
|
|
317
|
+
]
|
|
318
|
+
raw = sh(cmd)
|
|
319
|
+
issues = json.loads(raw)
|
|
320
|
+
|
|
321
|
+
repo_issues = []
|
|
322
|
+
for issue in issues:
|
|
323
|
+
issue_num = issue["number"]
|
|
324
|
+
|
|
325
|
+
labels = [l["name"] for l in issue.get("labels", [])]
|
|
326
|
+
step_label = get_step_from_label(labels)
|
|
327
|
+
|
|
328
|
+
state_comment = get_state_comment(repo, issue_num)
|
|
329
|
+
branch = None
|
|
330
|
+
if state_comment:
|
|
331
|
+
branch = state_comment.get("branch")
|
|
332
|
+
|
|
333
|
+
pr_info = None
|
|
334
|
+
if branch:
|
|
335
|
+
pr_info = get_pr_info(repo, branch)
|
|
336
|
+
|
|
337
|
+
updated_at = issue.get("updatedAt", "")
|
|
338
|
+
if updated_at:
|
|
339
|
+
from datetime import datetime
|
|
340
|
+
|
|
341
|
+
dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
|
342
|
+
duration = now - int(dt.timestamp())
|
|
343
|
+
else:
|
|
344
|
+
duration = 0
|
|
345
|
+
|
|
346
|
+
repo_issues.append(
|
|
347
|
+
{
|
|
348
|
+
"number": issue_num,
|
|
349
|
+
"title": issue["title"],
|
|
350
|
+
"state": step_label or in_progress_label,
|
|
351
|
+
"step_label": step_label,
|
|
352
|
+
"duration": duration,
|
|
353
|
+
"branch": branch,
|
|
354
|
+
"pr_info": pr_info,
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
repo_statuses[repo] = repo_issues
|
|
359
|
+
except Exception as e:
|
|
360
|
+
print(f"Error fetching status for {repo}: {e}", file=sys.stderr)
|
|
361
|
+
|
|
362
|
+
display_status(repo_statuses)
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
raise SystemExit(main())
|
|
Binary file
|
|
Binary file
|