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 the status of the autopilot systemd service")
542
- .action(() => {
543
- statusSystemdService();
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.1.1",
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.10.2",
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())