autopilot-code 0.3.0 → 0.5.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/git_ops.py +204 -0
- package/scripts/issue_runner/states.py +102 -0
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .git_ops import GitOperations, GitResult, MergeStatus
|
|
Binary file
|
|
Binary file
|
|
@@ -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,102 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from dataclasses import dataclass, asdict, field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IssueStep(Enum):
|
|
9
|
+
"""Discrete steps in the issue resolution workflow."""
|
|
10
|
+
|
|
11
|
+
INIT = "init"
|
|
12
|
+
WORKTREE_READY = "worktree_ready"
|
|
13
|
+
DEPS_INSTALLED = "deps_installed"
|
|
14
|
+
ISSUE_FETCHED = "issue_fetched"
|
|
15
|
+
PLAN_POSTED = "plan_posted"
|
|
16
|
+
IMPLEMENTED = "implemented"
|
|
17
|
+
COMMITTED = "committed"
|
|
18
|
+
PUSHED = "pushed"
|
|
19
|
+
PR_CREATED = "pr_created"
|
|
20
|
+
CONFLICTS_RESOLVING = "conflicts_resolving"
|
|
21
|
+
CONFLICTS_RESOLVED = "conflicts_resolved"
|
|
22
|
+
CHECKS_WAITING = "checks_waiting"
|
|
23
|
+
CHECKS_FIXING = "checks_fixing"
|
|
24
|
+
CHECKS_PASSED = "checks_passed"
|
|
25
|
+
MERGED = "merged"
|
|
26
|
+
DONE = "done"
|
|
27
|
+
FAILED = "failed"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class StateData:
|
|
32
|
+
"""
|
|
33
|
+
Issue state persisted to GitHub as a hidden comment.
|
|
34
|
+
This is the single source of truth for issue progress.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
issue_number: int
|
|
38
|
+
step: IssueStep
|
|
39
|
+
branch: str
|
|
40
|
+
worktree: str
|
|
41
|
+
pr_number: Optional[int] = None
|
|
42
|
+
session_id: Optional[str] = None # Agent session for context continuity
|
|
43
|
+
conflict_attempts: int = 0
|
|
44
|
+
ci_fix_attempts: int = 0
|
|
45
|
+
error_message: Optional[str] = None
|
|
46
|
+
updated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
"""Convert to JSON-serializable dict."""
|
|
50
|
+
d = asdict(self)
|
|
51
|
+
d["step"] = self.step.value
|
|
52
|
+
return d
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, data: dict) -> "StateData":
|
|
56
|
+
"""Create from dict (parsed from JSON)."""
|
|
57
|
+
data["step"] = IssueStep(data["step"])
|
|
58
|
+
return cls(**data)
|
|
59
|
+
|
|
60
|
+
def to_comment_body(self, status_message: str) -> str:
|
|
61
|
+
"""Generate GitHub comment with hidden state JSON."""
|
|
62
|
+
state_json = json.dumps(self.to_dict(), indent=2)
|
|
63
|
+
return f"""
|
|
64
|
+
<!-- autopilot-state
|
|
65
|
+
{state_json}
|
|
66
|
+
-->
|
|
67
|
+
|
|
68
|
+
🤖 **Autopilot Status**: {status_message}
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Human-readable status messages for each step
|
|
73
|
+
STEP_STATUS_MESSAGES = {
|
|
74
|
+
IssueStep.INIT: "Starting work on this issue...",
|
|
75
|
+
IssueStep.WORKTREE_READY: "Git worktree created.",
|
|
76
|
+
IssueStep.DEPS_INSTALLED: "Dependencies installed.",
|
|
77
|
+
IssueStep.ISSUE_FETCHED: "Issue details retrieved.",
|
|
78
|
+
IssueStep.PLAN_POSTED: "Implementation plan ready.",
|
|
79
|
+
IssueStep.IMPLEMENTED: "Code changes complete.",
|
|
80
|
+
IssueStep.COMMITTED: "Changes committed.",
|
|
81
|
+
IssueStep.PUSHED: "Branch pushed to remote.",
|
|
82
|
+
IssueStep.PR_CREATED: "Pull request created.",
|
|
83
|
+
IssueStep.CONFLICTS_RESOLVING: "Resolving merge conflicts...",
|
|
84
|
+
IssueStep.CONFLICTS_RESOLVED: "Merge conflicts resolved.",
|
|
85
|
+
IssueStep.CHECKS_WAITING: "Waiting for CI checks to pass...",
|
|
86
|
+
IssueStep.CHECKS_FIXING: "Attempting to fix failing CI checks...",
|
|
87
|
+
IssueStep.CHECKS_PASSED: "All CI checks passed!",
|
|
88
|
+
IssueStep.MERGED: "PR merged successfully!",
|
|
89
|
+
IssueStep.DONE: "Issue complete! 🎉",
|
|
90
|
+
IssueStep.FAILED: "❌ Autopilot encountered an error.",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Labels to apply for each step (for human visibility in issue list)
|
|
94
|
+
STEP_LABELS = {
|
|
95
|
+
IssueStep.INIT: "autopilot:starting",
|
|
96
|
+
IssueStep.PLAN_POSTED: "autopilot:planning",
|
|
97
|
+
IssueStep.IMPLEMENTED: "autopilot:implementing",
|
|
98
|
+
IssueStep.PR_CREATED: "autopilot:pr-created",
|
|
99
|
+
IssueStep.CHECKS_WAITING: "autopilot:waiting-checks",
|
|
100
|
+
IssueStep.CHECKS_FIXING: "autopilot:fixing-checks",
|
|
101
|
+
IssueStep.MERGED: "autopilot:merging",
|
|
102
|
+
}
|