autopilot-code 0.0.5 → 0.0.6

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,14 +2,29 @@
2
2
  "enabled": true,
3
3
  "repo": "bakkensoftware/autopilot",
4
4
  "agent": "opencode",
5
+ "allowedMergeUsers": [
6
+ "kellyelton"
7
+ ],
5
8
  "issueLabels": {
6
- "queue": ["autopilot:todo"],
9
+ "queue": [
10
+ "autopilot:todo"
11
+ ],
7
12
  "blocked": "autopilot:blocked",
8
13
  "inProgress": "autopilot:in-progress",
9
14
  "done": "autopilot:done"
10
15
  },
11
- "priorityLabels": ["p0", "p1", "p2"],
16
+ "priorityLabels": [
17
+ "p0",
18
+ "p1",
19
+ "p2"
20
+ ],
21
+ "minPriority": null,
22
+ "ignoreIssueLabels": [
23
+ "autopilot:backlog"
24
+ ],
12
25
  "maxParallel": 1,
13
26
  "branchPrefix": "autopilot/",
14
- "allowedBaseBranch": "main"
27
+ "allowedBaseBranch": "main",
28
+ "autoResolveConflicts": true,
29
+ "conflictResolutionMaxAttempts": 3
15
30
  }
package/README.md CHANGED
@@ -22,6 +22,8 @@ Example:
22
22
  "enabled": true,
23
23
  "repo": "bakkensoftware/autopilot",
24
24
  "agent": "opencode",
25
+ "autoMerge": false,
26
+ "allowedMergeUsers": ["github-username"],
25
27
  "issueLabels": {
26
28
  "queue": ["autopilot:todo"],
27
29
  "blocked": "autopilot:blocked",
@@ -39,6 +41,8 @@ Example:
39
41
  Notes:
40
42
  - `repo` must be the GitHub `owner/name`.
41
43
  - `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.
45
+ - `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.
42
46
  - `ignoreIssueLabels` (optional, default `["autopilot:backlog"]`): issues with any of these labels will be ignored by the runner.
43
47
  - `heartbeatMaxAgeSecs` controls how long an in-progress issue can go without a heartbeat before it's considered stale.
44
48
 
@@ -117,13 +121,10 @@ node dist/cli.js service --foreground --interval-seconds 60 --root /mnt/f/Source
117
121
  The foreground service mode runs continuously with the specified interval and logs to stdout. Press Ctrl+C to shut down cleanly.
118
122
 
119
123
  ## Roadmap
120
- - Spawn a coding agent (Claude Code / OpenCode) in a worktree per issue
121
- - Create PRs linked to issues; wait for checks to go green
122
- - Merge PRs automatically when mergeable + checks pass
124
+ - ~~Spawn a coding agent (Claude Code / OpenCode) in a worktree per issue~~ (done)
125
+ - ~~Create PRs linked to issues; wait for checks to go green~~ (done)
126
+ - ~~Merge PRs automatically when mergeable + checks pass~~ (done)
123
127
  - Close issues + apply `autopilot:done`
124
- - Richer durable state:
125
- - PR number / branch name / last heartbeat comment
126
- - “blocked reason” codified
127
128
 
128
129
  ## Config template
129
130
  See `templates/autopilot.json`.
package/dist/cli.js CHANGED
@@ -14,6 +14,8 @@ const packageJson = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.j
14
14
  const version = packageJson.version;
15
15
  const GLOBAL_CONFIG_DIR = node_path_1.default.join(process.env.HOME || "", ".config", "autopilot");
16
16
  const GLOBAL_CONFIG_FILE = node_path_1.default.join(GLOBAL_CONFIG_DIR, "config.json");
17
+ const SYSTEM_DIR = "/etc/systemd/system";
18
+ const USER_DIR = node_path_1.default.join(process.env.HOME || "", ".config", "systemd", "user");
17
19
  function run(cmd, args) {
18
20
  const res = (0, node_child_process_1.spawnSync)(cmd, args, { stdio: "inherit" });
19
21
  if (res.error)
@@ -107,6 +109,150 @@ async function collectSourceFolders() {
107
109
  }
108
110
  return folders;
109
111
  }
112
+ function isRoot() {
113
+ return process.getuid?.() === 0;
114
+ }
115
+ function getSystemdPaths() {
116
+ const useSystem = isRoot();
117
+ const unitPath = useSystem ? node_path_1.default.join(SYSTEM_DIR, "autopilot.service") : node_path_1.default.join(USER_DIR, "autopilot.service");
118
+ const logPath = node_path_1.default.join(GLOBAL_CONFIG_DIR, "autopilot.log");
119
+ return { unitPath, logPath, useSystem };
120
+ }
121
+ function generateSystemdUnit(logPath, intervalSeconds) {
122
+ const execPath = process.execPath;
123
+ const args = [
124
+ "service",
125
+ "--foreground",
126
+ "--interval-seconds", intervalSeconds,
127
+ ];
128
+ const user = process.env.USER || "root";
129
+ return `[Unit]
130
+ Description=Autopilot automated issue runner
131
+ After=network.target
132
+
133
+ [Service]
134
+ Type=simple
135
+ ExecStart=${execPath} ${args.join(" ")}
136
+ Restart=always
137
+ RestartSec=10
138
+ StandardOutput=journal
139
+ StandardError=journal
140
+ SyslogIdentifier=autopilot
141
+ ${isRoot() ? `User=${user}` : ""}
142
+ Environment="NODE_ENV=production"
143
+
144
+ [Install]
145
+ WantedBy=multi-user.target
146
+ `;
147
+ }
148
+ function systemctl(cmd, args, check = false) {
149
+ const cmdArgs = [cmd, ...args];
150
+ const result = (0, node_child_process_1.spawnSync)("systemctl", cmdArgs, { stdio: "inherit" });
151
+ if (result.error) {
152
+ console.error(`Failed to run systemctl: ${result.error.message}`);
153
+ return false;
154
+ }
155
+ if (result.status !== 0) {
156
+ if (!check) {
157
+ console.error(`systemctl ${cmd} failed with exit code ${result.status}`);
158
+ }
159
+ return false;
160
+ }
161
+ return true;
162
+ }
163
+ function installSystemdService() {
164
+ const { unitPath, logPath, useSystem } = getSystemdPaths();
165
+ const intervalSeconds = "60";
166
+ if (!(0, node_fs_1.existsSync)(GLOBAL_CONFIG_FILE)) {
167
+ console.error("Configuration not found. Run 'autopilot init' first.");
168
+ process.exit(1);
169
+ }
170
+ if (!systemctl("--version", [], true)) {
171
+ console.error("systemd is not available on this system.");
172
+ process.exit(1);
173
+ }
174
+ const unitDir = node_path_1.default.dirname(unitPath);
175
+ if (!(0, node_fs_1.existsSync)(unitDir)) {
176
+ (0, node_fs_1.mkdirSync)(unitDir, { recursive: true });
177
+ }
178
+ const unitContent = generateSystemdUnit(logPath, intervalSeconds);
179
+ (0, node_fs_1.writeFileSync)(unitPath, unitContent, { mode: 0o644 });
180
+ console.log(`Systemd unit file created at: ${unitPath}`);
181
+ const daemonReloadArgs = useSystem ? [] : ["--user"];
182
+ if (!systemctl("daemon-reload", daemonReloadArgs)) {
183
+ process.exit(1);
184
+ }
185
+ if (!systemctl("enable", ["autopilot.service", ...daemonReloadArgs])) {
186
+ process.exit(1);
187
+ }
188
+ if (!systemctl("start", ["autopilot.service", ...daemonReloadArgs])) {
189
+ process.exit(1);
190
+ }
191
+ console.log("✅ Autopilot service installed, enabled and started.");
192
+ console.log(`\nUse: journalctl ${useSystem ? "" : "--user "} -u autopilot -f`);
193
+ }
194
+ function uninstallSystemdService() {
195
+ const { unitPath, useSystem } = getSystemdPaths();
196
+ if (!(0, node_fs_1.existsSync)(unitPath)) {
197
+ console.log("Service not installed.");
198
+ return;
199
+ }
200
+ const daemonReloadArgs = useSystem ? [] : ["--user"];
201
+ if (!systemctl("stop", ["autopilot.service", ...daemonReloadArgs], true)) {
202
+ console.log("Service not running, continuing...");
203
+ }
204
+ if (!systemctl("disable", ["autopilot.service", ...daemonReloadArgs], true)) {
205
+ console.log("Service not enabled, continuing...");
206
+ }
207
+ const { spawnSync } = require("node:child_process");
208
+ const { unlinkSync } = require("node:fs");
209
+ try {
210
+ unlinkSync(unitPath);
211
+ console.log(`Removed unit file: ${unitPath}`);
212
+ }
213
+ catch (err) {
214
+ console.error(`Failed to remove unit file: ${err.message}`);
215
+ }
216
+ if (!systemctl("daemon-reload", daemonReloadArgs)) {
217
+ process.exit(1);
218
+ }
219
+ console.log("✅ Autopilot service uninstalled.");
220
+ }
221
+ function startSystemdService() {
222
+ const { unitPath, useSystem } = getSystemdPaths();
223
+ if (!(0, node_fs_1.existsSync)(unitPath)) {
224
+ console.error("Service not installed. Run 'autopilot install' first.");
225
+ process.exit(1);
226
+ }
227
+ const daemonReloadArgs = useSystem ? [] : ["--user"];
228
+ if (!systemctl("start", ["autopilot.service", ...daemonReloadArgs])) {
229
+ process.exit(1);
230
+ }
231
+ console.log("✅ Autopilot service started.");
232
+ }
233
+ function stopSystemdService() {
234
+ const { unitPath, useSystem } = getSystemdPaths();
235
+ if (!(0, node_fs_1.existsSync)(unitPath)) {
236
+ console.error("Service not installed. Run 'autopilot install' first.");
237
+ process.exit(1);
238
+ }
239
+ const daemonReloadArgs = useSystem ? [] : ["--user"];
240
+ if (!systemctl("stop", ["autopilot.service", ...daemonReloadArgs], true)) {
241
+ console.log("Service not running or already stopped.");
242
+ return;
243
+ }
244
+ console.log("✅ Autopilot service stopped.");
245
+ }
246
+ function statusSystemdService() {
247
+ const { unitPath, useSystem } = getSystemdPaths();
248
+ if (!(0, node_fs_1.existsSync)(unitPath)) {
249
+ console.log("Service not installed. Run 'autopilot install' first.");
250
+ return;
251
+ }
252
+ const daemonReloadArgs = useSystem ? [] : ["--user"];
253
+ const { spawnSync } = require("node:child_process");
254
+ spawnSync("systemctl", ["status", "autopilot.service", ...daemonReloadArgs], { stdio: "inherit" });
255
+ }
110
256
  async function initCommand() {
111
257
  console.log("🚀 Autopilot Setup\n");
112
258
  const existingConfig = loadGlobalConfig();
@@ -232,4 +378,34 @@ program
232
378
  process.exit(1);
233
379
  }
234
380
  });
381
+ program
382
+ .command("install")
383
+ .description("Install and enable autopilot as a systemd service")
384
+ .action(() => {
385
+ installSystemdService();
386
+ });
387
+ program
388
+ .command("uninstall")
389
+ .description("Uninstall and disable autopilot systemd service")
390
+ .action(() => {
391
+ uninstallSystemdService();
392
+ });
393
+ program
394
+ .command("start")
395
+ .description("Start the autopilot systemd service")
396
+ .action(() => {
397
+ startSystemdService();
398
+ });
399
+ program
400
+ .command("stop")
401
+ .description("Stop the autopilot systemd service")
402
+ .action(() => {
403
+ stopSystemdService();
404
+ });
405
+ program
406
+ .command("status")
407
+ .description("Show the status of the autopilot systemd service")
408
+ .action(() => {
409
+ statusSystemdService();
410
+ });
235
411
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autopilot-code",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -76,6 +76,13 @@ class RepoConfig:
76
76
  heartbeat_max_age_secs: int
77
77
  agent: str
78
78
  ignore_issue_labels: list[str]
79
+ min_priority: str | None
80
+ auto_merge: bool
81
+ allowed_merge_users: list[str]
82
+ auto_resolve_conflicts: bool
83
+ conflict_resolution_max_attempts: int
84
+ auto_fix_checks: bool
85
+ auto_fix_checks_max_attempts: int
79
86
 
80
87
 
81
88
  def load_config(repo_root: Path) -> RepoConfig | None:
@@ -89,6 +96,21 @@ def load_config(repo_root: Path) -> RepoConfig | None:
89
96
  labels = data.get("issueLabels", {})
90
97
  queue = labels.get("queue", ["autopilot:todo"])
91
98
 
99
+ auto_merge = data.get("autoMerge", True)
100
+ allowed_merge_users = list(data.get("allowedMergeUsers", []))
101
+
102
+ if auto_merge and not allowed_merge_users:
103
+ raise RuntimeError(
104
+ "autoMerge is enabled but allowedMergeUsers list is empty or missing. "
105
+ "Please specify at least one allowed GitHub username."
106
+ )
107
+
108
+ auto_resolve_conflicts = data.get("autoResolveConflicts", True)
109
+ conflict_resolution_max_attempts = int(data.get("conflictResolutionMaxAttempts", 3))
110
+
111
+ auto_fix_checks = data.get("autoFixChecks", True)
112
+ auto_fix_checks_max_attempts = int(data.get("autoFixChecksMaxAttempts", 3))
113
+
92
114
  return RepoConfig(
93
115
  root=repo_root,
94
116
  repo=data["repo"],
@@ -104,6 +126,13 @@ def load_config(repo_root: Path) -> RepoConfig | None:
104
126
  ),
105
127
  agent=data.get("agent", "none"),
106
128
  ignore_issue_labels=list(data.get("ignoreIssueLabels", ["autopilot:backlog"])),
129
+ min_priority=data.get("minPriority"),
130
+ auto_merge=auto_merge,
131
+ allowed_merge_users=allowed_merge_users,
132
+ auto_resolve_conflicts=auto_resolve_conflicts,
133
+ conflict_resolution_max_attempts=conflict_resolution_max_attempts,
134
+ auto_fix_checks=auto_fix_checks,
135
+ auto_fix_checks_max_attempts=auto_fix_checks_max_attempts,
107
136
  )
108
137
 
109
138
 
@@ -184,6 +213,11 @@ def list_candidate_issues(cfg: RepoConfig, limit: int = 10) -> list[dict[str, An
184
213
  return i
185
214
  return 999
186
215
 
216
+ # Filter by minPriority if set
217
+ if cfg.min_priority and cfg.min_priority in cfg.priority_labels:
218
+ cutoff = cfg.priority_labels.index(cfg.min_priority)
219
+ issues = [it for it in issues if pri_rank(it) <= cutoff]
220
+
187
221
  issues.sort(key=lambda x: (pri_rank(x), x.get("createdAt", "")))
188
222
  return issues
189
223
 
@@ -216,7 +250,9 @@ def touch_heartbeat(cfg: RepoConfig, issue_number: int) -> None:
216
250
  """
217
251
  state = load_state(cfg.root)
218
252
  now = int(time.time())
219
- state["activeIssue"] = {
253
+ if "activeIssues" not in state:
254
+ state["activeIssues"] = {}
255
+ state["activeIssues"][str(issue_number)] = {
220
256
  "number": issue_number,
221
257
  "updatedAt": now,
222
258
  "repo": cfg.repo,
@@ -226,7 +262,13 @@ def touch_heartbeat(cfg: RepoConfig, issue_number: int) -> None:
226
262
 
227
263
  def is_heartbeat_fresh(cfg: RepoConfig, issue_number: int) -> bool:
228
264
  state = load_state(cfg.root)
229
- active = state.get("activeIssue")
265
+ active_issues = state.get("activeIssues", {})
266
+ if not isinstance(active_issues, dict):
267
+ return False
268
+ issue_key = str(issue_number)
269
+ if issue_key not in active_issues:
270
+ return False
271
+ active = active_issues[issue_key]
230
272
  if not isinstance(active, dict):
231
273
  return False
232
274
  if int(active.get("number", -1)) != int(issue_number):
@@ -321,37 +363,50 @@ def run_cycle(
321
363
  if not dry_run:
322
364
  maybe_mark_blocked(cfg, it)
323
365
 
324
- # 2) If nothing is currently in progress, claim one from the queue.
325
- # (We only support one active issue per repo in this MVP.)
326
- if inprog:
366
+ # 2) Count active issues with fresh heartbeats from local state
367
+ state = load_state(cfg.root)
368
+ active_issues = state.get("activeIssues", {})
369
+ fresh_count = 0
370
+ if isinstance(active_issues, dict):
371
+ for issue_num_str, issue_data in active_issues.items():
372
+ if isinstance(issue_data, dict):
373
+ issue_num = int(issue_data.get("number", -1))
374
+ if is_heartbeat_fresh(cfg, issue_num):
375
+ fresh_count += 1
376
+
377
+ # 3) Determine how many new issues we can claim (respect maxParallel)
378
+ max_to_claim = cfg.max_parallel - fresh_count
379
+ if max_to_claim <= 0:
327
380
  continue
328
381
 
382
+ # 4) Claim new issues from the queue (respecting priority labels)
329
383
  issues = list_candidate_issues(cfg)
330
384
  if not issues:
331
385
  continue
332
386
 
333
- issue = issues[0]
334
- msg = (
335
- f"Autopilot claimed this issue at {time.strftime('%Y-%m-%d %H:%M:%S %Z')}.\n\n"
336
- "Next: implement fix and open PR.\n\n"
337
- "(Durable tracking: this repo will maintain `.autopilot/state.json` as a heartbeat; "
338
- "the runner does not inspect processes.)"
339
- )
340
- print(f"[{cfg.repo}] next issue: #{issue['number']} {issue['title']}")
341
- if dry_run:
342
- continue
343
- claim_issue(cfg, issue, msg)
344
- claimed_count += 1
345
-
346
- # If agent==opencode, delegate to bash script
347
- if cfg.agent == "opencode":
348
- sh(
349
- [
350
- "/home/kellye/clawd/repos/autopilot/scripts/run_opencode_issue.sh",
351
- str(cfg.root),
352
- str(issue["number"]),
353
- ]
387
+ for issue in issues[:max_to_claim]:
388
+ msg = (
389
+ f"Autopilot claimed this issue at {time.strftime('%Y-%m-%d %H:%M:%S %Z')}.\n\n"
390
+ "Next: implement fix and open PR.\n\n"
391
+ "(Durable tracking: this repo will maintain `.autopilot/state.json` as a heartbeat; "
392
+ "the runner does not inspect processes.)"
354
393
  )
394
+ print(f"[{cfg.repo}] next issue: #{issue['number']} {issue['title']}")
395
+ if dry_run:
396
+ claimed_count += 1
397
+ continue
398
+ claim_issue(cfg, issue, msg)
399
+ claimed_count += 1
400
+
401
+ # If agent==opencode, delegate to bash script
402
+ if cfg.agent == "opencode":
403
+ sh(
404
+ [
405
+ "/home/kellye/clawd/repos/autopilot/scripts/run_opencode_issue.sh",
406
+ str(cfg.root),
407
+ str(issue["number"]),
408
+ ]
409
+ )
355
410
 
356
411
  return claimed_count
357
412
 
@@ -11,11 +11,23 @@ fi
11
11
 
12
12
  cd "$REPO_DIR"
13
13
 
14
- # Read repo name from config (prefer jq, fallback to python3)
14
+ # Read config (prefer jq, fallback to python3)
15
15
  if command -v jq >/dev/null 2>&1; then
16
16
  REPO=$(jq -r '.repo' < .autopilot/autopilot.json)
17
+ AUTO_MERGE=$(jq -r '.autoMerge // true' < .autopilot/autopilot.json)
18
+ AUTO_RESOLVE_CONFLICTS=$(jq -r '.autoResolveConflicts // true' < .autopilot/autopilot.json)
19
+ CONFLICT_RESOLUTION_MAX_ATTEMPTS=$(jq -r '.conflictResolutionMaxAttempts // 3' < .autopilot/autopilot.json)
20
+ AUTO_FIX_CHECKS=$(jq -r '.autoFixChecks // true' < .autopilot/autopilot.json)
21
+ AUTO_FIX_CHECKS_MAX_ATTEMPTS=$(jq -r '.autoFixChecksMaxAttempts // 3' < .autopilot/autopilot.json)
22
+ ALLOWED_USERS=$(jq -r '.allowedMergeUsers[]' < .autopilot/autopilot.json 2>/dev/null || true)
17
23
  else
18
24
  REPO=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json"))["repo"])')
25
+ AUTO_MERGE=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoMerge", True))')
26
+ AUTO_RESOLVE_CONFLICTS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoResolveConflicts", True))')
27
+ CONFLICT_RESOLUTION_MAX_ATTEMPTS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("conflictResolutionMaxAttempts", 3))')
28
+ AUTO_FIX_CHECKS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoFixChecks", True))')
29
+ AUTO_FIX_CHECKS_MAX_ATTEMPTS=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json")).get("autoFixChecksMaxAttempts", 3))')
30
+ ALLOWED_USERS=$(python3 -c 'import json,sys; users=json.load(open(".autopilot/autopilot.json")).get("allowedMergeUsers", []); print("\n".join(users))' 2>/dev/null || true)
19
31
  fi
20
32
 
21
33
  WORKTREE="/tmp/autopilot-issue-$ISSUE_NUMBER"
@@ -83,4 +95,283 @@ if [[ -n "$PR_URL" ]]; then
83
95
  gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "Autopilot opened PR: $PR_URL" || true
84
96
  fi
85
97
 
98
+ # 8.5. Handle merge conflicts if auto-resolve is enabled
99
+ if [[ "$AUTO_RESOLVE_CONFLICTS" == "true" ]] && [[ -n "$PR_URL" ]]; then
100
+ echo "[run_opencode_issue.sh] Checking for merge conflicts..."
101
+
102
+ # Get PR merge status
103
+ MERGE_STATUS=$(gh pr view --repo "$REPO" --head "$BRANCH" --json mergeable,mergeStateStatus --jq '{mergeable, mergeStateStatus}' 2>/dev/null || echo '{"mergeable":"UNKNOWN"}')
104
+
105
+ if command -v jq >/dev/null 2>&1; then
106
+ IS_CONFLICTING=$(echo "$MERGE_STATUS" | jq -r 'if .mergeable == "CONFLICTING" or .mergeStateStatus == "DIRTY" then "true" else "false" end')
107
+ else
108
+ IS_CONFLICTING=$(python3 -c 'import json,sys; d=json.load(sys.stdin); print("true" if d.get("mergeable") == "CONFLICTING" or d.get("mergeStateStatus") == "DIRTY" else "false")' <<<"$MERGE_STATUS")
109
+ fi
110
+
111
+ if [[ "$IS_CONFLICTING" == "true" ]]; then
112
+ echo "[run_opencode_issue.sh] PR has merge conflicts. Attempting auto-resolution..."
113
+
114
+ RESOLVE_ATTEMPT=0
115
+ MAX_ATTEMPTS=$((CONFLICT_RESOLUTION_MAX_ATTEMPTS + 0))
116
+
117
+ while [[ $RESOLVE_ATTEMPT -lt $MAX_ATTEMPTS ]]; do
118
+ RESOLVE_ATTEMPT=$((RESOLVE_ATTEMPT + 1))
119
+ echo "[run_opencode_issue.sh] Conflict resolution attempt $RESOLVE_ATTEMPT/$MAX_ATTEMPTS"
120
+
121
+ # Fetch latest main
122
+ echo "[run_opencode_issue.sh] Fetching latest main..."
123
+ git fetch origin main || true
124
+
125
+ # Checkout branch and try to rebase
126
+ echo "[run_opencode_issue.sh] Rebasing branch onto main..."
127
+ cd "$WORKTREE"
128
+ if git rebase origin/main; then
129
+ echo "[run_opencode_issue.sh] Rebase successful."
130
+
131
+ # Push rebased changes
132
+ git push -f origin "$BRANCH" || true
133
+
134
+ # Check if conflicts are resolved
135
+ MERGE_STATUS=$(gh pr view --repo "$REPO" --head "$BRANCH" --json mergeable,mergeStateStatus --jq '{mergeable, mergeStateStatus}' 2>/dev/null || echo '{"mergeable":"UNKNOWN"}')
136
+
137
+ if command -v jq >/dev/null 2>&1; then
138
+ IS_CONFLICTING=$(echo "$MERGE_STATUS" | jq -r 'if .mergeable == "CONFLICTING" or .mergeStateStatus == "DIRTY" then "true" else "false" end')
139
+ else
140
+ IS_CONFLICTING=$(python3 -c 'import json,sys; d=json.load(sys.stdin); print("true" if d.get("mergeable") == "CONFLICTING" or d.get("mergeStateStatus") == "DIRTY" else "false")' <<<"$MERGE_STATUS")
141
+ fi
142
+
143
+ if [[ "$IS_CONFLICTING" == "false" ]]; then
144
+ echo "[run_opencode_issue.sh] Conflicts resolved successfully."
145
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "✅ Autopilot successfully resolved merge conflicts." || true
146
+ break
147
+ fi
148
+ else
149
+ # Rebase failed - there are conflicts to resolve
150
+ echo "[run_opencode_issue.sh] Rebase encountered conflicts. Attempting resolution with agent..."
151
+
152
+ # Abort the rebase to get back to a clean state
153
+ git rebase --abort 2>/dev/null || true
154
+
155
+ # Try merging main instead (may be easier to resolve)
156
+ if git merge origin/main; then
157
+ echo "[run_opencode_issue.sh] Merge successful."
158
+ git push -f origin "$BRANCH" || true
159
+
160
+ # Check if conflicts are resolved
161
+ MERGE_STATUS=$(gh pr view --repo "$REPO" --head "$BRANCH" --json mergeable,mergeStateStatus --jq '{mergeable, mergeStateStatus}' 2>/dev/null || echo '{"mergeable":"UNKNOWN"}')
162
+
163
+ if command -v jq >/dev/null 2>&1; then
164
+ IS_CONFLICTING=$(echo "$MERGE_STATUS" | jq -r 'if .mergeable == "CONFLICTING" or .mergeStateStatus == "DIRTY" then "true" else "false" end')
165
+ else
166
+ IS_CONFLICTING=$(python3 -c 'import json,sys; d=json.load(sys.stdin); print("true" if d.get("mergeable") == "CONFLICTING" or d.get("mergeStateStatus") == "DIRTY" else "false")' <<<"$MERGE_STATUS")
167
+ fi
168
+
169
+ if [[ "$IS_CONFLICTING" == "false" ]]; then
170
+ echo "[run_opencode_issue.sh] Conflicts resolved successfully."
171
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "✅ Autopilot successfully resolved merge conflicts." || true
172
+ break
173
+ fi
174
+ else
175
+ # Merge also has conflicts - abort and try to resolve with agent
176
+ git merge --abort 2>/dev/null || true
177
+
178
+ echo "[run_opencode_issue.sh] Attempting to resolve conflicts with opencode agent..."
179
+
180
+ # Use opencode to resolve conflicts
181
+ CONFLICT_PROMPT="This PR has merge conflicts. Please resolve them by examining the conflicted files and making the necessary edits to resolve the conflicts. Follow these steps:
182
+ 1. Run 'git status' to see all conflicted files
183
+ 2. For each conflicted file, examine the conflict markers (<<<<<<<, =======, >>>>>>>)
184
+ 3. Resolve the conflicts by choosing the appropriate code
185
+ 4. Stage the resolved files with 'git add <file>'
186
+ 5. Commit the resolved files if needed
187
+ 6. The goal is to make the branch mergeable with main
188
+
189
+ After resolving all conflicts, report the files that were resolved."
190
+
191
+ if opencode run "$CONFLICT_PROMPT"; then
192
+ # Check if there are still conflicts
193
+ if [[ -z "$(git diff --name-only --diff-filter=U)" ]]; then
194
+ echo "[run_opencode_issue.sh] Conflicts resolved by agent."
195
+
196
+ # Complete the merge or rebase
197
+ git add -A
198
+
199
+ # If we were in the middle of a merge, complete it
200
+ if [[ -f ".git/MERGE_HEAD" ]]; then
201
+ git commit --no-edit || true
202
+ fi
203
+
204
+ # Push resolved changes
205
+ git push -f origin "$BRANCH" || true
206
+
207
+ # Check if conflicts are resolved
208
+ MERGE_STATUS=$(gh pr view --repo "$REPO" --head "$BRANCH" --json mergeable,mergeStateStatus --jq '{mergeable, mergeStateStatus}' 2>/dev/null || echo '{"mergeable":"UNKNOWN"}')
209
+
210
+ if command -v jq >/dev/null 2>&1; then
211
+ IS_CONFLICTING=$(echo "$MERGE_STATUS" | jq -r 'if .mergeable == "CONFLICTING" or .mergeStateStatus == "DIRTY" then "true" else "false" end')
212
+ else
213
+ IS_CONFLICTING=$(python3 -c 'import json,sys; d=json.load(sys.stdin); print("true" if d.get("mergeable") == "CONFLICTING" or d.get("mergeStateStatus") == "DIRTY" else "false")' <<<"$MERGE_STATUS")
214
+ fi
215
+
216
+ if [[ "$IS_CONFLICTING" == "false" ]]; then
217
+ echo "[run_opencode_issue.sh] Conflicts resolved successfully."
218
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "✅ Autopilot successfully resolved merge conflicts." || true
219
+ break
220
+ fi
221
+ fi
222
+ fi
223
+ fi
224
+ fi
225
+
226
+ # If we're here, resolution failed and we should try again
227
+ echo "[run_opencode_issue.sh] Conflict resolution attempt $RESOLVE_ATTEMPT failed."
228
+
229
+ if [[ $RESOLVE_ATTEMPT -ge $MAX_ATTEMPTS ]]; then
230
+ echo "[run_opencode_issue.sh] Failed to resolve conflicts after $MAX_ATTEMPTS attempts."
231
+
232
+ # Mark issue as blocked
233
+ if command -v jq >/dev/null 2>&1; then
234
+ LABEL_BLOCKED=$(jq -r '.issueLabels.blocked' < .autopilot/autopilot.json)
235
+ else
236
+ LABEL_BLOCKED=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json"))["issueLabels"]["blocked"])')
237
+ fi
238
+
239
+ BLOCKED_MSG="❌ Autopilot failed to resolve merge conflicts after $MAX_ATTEMPTS attempts.\n\nThe PR will remain open but autopilot has stopped attempting to resolve conflicts.\n\nTo resolve manually:\n1. Checkout the branch: \`git checkout $BRANCH\`\n2. Fetch and rebase main: \`git fetch origin main && git rebase origin/main\`\n3. Resolve conflicts as needed\n4. Push resolved changes: \`git push -f origin $BRANCH\`\n\nOnce resolved, autopilot can attempt to merge the PR."
240
+
241
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$BLOCKED_MSG" || true
242
+ gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label "$LABEL_BLOCKED" || true
243
+
244
+ exit 1
245
+ fi
246
+
247
+ # Wait a bit before next attempt
248
+ sleep 5
249
+ done
250
+ fi
251
+ fi
252
+
253
+ # 9. Auto-merge if enabled and user is allowed
254
+ if [[ "$AUTO_MERGE" == "true" ]] && [[ -n "$PR_URL" ]]; then
255
+ # Get authenticated GitHub user
256
+ AUTH_USER=$(gh api user --jq .login)
257
+
258
+ # Check if user is in allowed list
259
+ USER_ALLOWED=false
260
+ if [[ -n "$ALLOWED_USERS" ]]; then
261
+ while IFS= read -r user; do
262
+ if [[ "$AUTH_USER" == "$user" ]]; then
263
+ USER_ALLOWED=true
264
+ break
265
+ fi
266
+ done <<< "$ALLOWED_USERS"
267
+ fi
268
+
269
+ if [[ "$USER_ALLOWED" == "true" ]]; then
270
+ echo "[run_opencode_issue.sh] Auto-merge enabled. Waiting for PR checks to pass..."
271
+
272
+ # Initialize auto-fix attempt counter
273
+ FIX_ATTEMPT=0
274
+
275
+ # Poll PR checks until they pass or fail
276
+ MAX_POLL_ATTEMPTS=60
277
+ POLL_INTERVAL=30
278
+ POLL_ATTEMPT=0
279
+
280
+ while [[ $POLL_ATTEMPT -lt $MAX_POLL_ATTEMPTS ]]; do
281
+ POLL_ATTEMPT=$((POLL_ATTEMPT + 1))
282
+
283
+ # Get PR check status
284
+ CHECK_STATUS=$(gh pr view --repo "$REPO" --head "$BRANCH" --json mergeable,mergeStateStatus,statusCheckRollup --jq '.statusCheckRollup | map(.conclusion) | if any(. == "FAILURE") then "FAILED" elif any(. == "PENDING") or any(. == "QUEUED") then "PENDING" else "PASSED" end' 2>/dev/null || echo "PENDING")
285
+
286
+ echo "[run_opencode_issue.sh] Poll attempt $POLL_ATTEMPT/$MAX_POLL_ATTEMPTS: Check status = $CHECK_STATUS"
287
+
288
+ if [[ "$CHECK_STATUS" == "PASSED" ]]; then
289
+ echo "[run_opencode_issue.sh] All checks passed. Proceeding with merge..."
290
+ break
291
+ elif [[ "$CHECK_STATUS" == "FAILED" ]]; then
292
+ echo "[run_opencode_issue.sh] PR checks failed."
293
+
294
+ # Check if auto-fix is enabled
295
+ if [[ "$AUTO_FIX_CHECKS" == "true" ]] && [[ $FIX_ATTEMPT -lt $AUTO_FIX_CHECKS_MAX_ATTEMPTS ]]; then
296
+ FIX_ATTEMPT=$((FIX_ATTEMPT + 1))
297
+ echo "[run_opencode_issue.sh] Auto-fix enabled. Attempting to fix failing checks ($FIX_ATTEMPT/$AUTO_FIX_CHECKS_MAX_ATTEMPTS)..."
298
+
299
+ # Fetch failed check details and logs
300
+ CHECK_RUNS_JSON=$(gh api "repos/$REPO/commits/$(git rev-parse HEAD)/check-runs" --jq '.check_runs[] | select(.conclusion == "FAILURE") | {name: .name, conclusion: .conclusion, details_url: .details_url, output: {title: .output.title, summary: .output.summary, text: .output.text}}' 2>/dev/null || echo "[]")
301
+
302
+ # Build failure context
303
+ FAILURE_CONTEXT="The following CI checks failed:\n"
304
+
305
+ # Add failure details to context
306
+ if [[ -n "$CHECK_RUNS_JSON" ]]; then
307
+ FAILURE_CONTEXT+="$CHECK_RUNS_JSON\n"
308
+ fi
309
+
310
+ # Generate repair prompt
311
+ REPAIR_PROMPT="The PR checks have failed. Please analyze the CI failures and fix the issues.\n\n$FAILURE_CONTEXT\n\nWork rules:\n- Examine the failed checks and identify the root cause\n- Make the necessary code changes to fix the failures\n- Commit with message: \"autopilot: fix CI check failures (attempt $FIX_ATTEMPT/$AUTO_FIX_CHECKS_MAX_ATTEMPTS)\"\n- Push your changes to the branch $BRANCH\n- Focus only on fixing the CI failures, do not make unrelated changes"
312
+
313
+ echo "[run_opencode_issue.sh] Running opencode agent to fix CI failures..."
314
+
315
+ # Run opencode to fix the issue
316
+ cd "$WORKTREE"
317
+ if opencode run "$REPAIR_PROMPT"; then
318
+ # Commit any changes
319
+ if [[ -n "$(git status --porcelain)" ]]; then
320
+ git add -A
321
+ git commit -m "autopilot: fix CI check failures (attempt $FIX_ATTEMPT/$AUTO_FIX_CHECKS_MAX_ATTEMPTS)"
322
+ echo "[run_opencode_issue.sh] Committed fixes for CI failures."
323
+
324
+ # Push changes
325
+ git push origin "$BRANCH"
326
+ echo "[run_opencode_issue.sh] Pushed fixes for CI failures."
327
+
328
+ # Reset poll counter to re-check CI status
329
+ POLL_ATTEMPT=0
330
+
331
+ # Wait a moment for checks to start
332
+ sleep 10
333
+
334
+ # Continue polling
335
+ continue
336
+ else
337
+ echo "[run_opencode_issue.sh] No changes made by opencode agent."
338
+ fi
339
+ else
340
+ echo "[run_opencode_issue.sh] Opencode agent failed to fix CI issues."
341
+ fi
342
+
343
+ # If we're here, auto-fix failed or made no changes
344
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "⚠️ Autopilot attempted to fix CI failures (attempt $FIX_ATTEMPT/$AUTO_FIX_CHECKS_MAX_ATTEMPTS) but was unable to resolve all issues." || true
345
+ fi
346
+
347
+ # If auto-fix is disabled or max attempts reached, fail
348
+ if [[ "$FIX_ATTEMPT" -ge "$AUTO_FIX_CHECKS_MAX_ATTEMPTS" ]]; then
349
+ FAILED_MSG="❌ Autopilot cannot auto-merge PR: checks failed after $AUTO_FIX_CHECKS_MAX_ATTEMPTS auto-fix attempts.\n\nPR will remain open for review."
350
+ else
351
+ FAILED_MSG="❌ Autopilot cannot auto-merge PR: checks failed (auto-fix disabled).\n\nPR will remain open for review."
352
+ fi
353
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$FAILED_MSG" || true
354
+ exit 1
355
+ fi
356
+
357
+ # Wait before next poll
358
+ if [[ $POLL_ATTEMPT -lt $MAX_POLL_ATTEMPTS ]]; then
359
+ echo "[run_opencode_issue.sh] Waiting ${POLL_INTERVAL}s before next check..."
360
+ sleep "$POLL_INTERVAL"
361
+ fi
362
+ done
363
+ else
364
+ BLOCKED_MSG="❌ Autopilot cannot auto-merge PR: authenticated user '$AUTH_USER' is not in the allowedMergeUsers list."
365
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$BLOCKED_MSG" || true
366
+
367
+ # Mark issue as blocked
368
+ if command -v jq >/dev/null 2>&1; then
369
+ LABEL_BLOCKED=$(jq -r '.issueLabels.blocked' < .autopilot/autopilot.json)
370
+ else
371
+ LABEL_BLOCKED=$(python3 -c 'import json; print(json.load(open(".autopilot/autopilot.json"))["issueLabels"]["blocked"])')
372
+ fi
373
+ gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label "$LABEL_BLOCKED" || true
374
+ fi
375
+ fi
376
+
86
377
  echo "[run_opencode_issue.sh] Done: $PR_URL"
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "enabled": true,
3
3
  "repo": "bakkensoftware/TicketToolbox",
4
+ "agent": "opencode",
5
+ "allowedMergeUsers": ["github-username"],
4
6
  "issueLabels": {
5
7
  "queue": ["autopilot:todo"],
6
8
  "blocked": "autopilot:blocked",
@@ -11,5 +13,9 @@
11
13
  "maxParallel": 2,
12
14
  "ignoreIssueLabels": ["autopilot:backlog"],
13
15
  "branchPrefix": "autopilot/",
14
- "allowedBaseBranch": "main"
16
+ "allowedBaseBranch": "main",
17
+ "autoResolveConflicts": true,
18
+ "conflictResolutionMaxAttempts": 3,
19
+ "autoFixChecks": true,
20
+ "autoFixChecksMaxAttempts": 3
15
21
  }