autopilot-code 0.0.16 → 0.0.18

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.0.16",
3
+ "version": "0.0.18",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -24,6 +24,7 @@ import time
24
24
  from dataclasses import dataclass
25
25
  from pathlib import Path
26
26
  from typing import Any
27
+ import shutil
27
28
 
28
29
  STATE_DIR = ".autopilot"
29
30
  STATE_FILE = "state.json"
@@ -329,7 +330,7 @@ def check_pr_for_issue(cfg: RepoConfig, issue_number: int) -> dict[str, Any] | N
329
330
  "--head",
330
331
  branch,
331
332
  "--json",
332
- "number,title,state,mergeable,mergeStateStatus,url,merged,closed",
333
+ "number,title,state,mergeable,mergeStateStatus,url,merged,closed,headRefName",
333
334
  "--limit",
334
335
  "1",
335
336
  ]
@@ -341,6 +342,146 @@ def check_pr_for_issue(cfg: RepoConfig, issue_number: int) -> dict[str, Any] | N
341
342
  return None
342
343
 
343
344
 
345
+ def find_opencode() -> str | None:
346
+ """Find opencode binary in PATH or common locations."""
347
+ # 1. Check PATH first
348
+ opencode = shutil.which("opencode")
349
+ if opencode:
350
+ return opencode
351
+
352
+ # 2. Check common nvm locations
353
+ home = Path.home()
354
+ nvm_dir = home / ".nvm" / "versions" / "node"
355
+ if nvm_dir.exists():
356
+ for node_version in sorted(nvm_dir.iterdir(), reverse=True):
357
+ candidate = node_version / "bin" / "opencode"
358
+ if candidate.exists() and candidate.is_file():
359
+ return str(candidate)
360
+
361
+ # 3. Other common locations
362
+ for path in [
363
+ home / ".local" / "bin" / "opencode",
364
+ Path("/usr/local/bin/opencode"),
365
+ home / ".npm-global" / "bin" / "opencode",
366
+ ]:
367
+ if path.exists() and path.is_file():
368
+ return str(path)
369
+
370
+ return None
371
+
372
+
373
+ def resolve_merge_conflicts(cfg: RepoConfig, issue_number: int, pr: dict[str, Any], attempt: int) -> bool:
374
+ """Attempt to resolve merge conflicts using the coding agent. Returns True if resolved."""
375
+ if cfg.agent != "opencode":
376
+ print(f"[{cfg.repo}] Cannot resolve conflicts: agent '{cfg.agent}' not supported (only 'opencode')", flush=True)
377
+ return False
378
+
379
+ opencode_bin = find_opencode()
380
+ if not opencode_bin:
381
+ print(f"[{cfg.repo}] Cannot resolve conflicts: opencode not found", flush=True)
382
+ return False
383
+
384
+ pr_number = pr["number"]
385
+ branch = pr.get("headRefName", f"autopilot/issue-{issue_number}")
386
+ worktree = Path(f"/tmp/autopilot-issue-{issue_number}")
387
+
388
+ print(f"[{cfg.repo}] Attempting to resolve merge conflicts for PR #{pr_number} (attempt {attempt}/{cfg.conflict_resolution_max_attempts})", flush=True)
389
+ print(f"[{cfg.repo}] Using opencode: {opencode_bin}", flush=True)
390
+
391
+ # Ensure worktree exists and is up to date
392
+ if not worktree.exists():
393
+ print(f"[{cfg.repo}] Creating worktree at {worktree}", flush=True)
394
+ try:
395
+ sh(["git", "worktree", "add", str(worktree), branch], cwd=cfg.root)
396
+ except Exception as e:
397
+ print(f"[{cfg.repo}] Failed to create worktree: {e}", flush=True)
398
+ return False
399
+
400
+ # Fetch latest and attempt rebase/merge
401
+ try:
402
+ sh(["git", "fetch", "origin", "main"], cwd=worktree)
403
+
404
+ # Try to rebase onto main
405
+ rebase_result = subprocess.run(
406
+ ["git", "rebase", "origin/main"],
407
+ cwd=str(worktree),
408
+ capture_output=True,
409
+ text=True
410
+ )
411
+
412
+ if rebase_result.returncode == 0:
413
+ # No conflicts, push and we're done
414
+ sh(["git", "push", "-f", "origin", branch], cwd=worktree)
415
+ print(f"[{cfg.repo}] Rebase successful, no conflicts to resolve", flush=True)
416
+ return True
417
+
418
+ # There are conflicts - abort the rebase first
419
+ subprocess.run(["git", "rebase", "--abort"], cwd=str(worktree), capture_output=True)
420
+
421
+ # Get list of files that would conflict
422
+ merge_base = sh(["git", "merge-base", "HEAD", "origin/main"], cwd=worktree).strip()
423
+ diff_output = sh(["git", "diff", "--name-only", merge_base, "origin/main"], cwd=worktree, check=False)
424
+ conflicting_files = diff_output.strip().split("\n") if diff_output.strip() else []
425
+
426
+ # Build prompt for the agent
427
+ conflict_prompt = f"""This PR has merge conflicts with main. Please resolve them.
428
+
429
+ Branch: {branch}
430
+ Files potentially conflicting: {', '.join(conflicting_files[:10])}
431
+
432
+ Steps:
433
+ 1. Run `git fetch origin main`
434
+ 2. Run `git rebase origin/main` to start the rebase
435
+ 3. When conflicts occur, examine the conflicted files
436
+ 4. Edit the files to resolve conflicts (remove conflict markers, keep correct code)
437
+ 5. Run `git add <resolved-files>` for each resolved file
438
+ 6. Run `git rebase --continue`
439
+ 7. Repeat until rebase is complete
440
+ 8. Run `git push -f origin {branch}` to update the PR
441
+
442
+ Work rules:
443
+ - Focus only on resolving conflicts, don't make unrelated changes
444
+ - Preserve the intent of both the PR changes and main branch changes
445
+ - If unsure about a conflict, prefer the PR's changes but ensure compatibility
446
+ """
447
+
448
+ print(f"[{cfg.repo}] Running opencode to resolve conflicts...", flush=True)
449
+
450
+ # Run opencode in the worktree
451
+ agent_result = subprocess.run(
452
+ [opencode_bin, "run", conflict_prompt],
453
+ cwd=str(worktree),
454
+ capture_output=True,
455
+ text=True,
456
+ timeout=600 # 10 minute timeout
457
+ )
458
+
459
+ if agent_result.returncode != 0:
460
+ print(f"[{cfg.repo}] Opencode failed: {agent_result.stderr[:500]}", flush=True)
461
+ return False
462
+
463
+ # Check if conflicts are resolved by checking PR status
464
+ time.sleep(5) # Give GitHub time to update
465
+ cmd = ["gh", "pr", "view", str(pr_number), "--repo", cfg.repo, "--json", "mergeable,mergeStateStatus"]
466
+ pr_status = json.loads(sh(cmd))
467
+
468
+ if pr_status.get("mergeable") == "MERGEABLE":
469
+ print(f"[{cfg.repo}] Conflicts resolved successfully!", flush=True)
470
+ sh(["gh", "issue", "comment", str(issue_number), "--repo", cfg.repo,
471
+ "--body", f"✅ Autopilot resolved merge conflicts (attempt {attempt})."])
472
+ return True
473
+ else:
474
+ print(f"[{cfg.repo}] Conflicts still present after resolution attempt", flush=True)
475
+ return False
476
+
477
+ except subprocess.TimeoutExpired:
478
+ print(f"[{cfg.repo}] Conflict resolution timed out", flush=True)
479
+ return False
480
+ except Exception as e:
481
+ print(f"[{cfg.repo}] Error resolving conflicts: {e}", flush=True)
482
+ return False
483
+
484
+
344
485
  def try_merge_pr(cfg: RepoConfig, issue_number: int, pr: dict[str, Any]) -> bool:
345
486
  """Attempt to merge a PR if it's ready. Returns True if merged or closed."""
346
487
  pr_number = pr["number"]
@@ -358,6 +499,43 @@ def try_merge_pr(cfg: RepoConfig, issue_number: int, pr: dict[str, Any]) -> bool
358
499
  mergeable = pr.get("mergeable")
359
500
  merge_state = pr.get("mergeStateStatus")
360
501
 
502
+ # Handle merge conflicts
503
+ if mergeable == "CONFLICTING" or merge_state == "DIRTY":
504
+ print(f"[{cfg.repo}] PR #{pr_number} has merge conflicts", flush=True)
505
+
506
+ if not cfg.auto_resolve_conflicts:
507
+ print(f"[{cfg.repo}] Auto-resolve conflicts disabled, skipping", flush=True)
508
+ return False
509
+
510
+ # Load state to track resolution attempts
511
+ state = load_state(cfg.root)
512
+ conflict_key = f"conflict_attempts_{issue_number}"
513
+ attempts = state.get(conflict_key, 0)
514
+
515
+ if attempts >= cfg.conflict_resolution_max_attempts:
516
+ print(f"[{cfg.repo}] Max conflict resolution attempts ({cfg.conflict_resolution_max_attempts}) reached", flush=True)
517
+ if attempts == cfg.conflict_resolution_max_attempts:
518
+ # Only notify once when we hit the limit
519
+ sh(["gh", "issue", "comment", str(issue_number), "--repo", cfg.repo,
520
+ "--body", f"❌ Autopilot failed to resolve merge conflicts after {attempts} attempts. Manual resolution required."])
521
+ state[conflict_key] = attempts + 1 # Increment to avoid repeat notifications
522
+ write_state(cfg.root, state)
523
+ return False
524
+
525
+ # Attempt to resolve
526
+ attempts += 1
527
+ state[conflict_key] = attempts
528
+ write_state(cfg.root, state)
529
+
530
+ if resolve_merge_conflicts(cfg, issue_number, pr, attempts):
531
+ # Reset attempts on success
532
+ del state[conflict_key]
533
+ write_state(cfg.root, state)
534
+ # Re-check the PR - it might be ready to merge now
535
+ return False # Return False to re-check on next cycle
536
+ else:
537
+ return False
538
+
361
539
  if mergeable != "MERGEABLE":
362
540
  print(f"[{cfg.repo}] PR #{pr_number} is not mergeable (state: {mergeable}/{merge_state}), skipping", flush=True)
363
541
  return False
@@ -21,6 +21,7 @@ if command -v jq >/dev/null 2>&1; then
21
21
  AUTO_FIX_CHECKS=$(jq -r '.autoFixChecks // true' < .autopilot/autopilot.json)
22
22
  AUTO_FIX_CHECKS_MAX_ATTEMPTS=$(jq -r '.autoFixChecksMaxAttempts // 3' < .autopilot/autopilot.json)
23
23
  ALLOWED_USERS=$(jq -r '.allowedMergeUsers[]' < .autopilot/autopilot.json 2>/dev/null || true)
24
+ AGENT_PATH=$(jq -r '.agentPath // ""' < .autopilot/autopilot.json)
24
25
  else
25
26
  REPO=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json"))["repo"])')
26
27
  AUTO_MERGE=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoMerge", True))')
@@ -30,8 +31,49 @@ else
30
31
  AUTO_FIX_CHECKS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoFixChecks", True))')
31
32
  AUTO_FIX_CHECKS_MAX_ATTEMPTS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoFixChecksMaxAttempts", 3))')
32
33
  ALLOWED_USERS=$(python3 -c 'import json,sys; users=json.load(open(".autopilot/autopilot.json")).get("allowedMergeUsers", []); print("\n".join(users))' 2>/dev/null || true)
34
+ AGENT_PATH=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("agentPath", ""))' 2>/dev/null || true)
33
35
  fi
34
36
 
37
+ # Find opencode binary - check config, PATH, then common locations
38
+ find_opencode() {
39
+ # 1. Config-specified path
40
+ if [[ -n "$AGENT_PATH" ]] && [[ -x "$AGENT_PATH" ]]; then
41
+ echo "$AGENT_PATH"
42
+ return
43
+ fi
44
+
45
+ # 2. Already in PATH
46
+ if command -v opencode >/dev/null 2>&1; then
47
+ command -v opencode
48
+ return
49
+ fi
50
+
51
+ # 3. Common nvm locations
52
+ for node_dir in "$HOME/.nvm/versions/node"/*/bin; do
53
+ if [[ -x "$node_dir/opencode" ]]; then
54
+ echo "$node_dir/opencode"
55
+ return
56
+ fi
57
+ done
58
+
59
+ # 4. Other common locations
60
+ for path in "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/.npm-global/bin/opencode"; do
61
+ if [[ -x "$path" ]]; then
62
+ echo "$path"
63
+ return
64
+ fi
65
+ done
66
+
67
+ # Not found
68
+ return 1
69
+ }
70
+
71
+ OPENCODE_BIN=$(find_opencode) || {
72
+ echo "[run_opencode_issue.sh] ERROR: opencode not found. Set 'agentPath' in autopilot.json or ensure opencode is installed." >&2
73
+ exit 1
74
+ }
75
+ echo "[run_opencode_issue.sh] Using opencode: $OPENCODE_BIN"
76
+
35
77
  WORKTREE="/tmp/autopilot-issue-$ISSUE_NUMBER"
36
78
  BRANCH="autopilot/issue-$ISSUE_NUMBER"
37
79
 
@@ -73,7 +115,7 @@ Work rules:
73
115
  - If the issue is a simple file-addition, just do it directly (no extra refactors)."
74
116
  # 4. Run opencode inside worktree
75
117
  cd "$WORKTREE"
76
- opencode run "$PROMPT"
118
+ "$OPENCODE_BIN" run "$PROMPT"
77
119
 
78
120
  # 5. Commit any changes OpenCode made
79
121
  if [[ -n "$(git status --porcelain)" ]]; then
@@ -190,7 +232,7 @@ if [[ "$AUTO_RESOLVE_CONFLICTS" == "true" ]] && [[ -n "$PR_URL" ]]; then
190
232
 
191
233
  After resolving all conflicts, report the files that were resolved."
192
234
 
193
- if opencode run "$CONFLICT_PROMPT"; then
235
+ if "$OPENCODE_BIN" run "$CONFLICT_PROMPT"; then
194
236
  # Check if there are still conflicts
195
237
  if [[ -z "$(git diff --name-only --diff-filter=U)" ]]; then
196
238
  echo "[run_opencode_issue.sh] Conflicts resolved by agent."
@@ -316,7 +358,7 @@ if [[ "$AUTO_MERGE" == "true" ]] && [[ -n "$PR_URL" ]]; then
316
358
 
317
359
  # Run opencode to fix the issue
318
360
  cd "$WORKTREE"
319
- if opencode run "$REPAIR_PROMPT"; then
361
+ if "$OPENCODE_BIN" run "$REPAIR_PROMPT"; then
320
362
  # Commit any changes
321
363
  if [[ -n "$(git status --porcelain)" ]]; then
322
364
  git add -A