autopilot-code 0.5.0 → 0.7.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,3 +1,32 @@
|
|
|
1
1
|
from .base import BaseAgent, AgentResult
|
|
2
|
+
from .opencode import OpenCodeAgent
|
|
3
|
+
from .claude import ClaudeCodeAgent
|
|
2
4
|
|
|
3
|
-
__all__ = ["BaseAgent", "AgentResult"]
|
|
5
|
+
__all__ = ["BaseAgent", "AgentResult", "OpenCodeAgent", "ClaudeCodeAgent"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_agent(agent_type: str, config: dict) -> BaseAgent:
|
|
9
|
+
"""
|
|
10
|
+
Factory function to create the appropriate agent.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
agent_type: "opencode" or "claude"
|
|
14
|
+
config: Agent configuration from autopilot.json
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Configured agent instance
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
ValueError: If agent_type is unknown
|
|
21
|
+
"""
|
|
22
|
+
agents = {
|
|
23
|
+
"opencode": OpenCodeAgent,
|
|
24
|
+
"claude": ClaudeCodeAgent,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if agent_type not in agents:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Unknown agent type: {agent_type}. Available: {list(agents.keys())}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return agents[agent_type](config)
|
|
@@ -0,0 +1,157 @@
|
|
|
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 ClaudeCodeAgent(BaseAgent):
|
|
11
|
+
"""
|
|
12
|
+
Agent implementation for Claude Code CLI (Anthropic's official CLI).
|
|
13
|
+
|
|
14
|
+
Claude Code is invoked via: claude [options] "<prompt>"
|
|
15
|
+
|
|
16
|
+
Session support: Claude Code supports --resume <session_id> to continue sessions.
|
|
17
|
+
Sessions maintain full conversation history and context.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return "Claude Code"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def supports_sessions(self) -> bool:
|
|
26
|
+
return True # Claude Code supports --resume
|
|
27
|
+
|
|
28
|
+
def find_binary(self) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Locate the claude binary.
|
|
31
|
+
|
|
32
|
+
Search order:
|
|
33
|
+
1. agentPath from config
|
|
34
|
+
2. PATH
|
|
35
|
+
3. Common npm global locations
|
|
36
|
+
4. Homebrew location (macOS)
|
|
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("claude")
|
|
45
|
+
if which_result:
|
|
46
|
+
return which_result
|
|
47
|
+
|
|
48
|
+
# 3. Common locations
|
|
49
|
+
home = Path.home()
|
|
50
|
+
common_paths = [
|
|
51
|
+
home / ".local" / "bin" / "claude",
|
|
52
|
+
Path("/usr/local/bin/claude"),
|
|
53
|
+
home / ".npm-global" / "bin" / "claude",
|
|
54
|
+
Path("/opt/homebrew/bin/claude"), # macOS Homebrew
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# Also check nvm locations
|
|
58
|
+
nvm_dir = home / ".nvm" / "versions" / "node"
|
|
59
|
+
if nvm_dir.exists():
|
|
60
|
+
for node_dir in nvm_dir.iterdir():
|
|
61
|
+
claude_path = node_dir / "bin" / "claude"
|
|
62
|
+
if claude_path.exists() and os.access(claude_path, os.X_OK):
|
|
63
|
+
return str(claude_path)
|
|
64
|
+
|
|
65
|
+
for path in common_paths:
|
|
66
|
+
if path.exists() and os.access(path, os.X_OK):
|
|
67
|
+
return str(path)
|
|
68
|
+
|
|
69
|
+
raise FileNotFoundError(
|
|
70
|
+
"claude not found. Install via 'npm install -g @anthropic-ai/claude-code' "
|
|
71
|
+
"or set 'agentPath' in autopilot.json."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def run(
|
|
75
|
+
self, worktree: Path, prompt: str, session_id: Optional[str] = None
|
|
76
|
+
) -> AgentResult:
|
|
77
|
+
"""
|
|
78
|
+
Run Claude Code with the given prompt.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
worktree: Working directory for the agent
|
|
82
|
+
prompt: The task/prompt
|
|
83
|
+
session_id: Previous session ID to resume (if any)
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
AgentResult with session_id for future continuation
|
|
87
|
+
"""
|
|
88
|
+
cmd = [self.binary_path]
|
|
89
|
+
|
|
90
|
+
# Add session resume if we have a previous session
|
|
91
|
+
if session_id and self.supports_sessions:
|
|
92
|
+
cmd.extend(["--resume", session_id])
|
|
93
|
+
|
|
94
|
+
# Add print mode for non-interactive execution
|
|
95
|
+
cmd.extend(["--print", "--dangerously-skip-permissions"])
|
|
96
|
+
|
|
97
|
+
# Add the prompt
|
|
98
|
+
cmd.extend(["-p", prompt])
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
cmd,
|
|
103
|
+
cwd=worktree,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
timeout=1800, # 30 minute timeout
|
|
107
|
+
env={**os.environ, "CLAUDE_CODE_HEADLESS": "1"},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Extract session ID from output
|
|
111
|
+
# Claude Code outputs the session ID which we can capture
|
|
112
|
+
new_session_id = (
|
|
113
|
+
self._extract_session_id(result.stdout, result.stderr) or session_id
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return AgentResult(
|
|
117
|
+
success=result.returncode == 0,
|
|
118
|
+
session_id=new_session_id,
|
|
119
|
+
output=result.stdout,
|
|
120
|
+
error=result.stderr if result.returncode != 0 else None,
|
|
121
|
+
)
|
|
122
|
+
except subprocess.TimeoutExpired:
|
|
123
|
+
return AgentResult(
|
|
124
|
+
success=False,
|
|
125
|
+
session_id=session_id,
|
|
126
|
+
output="",
|
|
127
|
+
error="Agent timed out after 30 minutes",
|
|
128
|
+
)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
return AgentResult(
|
|
131
|
+
success=False, session_id=session_id, output="", error=str(e)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _extract_session_id(self, stdout: str, stderr: str) -> Optional[str]:
|
|
135
|
+
"""
|
|
136
|
+
Extract session ID from Claude Code output.
|
|
137
|
+
|
|
138
|
+
Claude Code outputs session information that we can parse to get
|
|
139
|
+
the session ID for future --resume calls.
|
|
140
|
+
"""
|
|
141
|
+
import re
|
|
142
|
+
|
|
143
|
+
# Check both stdout and stderr for session info
|
|
144
|
+
for output in [stdout, stderr]:
|
|
145
|
+
# Look for session ID patterns
|
|
146
|
+
# Claude Code may output something like "Session ID: abc123" or similar
|
|
147
|
+
patterns = [
|
|
148
|
+
r"session[_\s]?id[:\s]+([a-zA-Z0-9_-]+)",
|
|
149
|
+
r"resume[:\s]+([a-zA-Z0-9_-]+)",
|
|
150
|
+
r"--resume\s+([a-zA-Z0-9_-]+)",
|
|
151
|
+
]
|
|
152
|
+
for pattern in patterns:
|
|
153
|
+
match = re.search(pattern, output, re.IGNORECASE)
|
|
154
|
+
if match:
|
|
155
|
+
return match.group(1)
|
|
156
|
+
|
|
157
|
+
return None
|
|
@@ -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
|