autopilot-code 2.1.0 → 2.2.0

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "enabled": true,
3
3
  "repo": "bakkensoftware/autopilot",
4
- "agent": "opencode",
4
+ "agent": "opencode-server",
5
5
  "autoMerge": true,
6
6
  "mergeMethod": "squash",
7
7
  "allowedMergeUsers": [
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.0",
3
+ "version": "2.2.0",
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
  }
@@ -183,11 +183,63 @@ class GitHubStateManager:
183
183
  except (json.JSONDecodeError, KeyError, TypeError):
184
184
  return None
185
185
 
186
+ def _create_label(self, label_name: str) -> bool:
187
+ """
188
+ Create a label in the GitHub repository.
189
+
190
+ Returns True if created or already exists, False on failure.
191
+ """
192
+ # Define standard labels with colors and descriptions
193
+ label_defs = {
194
+ "autopilot:todo": {"color": "7057ff", "description": "Ready to be picked up by autopilot"},
195
+ "autopilot:in-progress": {"color": "0075ca", "description": "Claimed by autopilot"},
196
+ "autopilot:blocked": {"color": "d93f0b", "description": "Needs human input or missing heartbeat"},
197
+ "autopilot:done": {"color": "3fb950", "description": "Completed"},
198
+ "autopilot:backlog": {"color": "d4c5f9", "description": "Captured, not ready"},
199
+ "autopilot:planning": {"color": "5319e7", "description": "Creating implementation plan"},
200
+ "autopilot:implementing": {"color": "1f883d", "description": "Writing code"},
201
+ "autopilot:pr-created": {"color": "fbca04", "description": "Pull request created"},
202
+ "autopilot:waiting-checks": {"color": "0e8a16", "description": "Waiting for CI checks"},
203
+ "autopilot:fixing-checks": {"color": "cfd3d7", "description": "Fixing failing CI checks"},
204
+ "autopilot:merging": {"color": "bfd4f2", "description": "Merging pull request"},
205
+ }
206
+
207
+ if label_name not in label_defs:
208
+ print(f"Warning: Unknown label '{label_name}', skipping auto-creation", flush=True)
209
+ return False
210
+
211
+ label_info = label_defs[label_name]
212
+
213
+ try:
214
+ self._run_gh([
215
+ "api",
216
+ "--method",
217
+ "POST",
218
+ "-H",
219
+ "Accept: application/vnd.github+json",
220
+ f"repos/{self.repo}/labels",
221
+ "-f",
222
+ f"name={label_name}",
223
+ "-f",
224
+ f"color={label_info['color']}",
225
+ "-f",
226
+ f"description={label_info['description']}",
227
+ ])
228
+ print(f"✅ Auto-created missing label: {label_name}", flush=True)
229
+ return True
230
+ except RuntimeError as e:
231
+ # Label might already exist, which is fine
232
+ if "already exists" in str(e).lower() or "Label already exists" in str(e):
233
+ return True
234
+ print(f"⚠️ Failed to auto-create label '{label_name}': {e}", flush=True)
235
+ return False
236
+
186
237
  def _set_step_label(self, issue_number: int, step: IssueStep) -> None:
187
238
  """
188
- Update the autopilot step label.
239
+ Update autopilot step label.
189
240
 
190
241
  Removes any existing autopilot:* step labels and adds the new one.
242
+ Auto-creates missing labels if needed.
191
243
  """
192
244
  result = self._run_gh(
193
245
  [
@@ -220,17 +272,48 @@ class GitHubStateManager:
220
272
  )
221
273
 
222
274
  if step in STEP_LABELS:
223
- self._run_gh(
224
- [
225
- "issue",
226
- "edit",
227
- str(issue_number),
228
- "--repo",
229
- self.repo,
230
- "--add-label",
231
- STEP_LABELS[step],
232
- ]
233
- )
275
+ new_label = STEP_LABELS[step]
276
+ try:
277
+ self._run_gh(
278
+ [
279
+ "issue",
280
+ "edit",
281
+ str(issue_number),
282
+ "--repo",
283
+ self.repo,
284
+ "--add-label",
285
+ new_label,
286
+ ]
287
+ )
288
+ except RuntimeError as e:
289
+ # Check if it's a "label not found" error
290
+ error_msg = str(e)
291
+ if "not found" in error_msg.lower() or "label" in error_msg.lower():
292
+ print(f"⚠️ Label '{new_label}' not found in repo '{self.repo}'", flush=True)
293
+ print(f"🔧 Attempting to auto-create label...", flush=True)
294
+
295
+ if self._create_label(new_label):
296
+ # Retry adding the label
297
+ self._run_gh(
298
+ [
299
+ "issue",
300
+ "edit",
301
+ str(issue_number),
302
+ "--repo",
303
+ self.repo,
304
+ "--add-label",
305
+ new_label,
306
+ ]
307
+ )
308
+ else:
309
+ # Auto-creation failed, provide helpful error
310
+ raise RuntimeError(
311
+ f"Required label '{new_label}' does not exist in repository '{self.repo}'. "
312
+ f"Please run: autopilot setup-labels --repo {self.repo}"
313
+ ) from e
314
+ else:
315
+ # Some other error, re-raise
316
+ raise
234
317
 
235
318
  def _run_gh(self, args: list[str]) -> str:
236
319
  """Run a gh CLI command and return stdout."""
@@ -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())