autopilot-code 0.6.0 → 0.8.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 +1,20 @@
|
|
|
1
1
|
from .git_ops import GitOperations, GitResult, MergeStatus
|
|
2
|
+
from .states import IssueStep, StateData, STEP_STATUS_MESSAGES, STEP_LABELS
|
|
3
|
+
from .github_state import GitHubStateManager
|
|
4
|
+
from .agents import BaseAgent, AgentResult, get_agent
|
|
5
|
+
from .runner import IssueRunner
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"GitOperations",
|
|
9
|
+
"GitResult",
|
|
10
|
+
"MergeStatus",
|
|
11
|
+
"IssueStep",
|
|
12
|
+
"StateData",
|
|
13
|
+
"STEP_STATUS_MESSAGES",
|
|
14
|
+
"STEP_LABELS",
|
|
15
|
+
"GitHubStateManager",
|
|
16
|
+
"BaseAgent",
|
|
17
|
+
"AgentResult",
|
|
18
|
+
"get_agent",
|
|
19
|
+
"IssueRunner",
|
|
20
|
+
]
|
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
from .base import BaseAgent, AgentResult
|
|
2
2
|
from .opencode import OpenCodeAgent
|
|
3
|
+
from .claude import ClaudeCodeAgent
|
|
3
4
|
|
|
4
|
-
__all__ = ["BaseAgent", "AgentResult", "OpenCodeAgent"]
|
|
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,678 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import subprocess
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from .states import IssueStep, StateData, STEP_STATUS_MESSAGES
|
|
10
|
+
from .github_state import GitHubStateManager
|
|
11
|
+
from .git_ops import GitOperations, MergeStatus
|
|
12
|
+
from .agents import BaseAgent, get_agent
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IssueRunner:
|
|
18
|
+
"""
|
|
19
|
+
State-machine-based issue runner with full resumability.
|
|
20
|
+
|
|
21
|
+
State is persisted to GitHub after each transition, allowing
|
|
22
|
+
the runner to resume from any step if interrupted.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
repo: str,
|
|
28
|
+
repo_root: Path,
|
|
29
|
+
config: dict,
|
|
30
|
+
agent: Optional[BaseAgent] = None,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Args:
|
|
34
|
+
repo: GitHub repo in "owner/repo" format
|
|
35
|
+
repo_root: Path to the repository root
|
|
36
|
+
config: Full autopilot.json configuration
|
|
37
|
+
agent: Agent to use (created from config if not provided)
|
|
38
|
+
"""
|
|
39
|
+
self.repo = repo
|
|
40
|
+
self.repo_root = repo_root
|
|
41
|
+
self.config = config
|
|
42
|
+
self.github = GitHubStateManager(repo)
|
|
43
|
+
self.git = GitOperations(repo_root)
|
|
44
|
+
|
|
45
|
+
# Create agent from config if not provided
|
|
46
|
+
if agent is None:
|
|
47
|
+
agent_type = config.get("agent", "opencode")
|
|
48
|
+
agent = get_agent(agent_type, config)
|
|
49
|
+
self.agent = agent
|
|
50
|
+
|
|
51
|
+
def run(self, issue_number: int) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Run the issue workflow to completion.
|
|
54
|
+
|
|
55
|
+
Resumes from stored state if available, otherwise starts fresh.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
issue_number: GitHub issue number to process
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if completed successfully, False if failed
|
|
62
|
+
"""
|
|
63
|
+
# Load existing state or create new
|
|
64
|
+
state = self._load_or_create_state(issue_number)
|
|
65
|
+
|
|
66
|
+
logger.info(f"Starting issue #{issue_number} from step: {state.step.value}")
|
|
67
|
+
|
|
68
|
+
# Run state machine until terminal state
|
|
69
|
+
terminal_states = {IssueStep.DONE, IssueStep.FAILED}
|
|
70
|
+
|
|
71
|
+
while state.step not in terminal_states:
|
|
72
|
+
try:
|
|
73
|
+
state = self._transition(state)
|
|
74
|
+
self._save_state(state)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.exception(f"Error in step {state.step.value}")
|
|
77
|
+
state = replace(
|
|
78
|
+
state,
|
|
79
|
+
step=IssueStep.FAILED,
|
|
80
|
+
error_message=str(e),
|
|
81
|
+
updated_at=datetime.utcnow().isoformat() + "Z",
|
|
82
|
+
)
|
|
83
|
+
self._save_state(state, f"❌ Failed at step {state.step.value}: {e}")
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
return state.step == IssueStep.DONE
|
|
87
|
+
|
|
88
|
+
def _load_or_create_state(self, issue_number: int) -> StateData:
|
|
89
|
+
"""Load existing state from GitHub or create initial state."""
|
|
90
|
+
existing = self.github.load_state(issue_number)
|
|
91
|
+
|
|
92
|
+
if existing:
|
|
93
|
+
logger.info(f"Resuming from step: {existing.step.value}")
|
|
94
|
+
return existing
|
|
95
|
+
|
|
96
|
+
# Create initial state
|
|
97
|
+
branch = f"{self.config.get('branchPrefix', 'autopilot/')}issue-{issue_number}"
|
|
98
|
+
worktree = f"/tmp/autopilot-issue-{issue_number}"
|
|
99
|
+
|
|
100
|
+
return StateData(
|
|
101
|
+
issue_number=issue_number,
|
|
102
|
+
step=IssueStep.INIT,
|
|
103
|
+
branch=branch,
|
|
104
|
+
worktree=worktree,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _save_state(self, state: StateData, message: Optional[str] = None) -> None:
|
|
108
|
+
"""Persist state to GitHub."""
|
|
109
|
+
if message is None:
|
|
110
|
+
message = STEP_STATUS_MESSAGES.get(state.step, f"Step: {state.step.value}")
|
|
111
|
+
self.github.save_state(state.issue_number, state, message)
|
|
112
|
+
|
|
113
|
+
def _transition(self, state: StateData) -> StateData:
|
|
114
|
+
"""Execute one state transition."""
|
|
115
|
+
handlers = {
|
|
116
|
+
IssueStep.INIT: self._handle_init,
|
|
117
|
+
IssueStep.WORKTREE_READY: self._handle_deps,
|
|
118
|
+
IssueStep.DEPS_INSTALLED: self._handle_fetch_issue,
|
|
119
|
+
IssueStep.ISSUE_FETCHED: self._handle_planning,
|
|
120
|
+
IssueStep.PLAN_POSTED: self._handle_implementation,
|
|
121
|
+
IssueStep.IMPLEMENTED: self._handle_commit,
|
|
122
|
+
IssueStep.COMMITTED: self._handle_push,
|
|
123
|
+
IssueStep.PUSHED: self._handle_pr_creation,
|
|
124
|
+
IssueStep.PR_CREATED: self._handle_conflict_check,
|
|
125
|
+
IssueStep.CONFLICTS_RESOLVING: self._handle_conflict_resolution,
|
|
126
|
+
IssueStep.CONFLICTS_RESOLVED: self._handle_checks_wait,
|
|
127
|
+
IssueStep.CHECKS_WAITING: self._handle_checks_poll,
|
|
128
|
+
IssueStep.CHECKS_FIXING: self._handle_checks_fix,
|
|
129
|
+
IssueStep.CHECKS_PASSED: self._handle_merge,
|
|
130
|
+
IssueStep.MERGED: self._handle_cleanup,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
handler = handlers.get(state.step)
|
|
134
|
+
if handler is None:
|
|
135
|
+
raise ValueError(f"No handler for step: {state.step}")
|
|
136
|
+
|
|
137
|
+
return handler(state)
|
|
138
|
+
|
|
139
|
+
# === State Handlers ===
|
|
140
|
+
|
|
141
|
+
def _handle_init(self, state: StateData) -> StateData:
|
|
142
|
+
"""Create git worktree."""
|
|
143
|
+
logger.info(f"Creating worktree at {state.worktree}")
|
|
144
|
+
|
|
145
|
+
result = self.git.create_worktree(
|
|
146
|
+
Path(state.worktree),
|
|
147
|
+
state.branch,
|
|
148
|
+
self.config.get("allowedBaseBranch", "main"),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if not result.success:
|
|
152
|
+
raise RuntimeError(f"Failed to create worktree: {result.error}")
|
|
153
|
+
|
|
154
|
+
return replace(state, step=IssueStep.WORKTREE_READY)
|
|
155
|
+
|
|
156
|
+
def _handle_deps(self, state: StateData) -> StateData:
|
|
157
|
+
"""Install dependencies if needed."""
|
|
158
|
+
worktree = Path(state.worktree)
|
|
159
|
+
|
|
160
|
+
if (worktree / "package.json").exists():
|
|
161
|
+
logger.info("Installing npm dependencies...")
|
|
162
|
+
result = subprocess.run(
|
|
163
|
+
["npm", "install", "--silent"],
|
|
164
|
+
cwd=worktree,
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
)
|
|
168
|
+
if result.returncode != 0:
|
|
169
|
+
logger.warning(f"npm install failed: {result.stderr}")
|
|
170
|
+
|
|
171
|
+
return replace(state, step=IssueStep.DEPS_INSTALLED)
|
|
172
|
+
|
|
173
|
+
def _handle_fetch_issue(self, state: StateData) -> StateData:
|
|
174
|
+
"""Fetch issue title and body."""
|
|
175
|
+
# Issue details are fetched when needed by agent
|
|
176
|
+
# This step just marks that we're ready for planning
|
|
177
|
+
return replace(state, step=IssueStep.ISSUE_FETCHED)
|
|
178
|
+
|
|
179
|
+
def _handle_planning(self, state: StateData) -> StateData:
|
|
180
|
+
"""Run planning step if enabled."""
|
|
181
|
+
if not self.config.get("enablePlanningStep", True):
|
|
182
|
+
logger.info("Planning step disabled, skipping")
|
|
183
|
+
return replace(state, step=IssueStep.PLAN_POSTED)
|
|
184
|
+
|
|
185
|
+
logger.info("Running planning step...")
|
|
186
|
+
|
|
187
|
+
title, body = self.github.get_issue_details(state.issue_number)
|
|
188
|
+
|
|
189
|
+
result = self.agent.run_planning(
|
|
190
|
+
Path(state.worktree), state.issue_number, title, body, state.session_id
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if not result.success:
|
|
194
|
+
logger.warning(f"Planning step failed: {result.error}")
|
|
195
|
+
# Continue anyway - planning is optional
|
|
196
|
+
|
|
197
|
+
# Post plan as comment
|
|
198
|
+
if result.output:
|
|
199
|
+
plan_comment = f"""## 📋 Implementation Plan
|
|
200
|
+
|
|
201
|
+
I've analyzed the issue and codebase. Here's my planned approach:
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
{result.output[:2000]} # Truncate if too long
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
I will now proceed with implementation."""
|
|
208
|
+
self.github.add_progress_comment(state.issue_number, plan_comment)
|
|
209
|
+
|
|
210
|
+
return replace(state, step=IssueStep.PLAN_POSTED, session_id=result.session_id)
|
|
211
|
+
|
|
212
|
+
def _handle_implementation(self, state: StateData) -> StateData:
|
|
213
|
+
"""Run the agent to implement the issue."""
|
|
214
|
+
logger.info("Running implementation step...")
|
|
215
|
+
|
|
216
|
+
title, body = self.github.get_issue_details(state.issue_number)
|
|
217
|
+
|
|
218
|
+
result = self.agent.run_implementation(
|
|
219
|
+
Path(state.worktree),
|
|
220
|
+
state.issue_number,
|
|
221
|
+
title,
|
|
222
|
+
body,
|
|
223
|
+
state.branch,
|
|
224
|
+
state.session_id,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if not result.success:
|
|
228
|
+
raise RuntimeError(f"Implementation failed: {result.error}")
|
|
229
|
+
|
|
230
|
+
self.github.add_progress_comment(
|
|
231
|
+
state.issue_number, "✅ Implementation complete. Now committing changes..."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return replace(state, step=IssueStep.IMPLEMENTED, session_id=result.session_id)
|
|
235
|
+
|
|
236
|
+
def _handle_commit(self, state: StateData) -> StateData:
|
|
237
|
+
"""Commit changes."""
|
|
238
|
+
worktree = Path(state.worktree)
|
|
239
|
+
|
|
240
|
+
if not self.git.has_changes(worktree):
|
|
241
|
+
logger.info("No changes to commit")
|
|
242
|
+
self.github.add_progress_comment(
|
|
243
|
+
state.issue_number,
|
|
244
|
+
"ℹ️ No changes were made. The issue may already be resolved.",
|
|
245
|
+
)
|
|
246
|
+
return replace(state, step=IssueStep.COMMITTED)
|
|
247
|
+
|
|
248
|
+
self.git.stage_all(worktree)
|
|
249
|
+
result = self.git.commit(
|
|
250
|
+
worktree, f"autopilot: work for issue #{state.issue_number}"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if not result.success:
|
|
254
|
+
raise RuntimeError(f"Commit failed: {result.error}")
|
|
255
|
+
|
|
256
|
+
sha = self.git.get_commit_sha(worktree)
|
|
257
|
+
count = self.git.get_changed_files_count(worktree)
|
|
258
|
+
|
|
259
|
+
self.github.add_progress_comment(
|
|
260
|
+
state.issue_number,
|
|
261
|
+
f"📝 Changes committed.\n\n- Commit: `{sha}`\n- Files changed: {count}",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return replace(state, step=IssueStep.COMMITTED)
|
|
265
|
+
|
|
266
|
+
def _handle_push(self, state: StateData) -> StateData:
|
|
267
|
+
"""Push branch to remote."""
|
|
268
|
+
result = self.git.push(state.branch, set_upstream=True)
|
|
269
|
+
|
|
270
|
+
if not result.success:
|
|
271
|
+
raise RuntimeError(f"Push failed: {result.error}")
|
|
272
|
+
|
|
273
|
+
self.github.add_progress_comment(
|
|
274
|
+
state.issue_number, f"📤 Changes pushed to branch `{state.branch}`."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return replace(state, step=IssueStep.PUSHED)
|
|
278
|
+
|
|
279
|
+
def _handle_pr_creation(self, state: StateData) -> StateData:
|
|
280
|
+
"""Create PR if it doesn't exist."""
|
|
281
|
+
# Check if PR already exists
|
|
282
|
+
result = self.git._run_gh(
|
|
283
|
+
[
|
|
284
|
+
"pr",
|
|
285
|
+
"view",
|
|
286
|
+
"--repo",
|
|
287
|
+
self.repo,
|
|
288
|
+
"--head",
|
|
289
|
+
state.branch,
|
|
290
|
+
"--json",
|
|
291
|
+
"number",
|
|
292
|
+
]
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if result.success:
|
|
296
|
+
# PR already exists
|
|
297
|
+
try:
|
|
298
|
+
pr_data = json.loads(result.output)
|
|
299
|
+
pr_number = pr_data.get("number")
|
|
300
|
+
logger.info(f"PR already exists: #{pr_number}")
|
|
301
|
+
return replace(state, step=IssueStep.PR_CREATED, pr_number=pr_number)
|
|
302
|
+
except json.JSONDecodeError:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
# Create new PR
|
|
306
|
+
title, body = self.github.get_issue_details(state.issue_number)
|
|
307
|
+
pr_title = f"Autopilot: Issue #{state.issue_number} - {title}"
|
|
308
|
+
pr_body = f"""Closes #{state.issue_number}
|
|
309
|
+
|
|
310
|
+
This PR is automatically created by Autopilot to implement issue #{state.issue_number}."""
|
|
311
|
+
|
|
312
|
+
result = self.git._run_gh(
|
|
313
|
+
[
|
|
314
|
+
"pr",
|
|
315
|
+
"create",
|
|
316
|
+
"--repo",
|
|
317
|
+
self.repo,
|
|
318
|
+
"--title",
|
|
319
|
+
pr_title,
|
|
320
|
+
"--body",
|
|
321
|
+
pr_body,
|
|
322
|
+
"--base",
|
|
323
|
+
self.config.get("allowedBaseBranch", "main"),
|
|
324
|
+
"--head",
|
|
325
|
+
state.branch,
|
|
326
|
+
]
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if not result.success:
|
|
330
|
+
raise RuntimeError(f"Failed to create PR: {result.error}")
|
|
331
|
+
|
|
332
|
+
# Extract PR number from output
|
|
333
|
+
# gh outputs: "https://github.com/owner/repo/pull/123"
|
|
334
|
+
pr_url = result.output.strip()
|
|
335
|
+
try:
|
|
336
|
+
pr_number = int(pr_url.split("/")[-1])
|
|
337
|
+
except (ValueError, IndexError):
|
|
338
|
+
# Fallback: query for the PR
|
|
339
|
+
result = self.git._run_gh(
|
|
340
|
+
[
|
|
341
|
+
"pr",
|
|
342
|
+
"view",
|
|
343
|
+
"--repo",
|
|
344
|
+
self.repo,
|
|
345
|
+
"--head",
|
|
346
|
+
state.branch,
|
|
347
|
+
"--json",
|
|
348
|
+
"number",
|
|
349
|
+
]
|
|
350
|
+
)
|
|
351
|
+
pr_data = json.loads(result.output)
|
|
352
|
+
pr_number = pr_data["number"]
|
|
353
|
+
pr_url = f"https://github.com/{self.repo}/pull/{pr_number}"
|
|
354
|
+
|
|
355
|
+
self.github.add_progress_comment(
|
|
356
|
+
state.issue_number, f"🔀 Pull request created: [{pr_number}]({pr_url})"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return replace(state, step=IssueStep.PR_CREATED, pr_number=pr_number)
|
|
360
|
+
|
|
361
|
+
def _handle_conflict_check(self, state: StateData) -> StateData:
|
|
362
|
+
"""Check for merge conflicts."""
|
|
363
|
+
if not self.config.get("autoResolveConflicts", True):
|
|
364
|
+
return replace(state, step=IssueStep.CONFLICTS_RESOLVED)
|
|
365
|
+
|
|
366
|
+
status = self.git.get_pr_merge_status(self.repo, state.branch)
|
|
367
|
+
|
|
368
|
+
if status == MergeStatus.CONFLICTING:
|
|
369
|
+
logger.info("PR has merge conflicts")
|
|
370
|
+
return replace(state, step=IssueStep.CONFLICTS_RESOLVING)
|
|
371
|
+
|
|
372
|
+
return replace(state, step=IssueStep.CONFLICTS_RESOLVED)
|
|
373
|
+
|
|
374
|
+
def _handle_conflict_resolution(self, state: StateData) -> StateData:
|
|
375
|
+
"""Attempt to resolve merge conflicts."""
|
|
376
|
+
max_attempts = self.config.get("conflictResolutionMaxAttempts", 3)
|
|
377
|
+
|
|
378
|
+
if state.conflict_attempts >= max_attempts:
|
|
379
|
+
raise RuntimeError(
|
|
380
|
+
f"Failed to resolve conflicts after {max_attempts} attempts"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
logger.info(
|
|
384
|
+
f"Attempting conflict resolution ({state.conflict_attempts + 1}/{max_attempts})"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
worktree = Path(state.worktree)
|
|
388
|
+
|
|
389
|
+
# Strategy 1: Try rebase
|
|
390
|
+
if state.conflict_attempts == 0:
|
|
391
|
+
logger.info("Attempting rebase strategy...")
|
|
392
|
+
self.git.fetch()
|
|
393
|
+
rebase_result = self.git.rebase(worktree)
|
|
394
|
+
|
|
395
|
+
if rebase_result.success and not self.git.has_conflicts(worktree):
|
|
396
|
+
# Rebase succeeded, push force
|
|
397
|
+
self.git.push(state.branch, force=True)
|
|
398
|
+
logger.info("Rebase succeeded")
|
|
399
|
+
return replace(
|
|
400
|
+
state,
|
|
401
|
+
step=IssueStep.CONFLICTS_RESOLVED,
|
|
402
|
+
conflict_attempts=state.conflict_attempts + 1,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# If rebase had conflicts, abort and try merge
|
|
406
|
+
self.git.abort_rebase(worktree)
|
|
407
|
+
|
|
408
|
+
# Strategy 2: Try merge
|
|
409
|
+
if state.conflict_attempts == 1:
|
|
410
|
+
logger.info("Attempting merge strategy...")
|
|
411
|
+
merge_result = self.git.merge(worktree)
|
|
412
|
+
|
|
413
|
+
if merge_result.success and not self.git.has_conflicts(worktree):
|
|
414
|
+
# Merge succeeded, push force
|
|
415
|
+
self.git.push(state.branch, force=True)
|
|
416
|
+
logger.info("Merge succeeded")
|
|
417
|
+
return replace(
|
|
418
|
+
state,
|
|
419
|
+
step=IssueStep.CONFLICTS_RESOLVED,
|
|
420
|
+
conflict_attempts=state.conflict_attempts + 1,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# If merge had conflicts, abort
|
|
424
|
+
self.git.abort_merge(worktree)
|
|
425
|
+
|
|
426
|
+
# Strategy 3: Use agent to resolve conflicts
|
|
427
|
+
logger.info("Attempting agent-based conflict resolution...")
|
|
428
|
+
|
|
429
|
+
conflicted_files = self.git.get_conflicted_files(worktree)
|
|
430
|
+
logger.info(f"Conflicted files: {conflicted_files}")
|
|
431
|
+
|
|
432
|
+
result = self.agent.run_conflict_resolution(worktree, state.session_id)
|
|
433
|
+
|
|
434
|
+
if not result.success:
|
|
435
|
+
raise RuntimeError(f"Conflict resolution failed: {result.error}")
|
|
436
|
+
|
|
437
|
+
# Stage resolved files
|
|
438
|
+
for file in conflicted_files:
|
|
439
|
+
self.git._run_git(["add", str(file)], cwd=worktree)
|
|
440
|
+
|
|
441
|
+
# Commit if in merge state
|
|
442
|
+
if self.git.is_merge_in_progress(worktree):
|
|
443
|
+
commit_result = self.git.commit(
|
|
444
|
+
worktree, "autopilot: resolve merge conflicts"
|
|
445
|
+
)
|
|
446
|
+
if not commit_result.success:
|
|
447
|
+
raise RuntimeError(
|
|
448
|
+
f"Failed to commit conflict resolution: {commit_result.error}"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Push force to update remote
|
|
452
|
+
push_result = self.git.push(state.branch, force=True)
|
|
453
|
+
if not push_result.success:
|
|
454
|
+
raise RuntimeError(
|
|
455
|
+
f"Failed to push after conflict resolution: {push_result.error}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
self.github.add_progress_comment(
|
|
459
|
+
state.issue_number,
|
|
460
|
+
f"✅ Merge conflicts resolved (attempt {state.conflict_attempts + 1}/{max_attempts})",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
return replace(
|
|
464
|
+
state,
|
|
465
|
+
step=IssueStep.CONFLICTS_RESOLVED,
|
|
466
|
+
conflict_attempts=state.conflict_attempts + 1,
|
|
467
|
+
session_id=result.session_id,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def _handle_checks_wait(self, state: StateData) -> StateData:
|
|
471
|
+
"""Start waiting for CI checks."""
|
|
472
|
+
self.github.add_progress_comment(
|
|
473
|
+
state.issue_number, "⏳ Waiting for CI checks to complete..."
|
|
474
|
+
)
|
|
475
|
+
return replace(state, step=IssueStep.CHECKS_WAITING)
|
|
476
|
+
|
|
477
|
+
def _handle_checks_poll(self, state: StateData) -> StateData:
|
|
478
|
+
"""Poll CI check status."""
|
|
479
|
+
result = self.git._run_gh(
|
|
480
|
+
[
|
|
481
|
+
"pr",
|
|
482
|
+
"checks",
|
|
483
|
+
"--repo",
|
|
484
|
+
self.repo,
|
|
485
|
+
"--head",
|
|
486
|
+
state.branch,
|
|
487
|
+
"--json",
|
|
488
|
+
"name,conclusion,status",
|
|
489
|
+
"--jq",
|
|
490
|
+
'.[] | "\(.name):\(.status):\(.conclusion)"',
|
|
491
|
+
]
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if not result.success:
|
|
495
|
+
# Assume still waiting if we can't get status
|
|
496
|
+
return replace(state, step=IssueStep.CHECKS_WAITING)
|
|
497
|
+
|
|
498
|
+
checks = result.output.strip().split("\n") if result.output.strip() else []
|
|
499
|
+
has_pending = False
|
|
500
|
+
has_failed = False
|
|
501
|
+
failure_context = ""
|
|
502
|
+
|
|
503
|
+
for check in checks:
|
|
504
|
+
parts = check.split(":")
|
|
505
|
+
if len(parts) >= 3:
|
|
506
|
+
name, status, conclusion = parts[0], parts[1], parts[2]
|
|
507
|
+
if status == "in_progress" or status == "queued" or status == "pending":
|
|
508
|
+
has_pending = True
|
|
509
|
+
elif conclusion == "failure":
|
|
510
|
+
has_failed = True
|
|
511
|
+
failure_context += f"\n❌ {name}: {conclusion}\n"
|
|
512
|
+
elif conclusion == "action_required":
|
|
513
|
+
has_failed = True
|
|
514
|
+
failure_context += f"\n⚠️ {name}: {conclusion}\n"
|
|
515
|
+
|
|
516
|
+
if has_pending:
|
|
517
|
+
# Still waiting for checks
|
|
518
|
+
return replace(state, step=IssueStep.CHECKS_WAITING)
|
|
519
|
+
|
|
520
|
+
if has_failed and self.config.get("autoFixChecks", True):
|
|
521
|
+
# Some checks failed
|
|
522
|
+
logger.info("CI checks failed, attempting to fix...")
|
|
523
|
+
self.github.add_progress_comment(
|
|
524
|
+
state.issue_number,
|
|
525
|
+
f"⚠️ Some CI checks failed. Attempting to fix...\n{failure_context}",
|
|
526
|
+
)
|
|
527
|
+
return replace(state, step=IssueStep.CHECKS_FIXING)
|
|
528
|
+
|
|
529
|
+
if has_failed:
|
|
530
|
+
# Checks failed but auto-fix is disabled
|
|
531
|
+
error_msg = f"CI checks failed but auto-fix is disabled:\n{failure_context}"
|
|
532
|
+
raise RuntimeError(error_msg)
|
|
533
|
+
|
|
534
|
+
# All checks passed
|
|
535
|
+
logger.info("All CI checks passed!")
|
|
536
|
+
return replace(state, step=IssueStep.CHECKS_PASSED)
|
|
537
|
+
|
|
538
|
+
def _handle_checks_fix(self, state: StateData) -> StateData:
|
|
539
|
+
"""Attempt to fix failing CI checks."""
|
|
540
|
+
max_attempts = self.config.get("autoFixChecksMaxAttempts", 3)
|
|
541
|
+
|
|
542
|
+
if state.ci_fix_attempts >= max_attempts:
|
|
543
|
+
raise RuntimeError(f"Failed to fix CI after {max_attempts} attempts")
|
|
544
|
+
|
|
545
|
+
logger.info(
|
|
546
|
+
f"Attempting CI fix (attempt {state.ci_fix_attempts + 1}/{max_attempts})"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Get failure details
|
|
550
|
+
result = self.git._run_gh(
|
|
551
|
+
[
|
|
552
|
+
"pr",
|
|
553
|
+
"checks",
|
|
554
|
+
"--repo",
|
|
555
|
+
self.repo,
|
|
556
|
+
"--head",
|
|
557
|
+
state.branch,
|
|
558
|
+
"--json",
|
|
559
|
+
"name,conclusion,logUrl",
|
|
560
|
+
"--jq",
|
|
561
|
+
'.[] | select(.conclusion == "failure" or .conclusion == "action_required") | "\(.name): \(.logUrl)"',
|
|
562
|
+
]
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
failure_context = (
|
|
566
|
+
result.output if result.success else "Could not fetch failure details"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
worktree = Path(state.worktree)
|
|
570
|
+
|
|
571
|
+
# Pull latest changes first
|
|
572
|
+
self.git.fetch()
|
|
573
|
+
result = self.git._run_git(["pull", "origin", state.branch], cwd=worktree)
|
|
574
|
+
if not result.success:
|
|
575
|
+
logger.warning(f"Failed to pull latest changes: {result.error}")
|
|
576
|
+
|
|
577
|
+
# Run agent to fix
|
|
578
|
+
agent_result = self.agent.run_ci_fix(
|
|
579
|
+
worktree,
|
|
580
|
+
failure_context,
|
|
581
|
+
state.ci_fix_attempts + 1,
|
|
582
|
+
max_attempts,
|
|
583
|
+
state.branch,
|
|
584
|
+
state.session_id,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
if not agent_result.success:
|
|
588
|
+
raise RuntimeError(f"CI fix attempt failed: {agent_result.error}")
|
|
589
|
+
|
|
590
|
+
# Commit and push changes
|
|
591
|
+
if self.git.has_changes(worktree):
|
|
592
|
+
self.git.stage_all(worktree)
|
|
593
|
+
commit_msg = f"autopilot: fix CI check failures (attempt {state.ci_fix_attempts + 1}/{max_attempts})"
|
|
594
|
+
commit_result = self.git.commit(worktree, commit_msg)
|
|
595
|
+
if not commit_result.success:
|
|
596
|
+
raise RuntimeError(f"Failed to commit CI fix: {commit_result.error}")
|
|
597
|
+
|
|
598
|
+
push_result = self.git.push(state.branch)
|
|
599
|
+
if not push_result.success:
|
|
600
|
+
raise RuntimeError(f"Failed to push CI fix: {push_result.error}")
|
|
601
|
+
|
|
602
|
+
self.github.add_progress_comment(
|
|
603
|
+
state.issue_number,
|
|
604
|
+
f"🔧 CI fix attempt {state.ci_fix_attempts + 1}/{max_attempts} complete. Re-running checks...",
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
return replace(
|
|
608
|
+
state,
|
|
609
|
+
step=IssueStep.CHECKS_WAITING, # Go back to polling
|
|
610
|
+
ci_fix_attempts=state.ci_fix_attempts + 1,
|
|
611
|
+
session_id=agent_result.session_id,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
def _handle_merge(self, state: StateData) -> StateData:
|
|
615
|
+
"""Merge the PR."""
|
|
616
|
+
if not self.config.get("autoMerge", True):
|
|
617
|
+
logger.info("Auto-merge is disabled")
|
|
618
|
+
self.github.add_progress_comment(
|
|
619
|
+
state.issue_number,
|
|
620
|
+
"ℹ️ Auto-merge is disabled. Please review and merge manually.",
|
|
621
|
+
)
|
|
622
|
+
return replace(state, step=IssueStep.DONE)
|
|
623
|
+
|
|
624
|
+
# Check if allowed merge user is configured
|
|
625
|
+
allowed_users = self.config.get("allowedMergeUsers", [])
|
|
626
|
+
if not allowed_users:
|
|
627
|
+
logger.warning("No allowedMergeUsers configured, auto-merge disabled")
|
|
628
|
+
self.github.add_progress_comment(
|
|
629
|
+
state.issue_number,
|
|
630
|
+
"⚠️ No allowedMergeUsers configured. Please review and merge manually.",
|
|
631
|
+
)
|
|
632
|
+
return replace(state, step=IssueStep.DONE)
|
|
633
|
+
|
|
634
|
+
# Merge the PR
|
|
635
|
+
result = self.git._run_gh(
|
|
636
|
+
[
|
|
637
|
+
"pr",
|
|
638
|
+
"merge",
|
|
639
|
+
str(state.pr_number),
|
|
640
|
+
"--repo",
|
|
641
|
+
self.repo,
|
|
642
|
+
"--merge",
|
|
643
|
+
]
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
if not result.success:
|
|
647
|
+
raise RuntimeError(f"Failed to merge PR: {result.error}")
|
|
648
|
+
|
|
649
|
+
self.github.add_progress_comment(
|
|
650
|
+
state.issue_number, f"✅ PR #{state.pr_number} merged successfully!"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
return replace(state, step=IssueStep.MERGED)
|
|
654
|
+
|
|
655
|
+
def _handle_cleanup(self, state: StateData) -> StateData:
|
|
656
|
+
"""Close issue and cleanup."""
|
|
657
|
+
# Close the issue
|
|
658
|
+
result = self.git._run_gh(
|
|
659
|
+
[
|
|
660
|
+
"issue",
|
|
661
|
+
"close",
|
|
662
|
+
str(state.issue_number),
|
|
663
|
+
"--repo",
|
|
664
|
+
self.repo,
|
|
665
|
+
]
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if result.success:
|
|
669
|
+
self.github.add_progress_comment(
|
|
670
|
+
state.issue_number, "✅ Issue closed. Work complete! 🎉"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
# Cleanup worktree
|
|
674
|
+
worktree = Path(state.worktree)
|
|
675
|
+
if self.git.worktree_exists(worktree):
|
|
676
|
+
self.git.remove_worktree(worktree)
|
|
677
|
+
|
|
678
|
+
return replace(state, step=IssueStep.DONE)
|