autopilot-code 1.0.0 → 2.0.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.
@@ -93,8 +93,7 @@ class RepoConfig:
93
93
  auto_fix_checks: bool
94
94
  auto_fix_checks_max_attempts: int
95
95
  auto_update: bool
96
- use_new_runner: bool = False
97
- config: dict[str, Any] = None
96
+ config: dict[str, Any] | None = None
98
97
 
99
98
 
100
99
  def load_config(repo_root: Path) -> RepoConfig | None:
@@ -112,7 +111,10 @@ def load_config(repo_root: Path) -> RepoConfig | None:
112
111
  allowed_merge_users = list(data.get("allowedMergeUsers", []))
113
112
 
114
113
  if auto_merge and not allowed_merge_users:
115
- print(f"Warning: [{data.get('repo', 'unknown')}] autoMerge is enabled but allowedMergeUsers is empty. Disabling autoMerge for this repo.", flush=True)
114
+ print(
115
+ f"Warning: [{data.get('repo', 'unknown')}] autoMerge is enabled but allowedMergeUsers is empty. Disabling autoMerge for this repo.",
116
+ flush=True,
117
+ )
116
118
  auto_merge = False
117
119
 
118
120
  auto_resolve_conflicts = data.get("autoResolveConflicts", True)
@@ -144,7 +146,6 @@ def load_config(repo_root: Path) -> RepoConfig | None:
144
146
  auto_fix_checks=auto_fix_checks,
145
147
  auto_fix_checks_max_attempts=auto_fix_checks_max_attempts,
146
148
  auto_update=data.get("autoUpdate", False),
147
- use_new_runner=data.get("useNewRunner", False),
148
149
  config=data,
149
150
  )
150
151
 
@@ -272,7 +273,9 @@ def load_global_state() -> dict[str, Any]:
272
273
 
273
274
  def write_global_state(state: dict[str, Any]) -> None:
274
275
  GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
275
- GLOBAL_STATE_FILE.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
276
+ GLOBAL_STATE_FILE.write_text(
277
+ json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8"
278
+ )
276
279
 
277
280
 
278
281
  def flag_autopilot_needs_update() -> None:
@@ -304,14 +307,14 @@ def is_autopilot_repo(repo: str) -> bool:
304
307
  def update_autopilot() -> bool:
305
308
  """Update autopilot globally and restart service."""
306
309
  print("[autopilot] Updating autopilot-code...", flush=True)
307
-
310
+
308
311
  try:
309
312
  sh(["npm", "install", "-g", "autopilot-code"], check=True)
310
313
  print("[autopilot] Successfully installed updated autopilot-code", flush=True)
311
314
  except Exception as e:
312
315
  print(f"[autopilot] Failed to install autopilot-code: {e}", flush=True)
313
316
  return False
314
-
317
+
315
318
  try:
316
319
  sh(["systemctl", "--user", "restart", "autopilot"], check=True)
317
320
  print("[autopilot] Restarted autopilot service", flush=True)
@@ -436,7 +439,7 @@ def find_opencode() -> str | None:
436
439
  opencode = shutil.which("opencode")
437
440
  if opencode:
438
441
  return opencode
439
-
442
+
440
443
  # 2. Check common nvm locations
441
444
  home = Path.home()
442
445
  nvm_dir = home / ".nvm" / "versions" / "node"
@@ -445,7 +448,7 @@ def find_opencode() -> str | None:
445
448
  candidate = node_version / "bin" / "opencode"
446
449
  if candidate.exists() and candidate.is_file():
447
450
  return str(candidate)
448
-
451
+
449
452
  # 3. Other common locations
450
453
  for path in [
451
454
  home / ".local" / "bin" / "opencode",
@@ -454,28 +457,36 @@ def find_opencode() -> str | None:
454
457
  ]:
455
458
  if path.exists() and path.is_file():
456
459
  return str(path)
457
-
460
+
458
461
  return None
459
462
 
460
463
 
461
- def resolve_merge_conflicts(cfg: RepoConfig, issue_number: int, pr: dict[str, Any], attempt: int) -> bool:
464
+ def resolve_merge_conflicts(
465
+ cfg: RepoConfig, issue_number: int, pr: dict[str, Any], attempt: int
466
+ ) -> bool:
462
467
  """Attempt to resolve merge conflicts using the coding agent. Returns True if resolved."""
463
468
  if cfg.agent != "opencode":
464
- print(f"[{cfg.repo}] Cannot resolve conflicts: agent '{cfg.agent}' not supported (only 'opencode')", flush=True)
469
+ print(
470
+ f"[{cfg.repo}] Cannot resolve conflicts: agent '{cfg.agent}' not supported (only 'opencode')",
471
+ flush=True,
472
+ )
465
473
  return False
466
-
474
+
467
475
  opencode_bin = find_opencode()
468
476
  if not opencode_bin:
469
477
  print(f"[{cfg.repo}] Cannot resolve conflicts: opencode not found", flush=True)
470
478
  return False
471
-
479
+
472
480
  pr_number = pr["number"]
473
481
  branch = pr.get("headRefName", f"autopilot/issue-{issue_number}")
474
482
  worktree = Path(f"/tmp/autopilot-issue-{issue_number}")
475
-
476
- print(f"[{cfg.repo}] Attempting to resolve merge conflicts for PR #{pr_number} (attempt {attempt}/{cfg.conflict_resolution_max_attempts})", flush=True)
483
+
484
+ print(
485
+ f"[{cfg.repo}] Attempting to resolve merge conflicts for PR #{pr_number} (attempt {attempt}/{cfg.conflict_resolution_max_attempts})",
486
+ flush=True,
487
+ )
477
488
  print(f"[{cfg.repo}] Using opencode: {opencode_bin}", flush=True)
478
-
489
+
479
490
  # Ensure worktree exists and is up to date
480
491
  if not worktree.exists():
481
492
  print(f"[{cfg.repo}] Creating worktree at {worktree}", flush=True)
@@ -484,38 +495,50 @@ def resolve_merge_conflicts(cfg: RepoConfig, issue_number: int, pr: dict[str, An
484
495
  except Exception as e:
485
496
  print(f"[{cfg.repo}] Failed to create worktree: {e}", flush=True)
486
497
  return False
487
-
498
+
488
499
  # Fetch latest and attempt rebase/merge
489
500
  try:
490
501
  sh(["git", "fetch", "origin", "main"], cwd=worktree)
491
-
502
+
492
503
  # Try to rebase onto main
493
504
  rebase_result = subprocess.run(
494
505
  ["git", "rebase", "origin/main"],
495
506
  cwd=str(worktree),
496
507
  capture_output=True,
497
- text=True
508
+ text=True,
498
509
  )
499
-
510
+
500
511
  if rebase_result.returncode == 0:
501
512
  # No conflicts, push and we're done
502
513
  sh(["git", "push", "-f", "origin", branch], cwd=worktree)
503
- print(f"[{cfg.repo}] Rebase successful, no conflicts to resolve", flush=True)
514
+ print(
515
+ f"[{cfg.repo}] Rebase successful, no conflicts to resolve", flush=True
516
+ )
504
517
  return True
505
-
518
+
506
519
  # There are conflicts - abort the rebase first
507
- subprocess.run(["git", "rebase", "--abort"], cwd=str(worktree), capture_output=True)
508
-
520
+ subprocess.run(
521
+ ["git", "rebase", "--abort"], cwd=str(worktree), capture_output=True
522
+ )
523
+
509
524
  # Get list of files that would conflict
510
- merge_base = sh(["git", "merge-base", "HEAD", "origin/main"], cwd=worktree).strip()
511
- diff_output = sh(["git", "diff", "--name-only", merge_base, "origin/main"], cwd=worktree, check=False)
512
- conflicting_files = diff_output.strip().split("\n") if diff_output.strip() else []
513
-
525
+ merge_base = sh(
526
+ ["git", "merge-base", "HEAD", "origin/main"], cwd=worktree
527
+ ).strip()
528
+ diff_output = sh(
529
+ ["git", "diff", "--name-only", merge_base, "origin/main"],
530
+ cwd=worktree,
531
+ check=False,
532
+ )
533
+ conflicting_files = (
534
+ diff_output.strip().split("\n") if diff_output.strip() else []
535
+ )
536
+
514
537
  # Build prompt for the agent
515
538
  conflict_prompt = f"""This PR has merge conflicts with main. Please resolve them.
516
539
 
517
540
  Branch: {branch}
518
- Files potentially conflicting: {', '.join(conflicting_files[:10])}
541
+ Files potentially conflicting: {", ".join(conflicting_files[:10])}
519
542
 
520
543
  Steps:
521
544
  1. Run `git fetch origin main`
@@ -532,36 +555,60 @@ Work rules:
532
555
  - Preserve the intent of both the PR changes and main branch changes
533
556
  - If unsure about a conflict, prefer the PR's changes but ensure compatibility
534
557
  """
535
-
558
+
536
559
  print(f"[{cfg.repo}] Running opencode to resolve conflicts...", flush=True)
537
-
560
+
538
561
  # Run opencode in the worktree
539
562
  agent_result = subprocess.run(
540
563
  [opencode_bin, "run", conflict_prompt],
541
564
  cwd=str(worktree),
542
565
  capture_output=True,
543
566
  text=True,
544
- timeout=600 # 10 minute timeout
567
+ timeout=600, # 10 minute timeout
545
568
  )
546
-
569
+
547
570
  if agent_result.returncode != 0:
548
- print(f"[{cfg.repo}] Opencode failed: {agent_result.stderr[:500]}", flush=True)
571
+ print(
572
+ f"[{cfg.repo}] Opencode failed: {agent_result.stderr[:500]}", flush=True
573
+ )
549
574
  return False
550
-
575
+
551
576
  # Check if conflicts are resolved by checking PR status
552
577
  time.sleep(5) # Give GitHub time to update
553
- cmd = ["gh", "pr", "view", str(pr_number), "--repo", cfg.repo, "--json", "mergeable,mergeStateStatus"]
578
+ cmd = [
579
+ "gh",
580
+ "pr",
581
+ "view",
582
+ str(pr_number),
583
+ "--repo",
584
+ cfg.repo,
585
+ "--json",
586
+ "mergeable,mergeStateStatus",
587
+ ]
554
588
  pr_status = json.loads(sh(cmd))
555
-
589
+
556
590
  if pr_status.get("mergeable") == "MERGEABLE":
557
591
  print(f"[{cfg.repo}] Conflicts resolved successfully!", flush=True)
558
- sh(["gh", "issue", "comment", str(issue_number), "--repo", cfg.repo,
559
- "--body", f"✅ Autopilot resolved merge conflicts (attempt {attempt})."])
592
+ sh(
593
+ [
594
+ "gh",
595
+ "issue",
596
+ "comment",
597
+ str(issue_number),
598
+ "--repo",
599
+ cfg.repo,
600
+ "--body",
601
+ f"✅ Autopilot resolved merge conflicts (attempt {attempt}).",
602
+ ]
603
+ )
560
604
  return True
561
605
  else:
562
- print(f"[{cfg.repo}] Conflicts still present after resolution attempt", flush=True)
606
+ print(
607
+ f"[{cfg.repo}] Conflicts still present after resolution attempt",
608
+ flush=True,
609
+ )
563
610
  return False
564
-
611
+
565
612
  except subprocess.TimeoutExpired:
566
613
  print(f"[{cfg.repo}] Conflict resolution timed out", flush=True)
567
614
  return False
@@ -574,47 +621,77 @@ def try_merge_pr(cfg: RepoConfig, issue_number: int, pr: dict[str, Any]) -> bool
574
621
  """Attempt to merge a PR if it's ready. Returns True if merged or closed."""
575
622
  pr_number = pr["number"]
576
623
  pr_url = pr["url"]
577
-
624
+
578
625
  # If already merged or closed, mark issue as done
579
626
  if pr.get("mergedAt") or pr.get("closed"):
580
- print(f"[{cfg.repo}] PR #{pr_number} is already merged/closed, marking issue #{issue_number} as done", flush=True)
581
- sh(["gh", "issue", "edit", str(issue_number), "--repo", cfg.repo,
582
- "--add-label", cfg.label_done, "--remove-label", cfg.label_in_progress])
627
+ print(
628
+ f"[{cfg.repo}] PR #{pr_number} is already merged/closed, marking issue #{issue_number} as done",
629
+ flush=True,
630
+ )
631
+ sh(
632
+ [
633
+ "gh",
634
+ "issue",
635
+ "edit",
636
+ str(issue_number),
637
+ "--repo",
638
+ cfg.repo,
639
+ "--add-label",
640
+ cfg.label_done,
641
+ "--remove-label",
642
+ cfg.label_in_progress,
643
+ ]
644
+ )
583
645
  sh(["gh", "issue", "close", str(issue_number), "--repo", cfg.repo])
584
646
  return True
585
-
647
+
586
648
  # Check if PR is mergeable
587
649
  mergeable = pr.get("mergeable")
588
650
  merge_state = pr.get("mergeStateStatus")
589
-
651
+
590
652
  # Handle merge conflicts
591
653
  if mergeable == "CONFLICTING" or merge_state == "DIRTY":
592
654
  print(f"[{cfg.repo}] PR #{pr_number} has merge conflicts", flush=True)
593
-
655
+
594
656
  if not cfg.auto_resolve_conflicts:
595
657
  print(f"[{cfg.repo}] Auto-resolve conflicts disabled, skipping", flush=True)
596
658
  return False
597
-
659
+
598
660
  # Load state to track resolution attempts
599
661
  state = load_state(cfg.root)
600
662
  conflict_key = f"conflict_attempts_{issue_number}"
601
663
  attempts = state.get(conflict_key, 0)
602
-
664
+
603
665
  if attempts >= cfg.conflict_resolution_max_attempts:
604
- print(f"[{cfg.repo}] Max conflict resolution attempts ({cfg.conflict_resolution_max_attempts}) reached", flush=True)
666
+ print(
667
+ f"[{cfg.repo}] Max conflict resolution attempts ({cfg.conflict_resolution_max_attempts}) reached",
668
+ flush=True,
669
+ )
605
670
  if attempts == cfg.conflict_resolution_max_attempts:
606
671
  # Only notify once when we hit the limit
607
- sh(["gh", "issue", "comment", str(issue_number), "--repo", cfg.repo,
608
- "--body", f"❌ Autopilot failed to resolve merge conflicts after {attempts} attempts. Manual resolution required."])
609
- state[conflict_key] = attempts + 1 # Increment to avoid repeat notifications
672
+ sh(
673
+ [
674
+ "gh",
675
+ "issue",
676
+ "comment",
677
+ str(issue_number),
678
+ "--repo",
679
+ cfg.repo,
680
+ "--body",
681
+ f"❌ Autopilot failed to resolve merge conflicts after {attempts} attempts. Manual resolution required.",
682
+ ]
683
+ )
684
+ state[conflict_key] = (
685
+ attempts + 1
686
+ ) # Increment to avoid repeat notifications
610
687
  write_state(cfg.root, state)
611
688
  return False
612
-
689
+
613
690
  # Attempt to resolve
614
691
  attempts += 1
615
692
  state[conflict_key] = attempts
616
693
  write_state(cfg.root, state)
617
-
694
+
618
695
  if resolve_merge_conflicts(cfg, issue_number, pr, attempts):
619
696
  # Reset attempts on success
620
697
  del state[conflict_key]
@@ -623,70 +700,121 @@ def try_merge_pr(cfg: RepoConfig, issue_number: int, pr: dict[str, Any]) -> bool
623
700
  return False # Return False to re-check on next cycle
624
701
  else:
625
702
  return False
626
-
703
+
627
704
  if mergeable != "MERGEABLE":
628
- print(f"[{cfg.repo}] PR #{pr_number} is not mergeable (state: {mergeable}/{merge_state}), skipping", flush=True)
705
+ print(
706
+ f"[{cfg.repo}] PR #{pr_number} is not mergeable (state: {mergeable}/{merge_state}), skipping",
707
+ flush=True,
708
+ )
629
709
  return False
630
-
710
+
631
711
  # Get PR check status
632
712
  cmd = [
633
- "gh", "pr", "view", str(pr_number), "--repo", cfg.repo,
634
- "--json", "statusCheckRollup",
713
+ "gh",
714
+ "pr",
715
+ "view",
716
+ str(pr_number),
717
+ "--repo",
718
+ cfg.repo,
719
+ "--json",
720
+ "statusCheckRollup",
635
721
  ]
636
722
  try:
637
723
  pr_details = json.loads(sh(cmd))
638
724
  checks = pr_details.get("statusCheckRollup", [])
639
-
725
+
640
726
  if not checks:
641
727
  # No checks configured, can merge
642
728
  check_status = "PASSED"
643
729
  else:
644
730
  # Check if any checks are pending or failed
645
731
  has_failure = any(c.get("conclusion") == "FAILURE" for c in checks)
646
- has_pending = any(c.get("conclusion") in ("PENDING", "QUEUED", None) for c in checks)
647
-
732
+ has_pending = any(
733
+ c.get("conclusion") in ("PENDING", "QUEUED", None) for c in checks
734
+ )
735
+
648
736
  # If all checks are pending/queued and there are more than 0, it's pending
649
737
  # But we should also check the rollup state if available
650
- rollup_state = pr_details.get("statusCheckRollup", {}).get("state") if isinstance(pr_details.get("statusCheckRollup"), dict) else None
651
-
738
+ rollup_state = (
739
+ pr_details.get("statusCheckRollup", {}).get("state")
740
+ if isinstance(pr_details.get("statusCheckRollup"), dict)
741
+ else None
742
+ )
743
+
652
744
  if has_failure or rollup_state == "FAILURE":
653
745
  check_status = "FAILED"
654
746
  elif has_pending or rollup_state == "PENDING":
655
747
  check_status = "PENDING"
656
748
  else:
657
749
  check_status = "PASSED"
658
-
750
+
659
751
  if check_status != "PASSED":
660
- print(f"[{cfg.repo}] PR #{pr_number} checks not ready (status: {check_status})", flush=True)
752
+ print(
753
+ f"[{cfg.repo}] PR #{pr_number} checks not ready (status: {check_status})",
754
+ flush=True,
755
+ )
661
756
  return False
662
-
757
+
663
758
  # Checks passed, attempt merge
664
759
  print(f"[{cfg.repo}] PR #{pr_number} is ready, attempting merge...", flush=True)
665
-
760
+
666
761
  merge_method = "squash" # Default, could be read from config if needed
667
- merge_cmd = ["gh", "pr", "merge", pr_url, "--delete-branch", f"--{merge_method}"]
668
-
762
+ merge_cmd = [
763
+ "gh",
764
+ "pr",
765
+ "merge",
766
+ pr_url,
767
+ "--delete-branch",
768
+ f"--{merge_method}",
769
+ ]
770
+
669
771
  try:
670
772
  sh(merge_cmd)
671
773
  print(f"[{cfg.repo}] Successfully merged PR #{pr_number}!", flush=True)
672
-
774
+
673
775
  # Comment and close issue
674
776
  success_msg = f"✅ Autopilot successfully merged PR #{pr_number} using {merge_method} method.\n\nThe fix has been merged to the main branch."
675
- sh(["gh", "issue", "comment", str(issue_number), "--repo", cfg.repo, "--body", success_msg])
676
- sh(["gh", "issue", "edit", str(issue_number), "--repo", cfg.repo,
677
- "--add-label", cfg.label_done, "--remove-label", cfg.label_in_progress])
777
+ sh(
778
+ [
779
+ "gh",
780
+ "issue",
781
+ "comment",
782
+ str(issue_number),
783
+ "--repo",
784
+ cfg.repo,
785
+ "--body",
786
+ success_msg,
787
+ ]
788
+ )
789
+ sh(
790
+ [
791
+ "gh",
792
+ "issue",
793
+ "edit",
794
+ str(issue_number),
795
+ "--repo",
796
+ cfg.repo,
797
+ "--add-label",
798
+ cfg.label_done,
799
+ "--remove-label",
800
+ cfg.label_in_progress,
801
+ ]
802
+ )
678
803
  sh(["gh", "issue", "close", str(issue_number), "--repo", cfg.repo])
679
-
804
+
680
805
  # Flag autopilot for update if we merged our own repo
681
806
  if is_autopilot_repo(cfg.repo):
682
- print(f"[{cfg.repo}] Merged PR to autopilot repo, flagging for self-update", flush=True)
807
+ print(
808
+ f"[{cfg.repo}] Merged PR to autopilot repo, flagging for self-update",
809
+ flush=True,
810
+ )
683
811
  flag_autopilot_needs_update()
684
-
812
+
685
813
  return True
686
814
  except Exception as e:
687
815
  print(f"[{cfg.repo}] Failed to merge PR #{pr_number}: {e}", flush=True)
688
816
  return False
689
-
817
+
690
818
  except Exception as e:
691
819
  print(f"[{cfg.repo}] Error checking PR #{pr_number}: {e}", flush=True)
692
820
  return False
@@ -696,34 +824,46 @@ def clean_completed_issues_from_state(cfg: RepoConfig) -> None:
696
824
  """Remove completed issues from state.json."""
697
825
  state = load_state(cfg.root)
698
826
  active_issues = state.get("activeIssues", {})
699
-
827
+
700
828
  if not isinstance(active_issues, dict):
701
829
  return
702
-
830
+
703
831
  # Check each issue to see if it's still open
704
832
  issues_to_remove = []
705
833
  for issue_num_str, issue_data in active_issues.items():
706
834
  if not isinstance(issue_data, dict):
707
835
  continue
708
-
836
+
709
837
  issue_num = int(issue_data.get("number", -1))
710
838
  if issue_num <= 0:
711
839
  continue
712
-
840
+
713
841
  # Check if issue is closed
714
842
  try:
715
- cmd = ["gh", "issue", "view", str(issue_num), "--repo", cfg.repo, "--json", "state,closed"]
843
+ cmd = [
844
+ "gh",
845
+ "issue",
846
+ "view",
847
+ str(issue_num),
848
+ "--repo",
849
+ cfg.repo,
850
+ "--json",
851
+ "state,closed",
852
+ ]
716
853
  result = json.loads(sh(cmd, check=False))
717
854
  if result.get("state") == "CLOSED" or result.get("closed"):
718
- print(f"[{cfg.repo}] Issue #{issue_num} is closed, removing from state", flush=True)
855
+ print(
856
+ f"[{cfg.repo}] Issue #{issue_num} is closed, removing from state",
857
+ flush=True,
858
+ )
719
859
  issues_to_remove.append(issue_num_str)
720
860
  except Exception:
721
861
  pass
722
-
862
+
723
863
  # Remove completed issues
724
864
  for issue_num_str in issues_to_remove:
725
865
  del active_issues[issue_num_str]
726
-
866
+
727
867
  if issues_to_remove:
728
868
  state["activeIssues"] = active_issues
729
869
  write_state(cfg.root, state)
@@ -760,55 +900,20 @@ def maybe_mark_blocked(cfg: RepoConfig, issue: dict[str, Any]) -> None:
760
900
 
761
901
  def run_issue(cfg: RepoConfig, issue_number: int) -> bool:
762
902
  """
763
- Run the agent on an issue.
764
-
765
- Uses either the new Python runner or legacy bash script
766
- based on configuration.
903
+ Run the agent on an issue using the Python state machine runner.
767
904
  """
768
- use_new_runner = cfg.config.get("useNewRunner", True)
769
-
770
- if not use_new_runner:
771
- import logging
772
- logger = logging.getLogger(__name__)
773
- logger.warning(
774
- "Legacy bash runner is deprecated and will be removed in a future version. "
775
- "Please migrate to new runner by removing 'useNewRunner: false' from config."
776
- )
777
-
778
- if cfg.use_new_runner:
779
- return run_issue_new(cfg, issue_number)
780
- else:
781
- return run_issue_legacy(cfg, issue_number)
782
-
783
-
784
- def run_issue_new(cfg: RepoConfig, issue_number: int) -> bool:
785
- """Run issue using new Python state machine runner."""
786
905
  # Touch heartbeat before starting
787
906
  touch_heartbeat(cfg, issue_number)
788
-
789
- runner = IssueRunner(
790
- repo=cfg.repo,
791
- repo_root=cfg.root,
792
- config=cfg.config
793
- )
794
-
907
+
908
+ runner = IssueRunner(repo=cfg.repo, repo_root=cfg.root, config=cfg.config or {})
909
+
795
910
  try:
796
911
  success = runner.run(issue_number)
797
912
  finally:
798
913
  # Clear from active issues when done
799
914
  clear_active_issue(cfg, issue_number)
800
-
801
- return success
802
-
803
915
 
804
- def run_issue_legacy(cfg: RepoConfig, issue_number: int) -> bool:
805
- """Run issue using legacy bash script (existing behavior)."""
806
- script_path = Path(__file__).parent / "run_opencode_issue.sh"
807
- result = subprocess.run(
808
- [str(script_path), str(cfg.root), str(issue_number)],
809
- cwd=cfg.root
810
- )
811
- return result.returncode == 0
916
+ return success
812
917
 
813
918
 
814
919
  def run_cycle(
@@ -823,30 +928,36 @@ def run_cycle(
823
928
 
824
929
  for cfg in all_configs:
825
930
  print(f"[{cfg.repo}] Scanning for issues...", flush=True)
826
-
931
+
827
932
  # 0) Clean up completed issues from state
828
933
  if not dry_run:
829
934
  clean_completed_issues_from_state(cfg)
830
-
935
+
831
936
  # 1) Check in-progress issues
832
937
  inprog = list_in_progress_issues(cfg)
833
938
  for it in inprog:
834
939
  issue_num = int(it["number"])
835
-
940
+
836
941
  # First check if we should mark as blocked due to stale heartbeat
837
942
  if not dry_run:
838
943
  # Only mark blocked if heartbeat is stale
839
944
  if not is_heartbeat_fresh(cfg, issue_num):
840
945
  maybe_mark_blocked(cfg, it)
841
946
  continue
842
-
947
+
843
948
  # If heartbeat is fresh and autoMerge is enabled, try to merge PR
844
949
  if cfg.auto_merge:
845
950
  pr = check_pr_for_issue(cfg, issue_num)
846
951
  if pr:
847
- print(f"[{cfg.repo}] Found PR for issue #{issue_num}, checking if ready to merge...", flush=True)
952
+ print(
953
+ f"[{cfg.repo}] Found PR for issue #{issue_num}, checking if ready to merge...",
954
+ flush=True,
955
+ )
848
956
  if try_merge_pr(cfg, issue_num, pr):
849
- print(f"[{cfg.repo}] Issue #{issue_num} completed!", flush=True)
957
+ print(
958
+ f"[{cfg.repo}] Issue #{issue_num} completed!",
959
+ flush=True,
960
+ )
850
961
  else:
851
962
  # Touch heartbeat to keep it alive while waiting for checks
852
963
  touch_heartbeat(cfg, issue_num)
@@ -873,11 +984,14 @@ def run_cycle(
873
984
  continue
874
985
 
875
986
  for issue in issues[:max_to_claim]:
876
- print(f"[{cfg.repo}] next issue: #{issue['number']} {issue['title']}", flush=True)
987
+ print(
988
+ f"[{cfg.repo}] next issue: #{issue['number']} {issue['title']}",
989
+ flush=True,
990
+ )
877
991
  if dry_run:
878
992
  claimed_count += 1
879
993
  continue
880
-
994
+
881
995
  # Post initial claim comment
882
996
  claim_msg = (
883
997
  f"Autopilot claimed this issue at {time.strftime('%Y-%m-%d %H:%M:%S %Z')}.\n\n"
@@ -889,7 +1003,18 @@ def run_cycle(
889
1003
 
890
1004
  # Post comment indicating agent is about to start
891
1005
  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."
892
- sh(["gh", "issue", "comment", str(issue["number"]), "--repo", cfg.repo, "--body", start_msg])
1006
+ sh(
1007
+ [
1008
+ "gh",
1009
+ "issue",
1010
+ "comment",
1011
+ str(issue["number"]),
1012
+ "--repo",
1013
+ cfg.repo,
1014
+ "--body",
1015
+ start_msg,
1016
+ ]
1017
+ )
893
1018
 
894
1019
  # Run the issue using the appropriate runner
895
1020
  if cfg.agent == "opencode":
@@ -898,19 +1023,27 @@ def run_cycle(
898
1023
  # Check if autopilot needs to update
899
1024
  if not dry_run and check_autopilot_needs_update():
900
1025
  print("[autopilot] Detected need for self-update", flush=True)
901
-
1026
+
902
1027
  # Check if any repo has auto_update enabled
903
1028
  any_auto_update = any(cfg.auto_update for cfg in all_configs)
904
-
1029
+
905
1030
  if any_auto_update:
906
1031
  print("[autopilot] Performing self-update...", flush=True)
907
1032
  if update_autopilot():
908
1033
  clear_autopilot_update_flag()
909
- print("[autopilot] Self-update completed, service restarted", flush=True)
1034
+ print(
1035
+ "[autopilot] Self-update completed, service restarted", flush=True
1036
+ )
910
1037
  else:
911
- print("[autopilot] Self-update failed, flag preserved for next cycle", flush=True)
1038
+ print(
1039
+ "[autopilot] Self-update failed, flag preserved for next cycle",
1040
+ flush=True,
1041
+ )
912
1042
  else:
913
- print("[autopilot] auto_update is disabled for all repos, skipping self-update", flush=True)
1043
+ print(
1044
+ "[autopilot] auto_update is disabled for all repos, skipping self-update",
1045
+ flush=True,
1046
+ )
914
1047
  clear_autopilot_update_flag()
915
1048
 
916
1049
  return claimed_count
@@ -949,8 +1082,14 @@ def main() -> int:
949
1082
  default=None,
950
1083
  help="Run in loop mode with this interval between cycles (for foreground service mode)",
951
1084
  )
952
- ap.add_argument("--min-priority", help="Override minPriority for all discovered repos")
953
- ap.add_argument("--ignore-labels", nargs="+", help="Override ignoreIssueLabels for all discovered repos")
1085
+ ap.add_argument(
1086
+ "--min-priority", help="Override minPriority for all discovered repos"
1087
+ )
1088
+ ap.add_argument(
1089
+ "--ignore-labels",
1090
+ nargs="+",
1091
+ help="Override ignoreIssueLabels for all discovered repos",
1092
+ )
954
1093
  args = ap.parse_args()
955
1094
 
956
1095
  all_configs: list[RepoConfig] = []
@@ -963,14 +1102,14 @@ def main() -> int:
963
1102
  print(f"Warning: root path {root} is not a directory, skipping", flush=True)
964
1103
  continue
965
1104
  configs = discover_repos(root, max_depth=args.max_depth)
966
-
1105
+
967
1106
  # Apply overrides
968
1107
  for cfg in configs:
969
1108
  if args.min_priority:
970
1109
  cfg.min_priority = args.min_priority
971
1110
  if args.ignore_labels:
972
1111
  cfg.ignore_issue_labels = args.ignore_labels
973
-
1112
+
974
1113
  all_configs.extend(configs)
975
1114
 
976
1115
  if not all_configs:
@@ -987,13 +1126,18 @@ def main() -> int:
987
1126
 
988
1127
  def signal_handler(signum: int, frame: Any) -> None:
989
1128
  nonlocal shutdown_requested
990
- print("\nShutdown requested (Ctrl+C), finishing current cycle...", flush=True)
1129
+ print(
1130
+ "\nShutdown requested (Ctrl+C), finishing current cycle...", flush=True
1131
+ )
991
1132
  shutdown_requested = True
992
1133
 
993
1134
  signal.signal(signal.SIGINT, signal_handler)
994
1135
  signal.signal(signal.SIGTERM, signal_handler)
995
1136
 
996
- print(f"Running in foreground mode with {args.interval_seconds}s interval", flush=True)
1137
+ print(
1138
+ f"Running in foreground mode with {args.interval_seconds}s interval",
1139
+ flush=True,
1140
+ )
997
1141
  print("Press Ctrl+C to shut down cleanly\n", flush=True)
998
1142
 
999
1143
  cycle_num = 0