autopilot-code 0.0.14 → 0.0.16

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.
@@ -2,6 +2,8 @@
2
2
  "enabled": true,
3
3
  "repo": "bakkensoftware/autopilot",
4
4
  "agent": "opencode",
5
+ "autoMerge": true,
6
+ "mergeMethod": "squash",
5
7
  "allowedMergeUsers": [
6
8
  "kellyelton"
7
9
  ],
@@ -26,5 +28,7 @@
26
28
  "branchPrefix": "autopilot/",
27
29
  "allowedBaseBranch": "main",
28
30
  "autoResolveConflicts": true,
29
- "conflictResolutionMaxAttempts": 3
31
+ "conflictResolutionMaxAttempts": 3,
32
+ "autoFixChecks": true,
33
+ "autoFixChecksMaxAttempts": 3
30
34
  }
package/README.md CHANGED
@@ -22,7 +22,8 @@ Example:
22
22
  "enabled": true,
23
23
  "repo": "bakkensoftware/autopilot",
24
24
  "agent": "opencode",
25
- "autoMerge": false,
25
+ "autoMerge": true,
26
+ "mergeMethod": "squash",
26
27
  "allowedMergeUsers": ["github-username"],
27
28
  "issueLabels": {
28
29
  "queue": ["autopilot:todo"],
@@ -31,19 +32,31 @@ Example:
31
32
  "done": "autopilot:done"
32
33
  },
33
34
  "priorityLabels": ["p0", "p1", "p2"],
35
+ "minPriority": null,
36
+ "ignoreIssueLabels": ["autopilot:backlog"],
34
37
  "maxParallel": 1,
35
38
  "heartbeatMaxAgeSecs": 3600,
36
39
  "branchPrefix": "autopilot/",
37
- "allowedBaseBranch": "main"
40
+ "allowedBaseBranch": "main",
41
+ "autoResolveConflicts": true,
42
+ "conflictResolutionMaxAttempts": 3,
43
+ "autoFixChecks": true,
44
+ "autoFixChecksMaxAttempts": 3
38
45
  }
39
46
  ```
40
47
 
41
48
  Notes:
42
49
  - `repo` must be the GitHub `owner/name`.
43
50
  - `agent` (optional, default `"none"`): set to `"opencode"` to enable OpenCode integration after claiming issues.
44
- - `autoMerge` (optional, default `false`): if `true`, autopilot will automatically merge PRs after they are created.
51
+ - `autoMerge` (optional, default `true`): if `true`, autopilot will automatically merge PRs after checks pass.
52
+ - `mergeMethod` (optional, default `"squash"`): merge strategy to use. Options: `"squash"`, `"merge"`, or `"rebase"`.
45
53
  - `allowedMergeUsers` (required when `autoMerge=true`): list of GitHub usernames allowed to auto-merge. The runner verifies the authenticated GitHub user is in this list before merging.
54
+ - `minPriority` (optional, default `null`): minimum priority to work on. For example, set to `"p1"` to only work on `p0` and `p1` issues. Uses `priorityLabels` array for priority order.
46
55
  - `ignoreIssueLabels` (optional, default `["autopilot:backlog"]`): issues with any of these labels will be ignored by the runner.
56
+ - `autoResolveConflicts` (optional, default `true`): if `true`, autopilot will attempt to automatically resolve merge conflicts.
57
+ - `conflictResolutionMaxAttempts` (optional, default `3`): maximum number of attempts to resolve merge conflicts.
58
+ - `autoFixChecks` (optional, default `true`): if `true`, autopilot will attempt to automatically fix failing CI checks.
59
+ - `autoFixChecksMaxAttempts` (optional, default `3`): maximum number of attempts to fix failing checks.
47
60
  - `heartbeatMaxAgeSecs` controls how long an in-progress issue can go without a heartbeat before it's considered stale.
48
61
 
49
62
  ## Workflow (labels)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autopilot-code",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -317,6 +317,143 @@ def list_in_progress_issues(cfg: RepoConfig, limit: int = 20) -> list[dict[str,
317
317
  return json.loads(raw)
318
318
 
319
319
 
320
+ def check_pr_for_issue(cfg: RepoConfig, issue_number: int) -> dict[str, Any] | None:
321
+ """Check if there's a PR for this issue and return its status."""
322
+ branch = f"autopilot/issue-{issue_number}"
323
+ cmd = [
324
+ "gh",
325
+ "pr",
326
+ "list",
327
+ "--repo",
328
+ cfg.repo,
329
+ "--head",
330
+ branch,
331
+ "--json",
332
+ "number,title,state,mergeable,mergeStateStatus,url,merged,closed",
333
+ "--limit",
334
+ "1",
335
+ ]
336
+ try:
337
+ raw = sh(cmd, check=False)
338
+ prs = json.loads(raw)
339
+ return prs[0] if prs else None
340
+ except Exception:
341
+ return None
342
+
343
+
344
+ def try_merge_pr(cfg: RepoConfig, issue_number: int, pr: dict[str, Any]) -> bool:
345
+ """Attempt to merge a PR if it's ready. Returns True if merged or closed."""
346
+ pr_number = pr["number"]
347
+ pr_url = pr["url"]
348
+
349
+ # If already merged or closed, mark issue as done
350
+ if pr.get("merged") or pr.get("closed"):
351
+ print(f"[{cfg.repo}] PR #{pr_number} is already merged/closed, marking issue #{issue_number} as done", flush=True)
352
+ sh(["gh", "issue", "edit", str(issue_number), "--repo", cfg.repo,
353
+ "--add-label", cfg.label_done, "--remove-label", cfg.label_in_progress])
354
+ sh(["gh", "issue", "close", str(issue_number), "--repo", cfg.repo, "--reason", "completed"])
355
+ return True
356
+
357
+ # Check if PR is mergeable
358
+ mergeable = pr.get("mergeable")
359
+ merge_state = pr.get("mergeStateStatus")
360
+
361
+ if mergeable != "MERGEABLE":
362
+ print(f"[{cfg.repo}] PR #{pr_number} is not mergeable (state: {mergeable}/{merge_state}), skipping", flush=True)
363
+ return False
364
+
365
+ # Get PR check status
366
+ cmd = [
367
+ "gh", "pr", "view", str(pr_number), "--repo", cfg.repo,
368
+ "--json", "statusCheckRollup",
369
+ ]
370
+ try:
371
+ pr_details = json.loads(sh(cmd))
372
+ checks = pr_details.get("statusCheckRollup", [])
373
+
374
+ if not checks:
375
+ # No checks configured, can merge
376
+ check_status = "PASSED"
377
+ else:
378
+ # Check if any checks are pending or failed
379
+ has_failure = any(c.get("conclusion") == "FAILURE" for c in checks)
380
+ has_pending = any(c.get("conclusion") in ("PENDING", "QUEUED", None) for c in checks)
381
+
382
+ if has_failure:
383
+ check_status = "FAILED"
384
+ elif has_pending:
385
+ check_status = "PENDING"
386
+ else:
387
+ check_status = "PASSED"
388
+
389
+ if check_status != "PASSED":
390
+ print(f"[{cfg.repo}] PR #{pr_number} checks not ready (status: {check_status})", flush=True)
391
+ return False
392
+
393
+ # Checks passed, attempt merge
394
+ print(f"[{cfg.repo}] PR #{pr_number} is ready, attempting merge...", flush=True)
395
+
396
+ merge_method = "squash" # Default, could be read from config if needed
397
+ merge_cmd = ["gh", "pr", "merge", pr_url, "--delete-branch", f"--{merge_method}"]
398
+
399
+ try:
400
+ sh(merge_cmd)
401
+ print(f"[{cfg.repo}] Successfully merged PR #{pr_number}!", flush=True)
402
+
403
+ # Comment and close issue
404
+ success_msg = f"✅ Autopilot successfully merged PR #{pr_number} using {merge_method} method.\n\nThe fix has been merged to the main branch."
405
+ sh(["gh", "issue", "comment", str(issue_number), "--repo", cfg.repo, "--body", success_msg])
406
+ sh(["gh", "issue", "edit", str(issue_number), "--repo", cfg.repo,
407
+ "--add-label", cfg.label_done, "--remove-label", cfg.label_in_progress])
408
+ sh(["gh", "issue", "close", str(issue_number), "--repo", cfg.repo, "--reason", "completed"])
409
+
410
+ return True
411
+ except Exception as e:
412
+ print(f"[{cfg.repo}] Failed to merge PR #{pr_number}: {e}", flush=True)
413
+ return False
414
+
415
+ except Exception as e:
416
+ print(f"[{cfg.repo}] Error checking PR #{pr_number}: {e}", flush=True)
417
+ return False
418
+
419
+
420
+ def clean_completed_issues_from_state(cfg: RepoConfig) -> None:
421
+ """Remove completed issues from state.json."""
422
+ state = load_state(cfg.root)
423
+ active_issues = state.get("activeIssues", {})
424
+
425
+ if not isinstance(active_issues, dict):
426
+ return
427
+
428
+ # Check each issue to see if it's still open
429
+ issues_to_remove = []
430
+ for issue_num_str, issue_data in active_issues.items():
431
+ if not isinstance(issue_data, dict):
432
+ continue
433
+
434
+ issue_num = int(issue_data.get("number", -1))
435
+ if issue_num <= 0:
436
+ continue
437
+
438
+ # Check if issue is closed
439
+ try:
440
+ cmd = ["gh", "issue", "view", str(issue_num), "--repo", cfg.repo, "--json", "state,closed"]
441
+ result = json.loads(sh(cmd, check=False))
442
+ if result.get("state") == "CLOSED" or result.get("closed"):
443
+ print(f"[{cfg.repo}] Issue #{issue_num} is closed, removing from state", flush=True)
444
+ issues_to_remove.append(issue_num_str)
445
+ except Exception:
446
+ pass
447
+
448
+ # Remove completed issues
449
+ for issue_num_str in issues_to_remove:
450
+ del active_issues[issue_num_str]
451
+
452
+ if issues_to_remove:
453
+ state["activeIssues"] = active_issues
454
+ write_state(cfg.root, state)
455
+
456
+
320
457
  def maybe_mark_blocked(cfg: RepoConfig, issue: dict[str, Any]) -> None:
321
458
  num = int(issue["number"])
322
459
  if is_heartbeat_fresh(cfg, num):
@@ -358,12 +495,33 @@ def run_cycle(
358
495
 
359
496
  for cfg in all_configs:
360
497
  print(f"[{cfg.repo}] Scanning for issues...", flush=True)
361
- # 1) First, check any in-progress issues and mark blocked if stale.
498
+
499
+ # 0) Clean up completed issues from state
500
+ if not dry_run:
501
+ clean_completed_issues_from_state(cfg)
502
+
503
+ # 1) Check in-progress issues
362
504
  inprog = list_in_progress_issues(cfg)
363
505
  for it in inprog:
364
- # This is conservative: only mark blocked if we have no fresh local heartbeat.
506
+ issue_num = int(it["number"])
507
+
508
+ # First check if we should mark as blocked due to stale heartbeat
365
509
  if not dry_run:
366
- maybe_mark_blocked(cfg, it)
510
+ # Only mark blocked if heartbeat is stale
511
+ if not is_heartbeat_fresh(cfg, issue_num):
512
+ maybe_mark_blocked(cfg, it)
513
+ continue
514
+
515
+ # If heartbeat is fresh and autoMerge is enabled, try to merge PR
516
+ if cfg.auto_merge:
517
+ pr = check_pr_for_issue(cfg, issue_num)
518
+ if pr:
519
+ print(f"[{cfg.repo}] Found PR for issue #{issue_num}, checking if ready to merge...", flush=True)
520
+ if try_merge_pr(cfg, issue_num, pr):
521
+ print(f"[{cfg.repo}] Issue #{issue_num} completed!", flush=True)
522
+ else:
523
+ # Touch heartbeat to keep it alive while waiting for checks
524
+ touch_heartbeat(cfg, issue_num)
367
525
 
368
526
  # 2) Count active issues with fresh heartbeats from local state
369
527
  state = load_state(cfg.root)
@@ -15,6 +15,7 @@ cd "$REPO_DIR"
15
15
  if command -v jq >/dev/null 2>&1; then
16
16
  REPO=$(jq -r '.repo' < .autopilot/autopilot.json)
17
17
  AUTO_MERGE=$(jq -r '.autoMerge // true' < .autopilot/autopilot.json)
18
+ MERGE_METHOD=$(jq -r '.mergeMethod // "squash"' < .autopilot/autopilot.json)
18
19
  AUTO_RESOLVE_CONFLICTS=$(jq -r '.autoResolveConflicts // true' < .autopilot/autopilot.json)
19
20
  CONFLICT_RESOLUTION_MAX_ATTEMPTS=$(jq -r '.conflictResolutionMaxAttempts // 3' < .autopilot/autopilot.json)
20
21
  AUTO_FIX_CHECKS=$(jq -r '.autoFixChecks // true' < .autopilot/autopilot.json)
@@ -23,6 +24,7 @@ if command -v jq >/dev/null 2>&1; then
23
24
  else
24
25
  REPO=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json"))["repo"])')
25
26
  AUTO_MERGE=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoMerge", True))')
27
+ MERGE_METHOD=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("mergeMethod", "squash"))')
26
28
  AUTO_RESOLVE_CONFLICTS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoResolveConflicts", True))')
27
29
  CONFLICT_RESOLUTION_MAX_ATTEMPTS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("conflictResolutionMaxAttempts", 3))')
28
30
  AUTO_FIX_CHECKS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoFixChecks", True))')
@@ -360,6 +362,58 @@ if [[ "$AUTO_MERGE" == "true" ]] && [[ -n "$PR_URL" ]]; then
360
362
  sleep "$POLL_INTERVAL"
361
363
  fi
362
364
  done
365
+
366
+ # After polling loop: merge if checks passed
367
+ if [[ "$CHECK_STATUS" == "PASSED" ]]; then
368
+ echo "[run_opencode_issue.sh] Merging PR using method: $MERGE_METHOD"
369
+
370
+ # Build merge command based on method
371
+ MERGE_CMD="gh pr merge $PR_URL --delete-branch"
372
+ case "$MERGE_METHOD" in
373
+ squash)
374
+ MERGE_CMD="$MERGE_CMD --squash"
375
+ ;;
376
+ merge)
377
+ MERGE_CMD="$MERGE_CMD --merge"
378
+ ;;
379
+ rebase)
380
+ MERGE_CMD="$MERGE_CMD --rebase"
381
+ ;;
382
+ *)
383
+ echo "[run_opencode_issue.sh] Unknown merge method '$MERGE_METHOD', defaulting to squash"
384
+ MERGE_CMD="$MERGE_CMD --squash"
385
+ ;;
386
+ esac
387
+
388
+ # Attempt merge
389
+ if eval "$MERGE_CMD"; then
390
+ echo "[run_opencode_issue.sh] PR merged successfully!"
391
+
392
+ # Update issue with success message and mark as done
393
+ SUCCESS_MSG="✅ Autopilot successfully merged PR #${PR_URL##*/} using $MERGE_METHOD method.\n\nThe fix has been merged to the main branch."
394
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$SUCCESS_MSG" || true
395
+
396
+ # Get done label and mark issue as done
397
+ if command -v jq >/dev/null 2>&1; then
398
+ LABEL_DONE=$(jq -r '.issueLabels.done' < .autopilot/autopilot.json)
399
+ LABEL_IN_PROGRESS=$(jq -r '.issueLabels.inProgress' < .autopilot/autopilot.json)
400
+ else
401
+ LABEL_DONE=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json"))["issueLabels"]["done"])')
402
+ LABEL_IN_PROGRESS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json"))["issueLabels"]["inProgress"])')
403
+ fi
404
+
405
+ gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label "$LABEL_DONE" --remove-label "$LABEL_IN_PROGRESS" || true
406
+ gh issue close "$ISSUE_NUMBER" --repo "$REPO" --reason completed || true
407
+ else
408
+ echo "[run_opencode_issue.sh] Failed to merge PR"
409
+ MERGE_FAIL_MSG="❌ Autopilot failed to merge PR automatically.\n\nThe PR is ready (checks passed) but the merge operation failed. You may need to merge manually."
410
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$MERGE_FAIL_MSG" || true
411
+ fi
412
+ elif [[ $POLL_ATTEMPT -ge $MAX_POLL_ATTEMPTS ]]; then
413
+ echo "[run_opencode_issue.sh] Timed out waiting for checks to pass"
414
+ TIMEOUT_MSG="⏱️ Autopilot timed out waiting for PR checks to complete.\n\nThe PR will remain open for manual review."
415
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$TIMEOUT_MSG" || true
416
+ fi
363
417
  else
364
418
  BLOCKED_MSG="❌ Autopilot cannot auto-merge PR: authenticated user '$AUTH_USER' is not in the allowedMergeUsers list."
365
419
  gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$BLOCKED_MSG" || true