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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autopilot-code",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -0,0 +1 @@
1
+ from .git_ops import GitOperations, GitResult, MergeStatus
@@ -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
+ }