autopilot-code 0.7.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,6 +1,6 @@
1
1
  {
2
2
  "name": "autopilot-code",
3
- "version": "0.7.0",
3
+ "version": "0.8.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)