autopilot-code 0.0.15 → 0.0.17

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.15",
3
+ "version": "0.0.17",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -317,6 +317,286 @@ 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,headRefName",
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 resolve_merge_conflicts(cfg: RepoConfig, issue_number: int, pr: dict[str, Any], attempt: int) -> bool:
345
+ """Attempt to resolve merge conflicts using the coding agent. Returns True if resolved."""
346
+ if cfg.agent != "opencode":
347
+ print(f"[{cfg.repo}] Cannot resolve conflicts: agent '{cfg.agent}' not supported (only 'opencode')", flush=True)
348
+ return False
349
+
350
+ pr_number = pr["number"]
351
+ branch = pr.get("headRefName", f"autopilot/issue-{issue_number}")
352
+ worktree = Path(f"/tmp/autopilot-issue-{issue_number}")
353
+
354
+ print(f"[{cfg.repo}] Attempting to resolve merge conflicts for PR #{pr_number} (attempt {attempt}/{cfg.conflict_resolution_max_attempts})", flush=True)
355
+
356
+ # Ensure worktree exists and is up to date
357
+ if not worktree.exists():
358
+ print(f"[{cfg.repo}] Creating worktree at {worktree}", flush=True)
359
+ try:
360
+ sh(["git", "worktree", "add", str(worktree), branch], cwd=cfg.root)
361
+ except Exception as e:
362
+ print(f"[{cfg.repo}] Failed to create worktree: {e}", flush=True)
363
+ return False
364
+
365
+ # Fetch latest and attempt rebase/merge
366
+ try:
367
+ sh(["git", "fetch", "origin", "main"], cwd=worktree)
368
+
369
+ # Try to rebase onto main
370
+ rebase_result = subprocess.run(
371
+ ["git", "rebase", "origin/main"],
372
+ cwd=str(worktree),
373
+ capture_output=True,
374
+ text=True
375
+ )
376
+
377
+ if rebase_result.returncode == 0:
378
+ # No conflicts, push and we're done
379
+ sh(["git", "push", "-f", "origin", branch], cwd=worktree)
380
+ print(f"[{cfg.repo}] Rebase successful, no conflicts to resolve", flush=True)
381
+ return True
382
+
383
+ # There are conflicts - abort the rebase first
384
+ subprocess.run(["git", "rebase", "--abort"], cwd=str(worktree), capture_output=True)
385
+
386
+ # Get list of files that would conflict
387
+ merge_base = sh(["git", "merge-base", "HEAD", "origin/main"], cwd=worktree).strip()
388
+ diff_output = sh(["git", "diff", "--name-only", merge_base, "origin/main"], cwd=worktree, check=False)
389
+ conflicting_files = diff_output.strip().split("\n") if diff_output.strip() else []
390
+
391
+ # Build prompt for the agent
392
+ conflict_prompt = f"""This PR has merge conflicts with main. Please resolve them.
393
+
394
+ Branch: {branch}
395
+ Files potentially conflicting: {', '.join(conflicting_files[:10])}
396
+
397
+ Steps:
398
+ 1. Run `git fetch origin main`
399
+ 2. Run `git rebase origin/main` to start the rebase
400
+ 3. When conflicts occur, examine the conflicted files
401
+ 4. Edit the files to resolve conflicts (remove conflict markers, keep correct code)
402
+ 5. Run `git add <resolved-files>` for each resolved file
403
+ 6. Run `git rebase --continue`
404
+ 7. Repeat until rebase is complete
405
+ 8. Run `git push -f origin {branch}` to update the PR
406
+
407
+ Work rules:
408
+ - Focus only on resolving conflicts, don't make unrelated changes
409
+ - Preserve the intent of both the PR changes and main branch changes
410
+ - If unsure about a conflict, prefer the PR's changes but ensure compatibility
411
+ """
412
+
413
+ print(f"[{cfg.repo}] Running opencode to resolve conflicts...", flush=True)
414
+
415
+ # Run opencode in the worktree
416
+ agent_result = subprocess.run(
417
+ ["opencode", "run", conflict_prompt],
418
+ cwd=str(worktree),
419
+ capture_output=True,
420
+ text=True,
421
+ timeout=600 # 10 minute timeout
422
+ )
423
+
424
+ if agent_result.returncode != 0:
425
+ print(f"[{cfg.repo}] Opencode failed: {agent_result.stderr[:500]}", flush=True)
426
+ return False
427
+
428
+ # Check if conflicts are resolved by checking PR status
429
+ time.sleep(5) # Give GitHub time to update
430
+ cmd = ["gh", "pr", "view", str(pr_number), "--repo", cfg.repo, "--json", "mergeable,mergeStateStatus"]
431
+ pr_status = json.loads(sh(cmd))
432
+
433
+ if pr_status.get("mergeable") == "MERGEABLE":
434
+ print(f"[{cfg.repo}] Conflicts resolved successfully!", flush=True)
435
+ sh(["gh", "issue", "comment", str(issue_number), "--repo", cfg.repo,
436
+ "--body", f"✅ Autopilot resolved merge conflicts (attempt {attempt})."])
437
+ return True
438
+ else:
439
+ print(f"[{cfg.repo}] Conflicts still present after resolution attempt", flush=True)
440
+ return False
441
+
442
+ except subprocess.TimeoutExpired:
443
+ print(f"[{cfg.repo}] Conflict resolution timed out", flush=True)
444
+ return False
445
+ except Exception as e:
446
+ print(f"[{cfg.repo}] Error resolving conflicts: {e}", flush=True)
447
+ return False
448
+
449
+
450
+ def try_merge_pr(cfg: RepoConfig, issue_number: int, pr: dict[str, Any]) -> bool:
451
+ """Attempt to merge a PR if it's ready. Returns True if merged or closed."""
452
+ pr_number = pr["number"]
453
+ pr_url = pr["url"]
454
+
455
+ # If already merged or closed, mark issue as done
456
+ if pr.get("merged") or pr.get("closed"):
457
+ print(f"[{cfg.repo}] PR #{pr_number} is already merged/closed, marking issue #{issue_number} as done", flush=True)
458
+ sh(["gh", "issue", "edit", str(issue_number), "--repo", cfg.repo,
459
+ "--add-label", cfg.label_done, "--remove-label", cfg.label_in_progress])
460
+ sh(["gh", "issue", "close", str(issue_number), "--repo", cfg.repo, "--reason", "completed"])
461
+ return True
462
+
463
+ # Check if PR is mergeable
464
+ mergeable = pr.get("mergeable")
465
+ merge_state = pr.get("mergeStateStatus")
466
+
467
+ # Handle merge conflicts
468
+ if mergeable == "CONFLICTING" or merge_state == "DIRTY":
469
+ print(f"[{cfg.repo}] PR #{pr_number} has merge conflicts", flush=True)
470
+
471
+ if not cfg.auto_resolve_conflicts:
472
+ print(f"[{cfg.repo}] Auto-resolve conflicts disabled, skipping", flush=True)
473
+ return False
474
+
475
+ # Load state to track resolution attempts
476
+ state = load_state(cfg.root)
477
+ conflict_key = f"conflict_attempts_{issue_number}"
478
+ attempts = state.get(conflict_key, 0)
479
+
480
+ if attempts >= cfg.conflict_resolution_max_attempts:
481
+ print(f"[{cfg.repo}] Max conflict resolution attempts ({cfg.conflict_resolution_max_attempts}) reached", flush=True)
482
+ if attempts == cfg.conflict_resolution_max_attempts:
483
+ # Only notify once when we hit the limit
484
+ sh(["gh", "issue", "comment", str(issue_number), "--repo", cfg.repo,
485
+ "--body", f"❌ Autopilot failed to resolve merge conflicts after {attempts} attempts. Manual resolution required."])
486
+ state[conflict_key] = attempts + 1 # Increment to avoid repeat notifications
487
+ write_state(cfg.root, state)
488
+ return False
489
+
490
+ # Attempt to resolve
491
+ attempts += 1
492
+ state[conflict_key] = attempts
493
+ write_state(cfg.root, state)
494
+
495
+ if resolve_merge_conflicts(cfg, issue_number, pr, attempts):
496
+ # Reset attempts on success
497
+ del state[conflict_key]
498
+ write_state(cfg.root, state)
499
+ # Re-check the PR - it might be ready to merge now
500
+ return False # Return False to re-check on next cycle
501
+ else:
502
+ return False
503
+
504
+ if mergeable != "MERGEABLE":
505
+ print(f"[{cfg.repo}] PR #{pr_number} is not mergeable (state: {mergeable}/{merge_state}), skipping", flush=True)
506
+ return False
507
+
508
+ # Get PR check status
509
+ cmd = [
510
+ "gh", "pr", "view", str(pr_number), "--repo", cfg.repo,
511
+ "--json", "statusCheckRollup",
512
+ ]
513
+ try:
514
+ pr_details = json.loads(sh(cmd))
515
+ checks = pr_details.get("statusCheckRollup", [])
516
+
517
+ if not checks:
518
+ # No checks configured, can merge
519
+ check_status = "PASSED"
520
+ else:
521
+ # Check if any checks are pending or failed
522
+ has_failure = any(c.get("conclusion") == "FAILURE" for c in checks)
523
+ has_pending = any(c.get("conclusion") in ("PENDING", "QUEUED", None) for c in checks)
524
+
525
+ if has_failure:
526
+ check_status = "FAILED"
527
+ elif has_pending:
528
+ check_status = "PENDING"
529
+ else:
530
+ check_status = "PASSED"
531
+
532
+ if check_status != "PASSED":
533
+ print(f"[{cfg.repo}] PR #{pr_number} checks not ready (status: {check_status})", flush=True)
534
+ return False
535
+
536
+ # Checks passed, attempt merge
537
+ print(f"[{cfg.repo}] PR #{pr_number} is ready, attempting merge...", flush=True)
538
+
539
+ merge_method = "squash" # Default, could be read from config if needed
540
+ merge_cmd = ["gh", "pr", "merge", pr_url, "--delete-branch", f"--{merge_method}"]
541
+
542
+ try:
543
+ sh(merge_cmd)
544
+ print(f"[{cfg.repo}] Successfully merged PR #{pr_number}!", flush=True)
545
+
546
+ # Comment and close issue
547
+ success_msg = f"✅ Autopilot successfully merged PR #{pr_number} using {merge_method} method.\n\nThe fix has been merged to the main branch."
548
+ sh(["gh", "issue", "comment", str(issue_number), "--repo", cfg.repo, "--body", success_msg])
549
+ sh(["gh", "issue", "edit", str(issue_number), "--repo", cfg.repo,
550
+ "--add-label", cfg.label_done, "--remove-label", cfg.label_in_progress])
551
+ sh(["gh", "issue", "close", str(issue_number), "--repo", cfg.repo, "--reason", "completed"])
552
+
553
+ return True
554
+ except Exception as e:
555
+ print(f"[{cfg.repo}] Failed to merge PR #{pr_number}: {e}", flush=True)
556
+ return False
557
+
558
+ except Exception as e:
559
+ print(f"[{cfg.repo}] Error checking PR #{pr_number}: {e}", flush=True)
560
+ return False
561
+
562
+
563
+ def clean_completed_issues_from_state(cfg: RepoConfig) -> None:
564
+ """Remove completed issues from state.json."""
565
+ state = load_state(cfg.root)
566
+ active_issues = state.get("activeIssues", {})
567
+
568
+ if not isinstance(active_issues, dict):
569
+ return
570
+
571
+ # Check each issue to see if it's still open
572
+ issues_to_remove = []
573
+ for issue_num_str, issue_data in active_issues.items():
574
+ if not isinstance(issue_data, dict):
575
+ continue
576
+
577
+ issue_num = int(issue_data.get("number", -1))
578
+ if issue_num <= 0:
579
+ continue
580
+
581
+ # Check if issue is closed
582
+ try:
583
+ cmd = ["gh", "issue", "view", str(issue_num), "--repo", cfg.repo, "--json", "state,closed"]
584
+ result = json.loads(sh(cmd, check=False))
585
+ if result.get("state") == "CLOSED" or result.get("closed"):
586
+ print(f"[{cfg.repo}] Issue #{issue_num} is closed, removing from state", flush=True)
587
+ issues_to_remove.append(issue_num_str)
588
+ except Exception:
589
+ pass
590
+
591
+ # Remove completed issues
592
+ for issue_num_str in issues_to_remove:
593
+ del active_issues[issue_num_str]
594
+
595
+ if issues_to_remove:
596
+ state["activeIssues"] = active_issues
597
+ write_state(cfg.root, state)
598
+
599
+
320
600
  def maybe_mark_blocked(cfg: RepoConfig, issue: dict[str, Any]) -> None:
321
601
  num = int(issue["number"])
322
602
  if is_heartbeat_fresh(cfg, num):
@@ -358,12 +638,33 @@ def run_cycle(
358
638
 
359
639
  for cfg in all_configs:
360
640
  print(f"[{cfg.repo}] Scanning for issues...", flush=True)
361
- # 1) First, check any in-progress issues and mark blocked if stale.
641
+
642
+ # 0) Clean up completed issues from state
643
+ if not dry_run:
644
+ clean_completed_issues_from_state(cfg)
645
+
646
+ # 1) Check in-progress issues
362
647
  inprog = list_in_progress_issues(cfg)
363
648
  for it in inprog:
364
- # This is conservative: only mark blocked if we have no fresh local heartbeat.
649
+ issue_num = int(it["number"])
650
+
651
+ # First check if we should mark as blocked due to stale heartbeat
365
652
  if not dry_run:
366
- maybe_mark_blocked(cfg, it)
653
+ # Only mark blocked if heartbeat is stale
654
+ if not is_heartbeat_fresh(cfg, issue_num):
655
+ maybe_mark_blocked(cfg, it)
656
+ continue
657
+
658
+ # If heartbeat is fresh and autoMerge is enabled, try to merge PR
659
+ if cfg.auto_merge:
660
+ pr = check_pr_for_issue(cfg, issue_num)
661
+ if pr:
662
+ print(f"[{cfg.repo}] Found PR for issue #{issue_num}, checking if ready to merge...", flush=True)
663
+ if try_merge_pr(cfg, issue_num, pr):
664
+ print(f"[{cfg.repo}] Issue #{issue_num} completed!", flush=True)
665
+ else:
666
+ # Touch heartbeat to keep it alive while waiting for checks
667
+ touch_heartbeat(cfg, issue_num)
367
668
 
368
669
  # 2) Count active issues with fresh heartbeats from local state
369
670
  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