autopilot-code 0.5.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.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -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,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