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.
- package/README.md +1 -3
- package/package.json +1 -1
- package/scripts/run_autopilot.py +293 -149
- package/templates/autopilot.json +0 -1
- package/scripts/run_opencode_issue.sh +0 -690
package/scripts/run_autopilot.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
511
|
-
|
|
512
|
-
|
|
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: {
|
|
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(
|
|
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 = [
|
|
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(
|
|
559
|
-
|
|
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(
|
|
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(
|
|
581
|
-
|
|
582
|
-
|
|
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(
|
|
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(
|
|
608
|
-
|
|
609
|
-
|
|
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(
|
|
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",
|
|
634
|
-
"
|
|
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(
|
|
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 =
|
|
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(
|
|
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 = [
|
|
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(
|
|
676
|
-
|
|
677
|
-
|
|
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(
|
|
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 = [
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1034
|
+
print(
|
|
1035
|
+
"[autopilot] Self-update completed, service restarted", flush=True
|
|
1036
|
+
)
|
|
910
1037
|
else:
|
|
911
|
-
print(
|
|
1038
|
+
print(
|
|
1039
|
+
"[autopilot] Self-update failed, flag preserved for next cycle",
|
|
1040
|
+
flush=True,
|
|
1041
|
+
)
|
|
912
1042
|
else:
|
|
913
|
-
print(
|
|
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(
|
|
953
|
-
|
|
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(
|
|
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(
|
|
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
|