autopilot-code 0.4.0 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autopilot-code",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -0,0 +1 @@
1
+ from .git_ops import GitOperations, GitResult, MergeStatus
@@ -1,3 +1,4 @@
1
1
  from .base import BaseAgent, AgentResult
2
+ from .opencode import OpenCodeAgent
2
3
 
3
- __all__ = ["BaseAgent", "AgentResult"]
4
+ __all__ = ["BaseAgent", "AgentResult", "OpenCodeAgent"]
@@ -0,0 +1,137 @@
1
+ import subprocess
2
+ import shutil
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from .base import BaseAgent, AgentResult
8
+
9
+
10
+ class OpenCodeAgent(BaseAgent):
11
+ """
12
+ Agent implementation for OpenCode CLI.
13
+
14
+ OpenCode is invoked via: opencode run "<prompt>"
15
+
16
+ Session support: OpenCode supports --continue flag to resume sessions.
17
+ The session ID is the conversation ID from the previous run.
18
+ """
19
+
20
+ @property
21
+ def name(self) -> str:
22
+ return "OpenCode"
23
+
24
+ @property
25
+ def supports_sessions(self) -> bool:
26
+ return True # OpenCode supports --continue
27
+
28
+ def find_binary(self) -> str:
29
+ """
30
+ Locate the opencode binary.
31
+
32
+ Search order:
33
+ 1. agentPath from config
34
+ 2. PATH
35
+ 3. Common nvm locations
36
+ 4. Other common locations
37
+ """
38
+ # 1. Config-specified path
39
+ agent_path = self.config.get("agentPath", "")
40
+ if agent_path and os.path.isfile(agent_path) and os.access(agent_path, os.X_OK):
41
+ return agent_path
42
+
43
+ # 2. Already in PATH
44
+ which_result = shutil.which("opencode")
45
+ if which_result:
46
+ return which_result
47
+
48
+ # 3. Common nvm locations
49
+ home = Path.home()
50
+ nvm_dir = home / ".nvm" / "versions" / "node"
51
+ if nvm_dir.exists():
52
+ for node_dir in nvm_dir.iterdir():
53
+ opencode_path = node_dir / "bin" / "opencode"
54
+ if opencode_path.exists() and os.access(opencode_path, os.X_OK):
55
+ return str(opencode_path)
56
+
57
+ # 4. Other common locations
58
+ common_paths = [
59
+ home / ".local" / "bin" / "opencode",
60
+ Path("/usr/local/bin/opencode"),
61
+ home / ".npm-global" / "bin" / "opencode",
62
+ ]
63
+ for path in common_paths:
64
+ if path.exists() and os.access(path, os.X_OK):
65
+ return str(path)
66
+
67
+ raise FileNotFoundError(
68
+ "opencode not found. Set 'agentPath' in autopilot.json or ensure opencode is installed."
69
+ )
70
+
71
+ def run(
72
+ self, worktree: Path, prompt: str, session_id: Optional[str] = None
73
+ ) -> AgentResult:
74
+ """
75
+ Run OpenCode with the given prompt.
76
+
77
+ Args:
78
+ worktree: Working directory for the agent
79
+ prompt: The task/prompt
80
+ session_id: Previous session ID to continue (if any)
81
+
82
+ Returns:
83
+ AgentResult with session_id for future continuation
84
+ """
85
+ cmd = [self.binary_path, "run"]
86
+
87
+ # Add session continuation if we have a previous session
88
+ if session_id and self.supports_sessions:
89
+ cmd.extend(["--continue", session_id])
90
+
91
+ cmd.append(prompt)
92
+
93
+ try:
94
+ result = subprocess.run(
95
+ cmd,
96
+ cwd=worktree,
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=1800, # 30 minute timeout
100
+ )
101
+
102
+ # Extract session ID from output if available
103
+ # OpenCode outputs session info that we can parse
104
+ new_session_id = self._extract_session_id(result.stdout) or session_id
105
+
106
+ return AgentResult(
107
+ success=result.returncode == 0,
108
+ session_id=new_session_id,
109
+ output=result.stdout,
110
+ error=result.stderr if result.returncode != 0 else None,
111
+ )
112
+ except subprocess.TimeoutExpired:
113
+ return AgentResult(
114
+ success=False,
115
+ session_id=session_id,
116
+ output="",
117
+ error="Agent timed out after 30 minutes",
118
+ )
119
+ except Exception as e:
120
+ return AgentResult(
121
+ success=False, session_id=session_id, output="", error=str(e)
122
+ )
123
+
124
+ def _extract_session_id(self, output: str) -> Optional[str]:
125
+ """
126
+ Extract session/conversation ID from OpenCode output.
127
+
128
+ This allows subsequent calls to continue the same session.
129
+ """
130
+ # TODO: Implement based on OpenCode's actual output format
131
+ # Look for patterns like "Session: abc123" or similar
132
+ import re
133
+
134
+ match = re.search(
135
+ r"(?:session|conversation)[:\s]+([a-zA-Z0-9_-]+)", output, re.IGNORECASE
136
+ )
137
+ return match.group(1) if match else None
@@ -0,0 +1,204 @@
1
+ import subprocess
2
+ import shutil
3
+ import json
4
+ from pathlib import Path
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+ from enum import Enum, auto
8
+
9
+
10
+ class MergeStatus(Enum):
11
+ MERGEABLE = auto()
12
+ CONFLICTING = auto()
13
+ UNKNOWN = auto()
14
+ BLOCKED = auto()
15
+
16
+
17
+ @dataclass
18
+ class GitResult:
19
+ success: bool
20
+ output: str
21
+ error: Optional[str] = None
22
+
23
+
24
+ class GitOperations:
25
+ def __init__(self, repo_root: Path):
26
+ self.repo_root = repo_root
27
+
28
+ def create_worktree(
29
+ self, worktree_path: Path, branch: str, base_branch: str = "main"
30
+ ) -> GitResult:
31
+ self.prune_worktrees()
32
+
33
+ if worktree_path.exists():
34
+ return GitResult(
35
+ success=True, output=f"Reusing existing worktree: {worktree_path}"
36
+ )
37
+
38
+ if self.branch_exists(branch):
39
+ return self._run_git(["worktree", "add", str(worktree_path), branch])
40
+ else:
41
+ return self._run_git(
42
+ ["worktree", "add", str(worktree_path), "-b", branch, base_branch]
43
+ )
44
+
45
+ def remove_worktree(self, worktree_path: Path) -> GitResult:
46
+ return self._run_git(["worktree", "remove", str(worktree_path)])
47
+
48
+ def prune_worktrees(self) -> GitResult:
49
+ return self._run_git(["worktree", "prune"])
50
+
51
+ def worktree_exists(self, worktree_path: Path) -> bool:
52
+ result = self._run_git(["worktree", "list"])
53
+ if not result.success:
54
+ return False
55
+ return str(worktree_path) in result.output
56
+
57
+ def branch_exists(self, branch: str, remote: bool = False) -> bool:
58
+ if remote:
59
+ result = self._run_git(["ls-remote", "--heads", "origin", branch])
60
+ else:
61
+ result = self._run_git(
62
+ ["show-ref", "--verify", f"refs/heads/{branch}"], cwd=self.repo_root
63
+ )
64
+ return result.success
65
+
66
+ def fetch(self, remote: str = "origin", branch: Optional[str] = None) -> GitResult:
67
+ args = ["fetch", remote]
68
+ if branch:
69
+ args.append(branch)
70
+ return self._run_git(args)
71
+
72
+ def push(
73
+ self,
74
+ branch: str,
75
+ remote: str = "origin",
76
+ force: bool = False,
77
+ set_upstream: bool = True,
78
+ ) -> GitResult:
79
+ args = ["push", remote, branch]
80
+ if force:
81
+ args.append("-f")
82
+ if set_upstream:
83
+ args.append("-u")
84
+ return self._run_git(args)
85
+
86
+ def has_changes(self, worktree: Path) -> bool:
87
+ result = self._run_git(["status", "--porcelain"], cwd=worktree)
88
+ return bool(result.output.strip())
89
+
90
+ def stage_all(self, worktree: Path) -> GitResult:
91
+ return self._run_git(["add", "-A"], cwd=worktree)
92
+
93
+ def commit(self, worktree: Path, message: str) -> GitResult:
94
+ return self._run_git(["commit", "-m", message], cwd=worktree)
95
+
96
+ def get_commit_sha(self, worktree: Path, short: bool = True) -> Optional[str]:
97
+ args = ["rev-parse"]
98
+ if short:
99
+ args.append("--short")
100
+ args.append("HEAD")
101
+ result = self._run_git(args, cwd=worktree)
102
+ if result.success:
103
+ return result.output.strip()
104
+ return None
105
+
106
+ def get_changed_files_count(self, worktree: Path) -> int:
107
+ result = self._run_git(
108
+ ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"], cwd=worktree
109
+ )
110
+ if result.success:
111
+ return len([line for line in result.output.splitlines() if line.strip()])
112
+ return 0
113
+
114
+ def rebase(self, worktree: Path, onto: str = "origin/main") -> GitResult:
115
+ return self._run_git(["rebase", onto], cwd=worktree)
116
+
117
+ def abort_rebase(self, worktree: Path) -> GitResult:
118
+ return self._run_git(["rebase", "--abort"], cwd=worktree)
119
+
120
+ def merge(self, worktree: Path, branch: str = "origin/main") -> GitResult:
121
+ return self._run_git(["merge", branch], cwd=worktree)
122
+
123
+ def abort_merge(self, worktree: Path) -> GitResult:
124
+ return self._run_git(["merge", "--abort"], cwd=worktree)
125
+
126
+ def has_conflicts(self, worktree: Path) -> bool:
127
+ result = self._run_git(["diff", "--name-only", "--diff-filter=U"], cwd=worktree)
128
+ if result.success:
129
+ return bool(result.output.strip())
130
+ return False
131
+
132
+ def get_conflicted_files(self, worktree: Path) -> list[str]:
133
+ result = self._run_git(["diff", "--name-only", "--diff-filter=U"], cwd=worktree)
134
+ if result.success:
135
+ return [line.strip() for line in result.output.splitlines() if line.strip()]
136
+ return []
137
+
138
+ def is_merge_in_progress(self, worktree: Path) -> bool:
139
+ merge_head = worktree / ".git" / "MERGE_HEAD"
140
+ if merge_head.exists():
141
+ return True
142
+ rebase_merge = worktree / ".git" / "rebase-merge"
143
+ rebase_apply = worktree / ".git" / "rebase-apply"
144
+ return rebase_merge.exists() or rebase_apply.exists()
145
+
146
+ def get_pr_merge_status(self, repo: str, branch: str) -> MergeStatus:
147
+ result = self._run_gh(
148
+ [
149
+ "pr",
150
+ "view",
151
+ "--repo",
152
+ repo,
153
+ "--head",
154
+ branch,
155
+ "--json",
156
+ "mergeable,mergeStateStatus",
157
+ ]
158
+ )
159
+
160
+ if not result.success:
161
+ return MergeStatus.UNKNOWN
162
+
163
+ try:
164
+ data = json.loads(result.output)
165
+ mergeable = data.get("mergeable")
166
+ merge_state = data.get("mergeStateStatus")
167
+
168
+ if mergeable == "CONFLICTING" or merge_state == "DIRTY":
169
+ return MergeStatus.CONFLICTING
170
+ elif merge_state == "CLEAN" or merge_state == "HAS_HOOKS":
171
+ return MergeStatus.MERGEABLE
172
+ elif merge_state == "BLOCKED":
173
+ return MergeStatus.BLOCKED
174
+ else:
175
+ return MergeStatus.UNKNOWN
176
+ except (json.JSONDecodeError, TypeError):
177
+ return MergeStatus.UNKNOWN
178
+
179
+ def _run_git(self, args: list[str], cwd: Optional[Path] = None) -> GitResult:
180
+ cwd = cwd or self.repo_root
181
+ try:
182
+ result = subprocess.run(
183
+ ["git"] + args, cwd=cwd, capture_output=True, text=True
184
+ )
185
+ return GitResult(
186
+ success=result.returncode == 0,
187
+ output=result.stdout,
188
+ error=result.stderr if result.returncode != 0 else None,
189
+ )
190
+ except Exception as e:
191
+ return GitResult(success=False, output="", error=str(e))
192
+
193
+ def _run_gh(self, args: list[str]) -> GitResult:
194
+ try:
195
+ result = subprocess.run(
196
+ ["gh"] + args, cwd=self.repo_root, capture_output=True, text=True
197
+ )
198
+ return GitResult(
199
+ success=result.returncode == 0,
200
+ output=result.stdout,
201
+ error=result.stderr if result.returncode != 0 else None,
202
+ )
203
+ except Exception as e:
204
+ return GitResult(success=False, output="", error=str(e))
@@ -0,0 +1,247 @@
1
+ import json
2
+ import subprocess
3
+ import re
4
+ from typing import Optional
5
+ from .states import StateData, IssueStep, STEP_STATUS_MESSAGES, STEP_LABELS
6
+
7
+
8
+ class GitHubStateManager:
9
+ """
10
+ Manages issue state via GitHub comments and labels.
11
+
12
+ State is stored in a special comment with hidden JSON:
13
+ <!-- autopilot-state
14
+ {"step": "...", "branch": "...", ...}
15
+ -->
16
+ """
17
+
18
+ STATE_COMMENT_MARKER = "<!-- autopilot-state"
19
+
20
+ def __init__(self, repo: str):
21
+ """
22
+ Args:
23
+ repo: GitHub repo in "owner/repo" format
24
+ """
25
+ self.repo = repo
26
+
27
+ def load_state(self, issue_number: int) -> Optional[StateData]:
28
+ """
29
+ Load state from the autopilot comment on the issue.
30
+
31
+ Returns None if no state comment exists (new issue).
32
+ """
33
+ comment_id = self._find_state_comment_id(issue_number)
34
+ if not comment_id:
35
+ return None
36
+
37
+ comments = self._get_comments(issue_number)
38
+ for comment in comments:
39
+ if comment["id"] == comment_id:
40
+ return self._parse_state_from_comment(comment["body"])
41
+
42
+ return None
43
+
44
+ def save_state(
45
+ self, issue_number: int, state: StateData, message: Optional[str] = None
46
+ ) -> None:
47
+ """
48
+ Create or update the state comment on the issue.
49
+
50
+ Also updates the autopilot step label for visibility.
51
+
52
+ Args:
53
+ issue_number: The GitHub issue number
54
+ state: The state to persist
55
+ message: Optional custom status message (uses default if None)
56
+ """
57
+ if message is None:
58
+ message = STEP_STATUS_MESSAGES.get(state.step, "Processing...")
59
+
60
+ state_body = state.to_comment_body(message)
61
+ comment_id = self._find_state_comment_id(issue_number)
62
+
63
+ if comment_id:
64
+ self._update_comment(comment_id, state_body)
65
+ else:
66
+ self._create_comment(issue_number, state_body)
67
+
68
+ self._set_step_label(issue_number, state.step)
69
+
70
+ def transition(
71
+ self, issue_number: int, state: StateData, new_step: IssueStep, **updates
72
+ ) -> StateData:
73
+ """
74
+ Transition to a new step and persist.
75
+
76
+ Convenience method that updates step, applies any additional updates,
77
+ and saves to GitHub.
78
+
79
+ Args:
80
+ issue_number: The GitHub issue number
81
+ state: Current state
82
+ new_step: The step to transition to
83
+ **updates: Additional StateData fields to update (e.g., pr_number=123)
84
+
85
+ Returns:
86
+ Updated StateData
87
+ """
88
+ state.step = new_step
89
+ for key, value in updates.items():
90
+ if hasattr(state, key):
91
+ setattr(state, key, value)
92
+ self.save_state(issue_number, state)
93
+ return state
94
+
95
+ def add_progress_comment(self, issue_number: int, message: str) -> None:
96
+ """Add a regular (non-state) comment for progress updates."""
97
+ self._create_comment(issue_number, message)
98
+
99
+ def get_issue_details(self, issue_number: int) -> tuple[str, str]:
100
+ """
101
+ Fetch issue title and body.
102
+
103
+ Returns:
104
+ (title, body) tuple
105
+ """
106
+ result = self._run_gh(
107
+ [
108
+ "issue",
109
+ "view",
110
+ str(issue_number),
111
+ "--repo",
112
+ self.repo,
113
+ "--json",
114
+ "title,body",
115
+ ]
116
+ )
117
+
118
+ try:
119
+ data = json.loads(result)
120
+ return data.get("title", ""), data.get("body", "")
121
+ except json.JSONDecodeError:
122
+ return "", ""
123
+
124
+ def _find_state_comment_id(self, issue_number: int) -> Optional[int]:
125
+ """Find the comment ID of the state comment, if it exists."""
126
+ comments = self._get_comments(issue_number)
127
+ for comment in comments:
128
+ if self.STATE_COMMENT_MARKER in comment.get("body", ""):
129
+ return comment["id"]
130
+ return None
131
+
132
+ def _get_comments(self, issue_number: int) -> list[dict]:
133
+ """Get all comments on an issue."""
134
+ result = self._run_gh(
135
+ [
136
+ "issue",
137
+ "view",
138
+ str(issue_number),
139
+ "--repo",
140
+ self.repo,
141
+ "--json",
142
+ "comments",
143
+ "--jq",
144
+ ".comments[]",
145
+ ]
146
+ )
147
+
148
+ if not result.strip():
149
+ return []
150
+
151
+ try:
152
+ return json.loads("[" + result + "]")
153
+ except json.JSONDecodeError:
154
+ return []
155
+
156
+ def _create_comment(self, issue_number: int, body: str) -> None:
157
+ """Create a new comment on the issue."""
158
+ self._run_gh(
159
+ ["issue", "comment", str(issue_number), "--repo", self.repo, "--body", body]
160
+ )
161
+
162
+ def _update_comment(self, comment_id: int, body: str) -> None:
163
+ """Update an existing comment."""
164
+ self._run_gh(
165
+ [
166
+ "api",
167
+ f"repos/{self.repo}/issues/comments/{comment_id}",
168
+ "--method",
169
+ "PATCH",
170
+ "-f",
171
+ f"body={body}",
172
+ ]
173
+ )
174
+
175
+ def _parse_state_from_comment(self, body: str) -> Optional[StateData]:
176
+ """Extract StateData from a comment body containing hidden JSON."""
177
+ match = re.search(r"<!-- autopilot-state\s*\n({.*?})\s*\n-->", body, re.DOTALL)
178
+ if not match:
179
+ return None
180
+ try:
181
+ data = json.loads(match.group(1))
182
+ return StateData.from_dict(data)
183
+ except (json.JSONDecodeError, KeyError, TypeError):
184
+ return None
185
+
186
+ def _set_step_label(self, issue_number: int, step: IssueStep) -> None:
187
+ """
188
+ Update the autopilot step label.
189
+
190
+ Removes any existing autopilot:* step labels and adds the new one.
191
+ """
192
+ result = self._run_gh(
193
+ [
194
+ "issue",
195
+ "view",
196
+ str(issue_number),
197
+ "--repo",
198
+ self.repo,
199
+ "--json",
200
+ "labels",
201
+ "--jq",
202
+ ".labels[].name",
203
+ ]
204
+ )
205
+
206
+ current_labels = result.strip().split("\n") if result.strip() else []
207
+
208
+ for label in current_labels:
209
+ if label.startswith("autopilot:") and label in STEP_LABELS.values():
210
+ self._run_gh(
211
+ [
212
+ "issue",
213
+ "edit",
214
+ str(issue_number),
215
+ "--repo",
216
+ self.repo,
217
+ "--remove-label",
218
+ label,
219
+ ]
220
+ )
221
+
222
+ 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
+ )
234
+
235
+ def _run_gh(self, args: list[str]) -> str:
236
+ """Run a gh CLI command and return stdout."""
237
+ try:
238
+ result = subprocess.run(
239
+ ["gh"] + args, capture_output=True, text=True, check=True
240
+ )
241
+ return result.stdout
242
+ except subprocess.CalledProcessError as e:
243
+ if e.stderr:
244
+ raise RuntimeError(f"gh command failed: {e.stderr}") from e
245
+ raise RuntimeError(
246
+ f"gh command failed with exit code {e.returncode}"
247
+ ) from e