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 +1 -1
- package/scripts/issue_runner/__init__.py +1 -0
- package/scripts/issue_runner/__pycache__/__init__.cpython-310.pyc +0 -0
- package/scripts/issue_runner/__pycache__/git_ops.cpython-310.pyc +0 -0
- package/scripts/issue_runner/agents/__init__.py +2 -1
- package/scripts/issue_runner/agents/opencode.py +137 -0
- package/scripts/issue_runner/git_ops.py +204 -0
- package/scripts/issue_runner/github_state.py +247 -0
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .git_ops import GitOperations, GitResult, MergeStatus
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|