autopilot-code 0.7.0 → 0.9.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.7.0",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -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
+ ]
@@ -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)
@@ -25,6 +25,12 @@ from dataclasses import dataclass
25
25
  from pathlib import Path
26
26
  from typing import Any
27
27
  import shutil
28
+ from sys import path as sys_path
29
+
30
+ # Add scripts directory to path for imports
31
+ script_dir = Path(__file__).parent
32
+ sys_path.insert(0, str(script_dir))
33
+ from issue_runner import IssueRunner
28
34
 
29
35
  STATE_DIR = ".autopilot"
30
36
  STATE_FILE = "state.json"
@@ -87,6 +93,8 @@ class RepoConfig:
87
93
  auto_fix_checks: bool
88
94
  auto_fix_checks_max_attempts: int
89
95
  auto_update: bool
96
+ use_new_runner: bool = False
97
+ config: dict[str, Any] = None
90
98
 
91
99
 
92
100
  def load_config(repo_root: Path) -> RepoConfig | None:
@@ -136,6 +144,8 @@ def load_config(repo_root: Path) -> RepoConfig | None:
136
144
  auto_fix_checks=auto_fix_checks,
137
145
  auto_fix_checks_max_attempts=auto_fix_checks_max_attempts,
138
146
  auto_update=data.get("autoUpdate", False),
147
+ use_new_runner=data.get("useNewRunner", False),
148
+ config=data,
139
149
  )
140
150
 
141
151
 
@@ -366,6 +376,18 @@ def claim_issue(cfg: RepoConfig, issue: dict[str, Any], note: str) -> None:
366
376
  touch_heartbeat(cfg, num)
367
377
 
368
378
 
379
+ def clear_active_issue(cfg: RepoConfig, issue_number: int) -> None:
380
+ """Clear an issue from active issues state."""
381
+ state = load_state(cfg.root)
382
+ active_issues = state.get("activeIssues", {})
383
+ if isinstance(active_issues, dict):
384
+ issue_key = str(issue_number)
385
+ if issue_key in active_issues:
386
+ del active_issues[issue_key]
387
+ state["activeIssues"] = active_issues
388
+ write_state(cfg.root, state)
389
+
390
+
369
391
  def list_in_progress_issues(cfg: RepoConfig, limit: int = 20) -> list[dict[str, Any]]:
370
392
  cmd = [
371
393
  "gh",
@@ -736,6 +758,49 @@ def maybe_mark_blocked(cfg: RepoConfig, issue: dict[str, Any]) -> None:
736
758
  )
737
759
 
738
760
 
761
+ def run_issue(cfg: RepoConfig, issue_number: int) -> bool:
762
+ """
763
+ Run the agent on an issue.
764
+
765
+ Uses either the new Python runner or legacy bash script
766
+ based on configuration.
767
+ """
768
+ if cfg.use_new_runner:
769
+ return run_issue_new(cfg, issue_number)
770
+ else:
771
+ return run_issue_legacy(cfg, issue_number)
772
+
773
+
774
+ def run_issue_new(cfg: RepoConfig, issue_number: int) -> bool:
775
+ """Run issue using new Python state machine runner."""
776
+ # Touch heartbeat before starting
777
+ touch_heartbeat(cfg, issue_number)
778
+
779
+ runner = IssueRunner(
780
+ repo=cfg.repo,
781
+ repo_root=cfg.root,
782
+ config=cfg.config
783
+ )
784
+
785
+ try:
786
+ success = runner.run(issue_number)
787
+ finally:
788
+ # Clear from active issues when done
789
+ clear_active_issue(cfg, issue_number)
790
+
791
+ return success
792
+
793
+
794
+ def run_issue_legacy(cfg: RepoConfig, issue_number: int) -> bool:
795
+ """Run issue using legacy bash script (existing behavior)."""
796
+ script_path = Path(__file__).parent / "run_opencode_issue.sh"
797
+ result = subprocess.run(
798
+ [str(script_path), str(cfg.root), str(issue_number)],
799
+ cwd=cfg.root
800
+ )
801
+ return result.returncode == 0
802
+
803
+
739
804
  def run_cycle(
740
805
  all_configs: list[RepoConfig],
741
806
  dry_run: bool = False,
@@ -816,18 +881,9 @@ def run_cycle(
816
881
  start_msg = f"🚀 Autopilot is now starting work on issue #{issue['number']}.\n\nI'll post regular progress updates as I work through the implementation."
817
882
  sh(["gh", "issue", "comment", str(issue["number"]), "--repo", cfg.repo, "--body", start_msg])
818
883
 
819
- # If agent==opencode, delegate to bash script
884
+ # Run the issue using the appropriate runner
820
885
  if cfg.agent == "opencode":
821
- # Script is in the same directory as this Python file
822
- script_dir = Path(__file__).parent
823
- script_path = script_dir / "run_opencode_issue.sh"
824
- sh(
825
- [
826
- str(script_path),
827
- str(cfg.root),
828
- str(issue["number"]),
829
- ]
830
- )
886
+ run_issue(cfg, issue["number"])
831
887
 
832
888
  # Check if autopilot needs to update
833
889
  if not dry_run and check_autopilot_needs_update():