autopilot-code 0.0.4 → 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,9 @@ 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.
46
+ - `ignoreIssueLabels` (optional, default `["autopilot:backlog"]`): issues with any of these labels will be ignored by the runner.
42
47
  - `heartbeatMaxAgeSecs` controls how long an in-progress issue can go without a heartbeat before it's considered stale.
43
48
 
44
49
  ## Workflow (labels)
@@ -86,7 +91,11 @@ On each loop:
86
91
  ## Running locally
87
92
  ### Python runner
88
93
  ```bash
94
+ # Run a single scan/claim/act cycle
89
95
  python3 scripts/run_autopilot.py --root /mnt/f/Source
96
+
97
+ # Run in foreground loop mode (dev-friendly)
98
+ python3 scripts/run_autopilot.py --root /mnt/f/Source --interval-seconds 60
90
99
  ```
91
100
 
92
101
  ### Node CLI wrapper
@@ -104,16 +113,18 @@ node dist/cli.js scan --root /mnt/f/Source
104
113
 
105
114
  # claim exactly one issue + comment
106
115
  node dist/cli.js run-once --root /mnt/f/Source
116
+
117
+ # run service in foreground mode (dev-friendly)
118
+ node dist/cli.js service --foreground --interval-seconds 60 --root /mnt/f/Source
107
119
  ```
108
120
 
121
+ The foreground service mode runs continuously with the specified interval and logs to stdout. Press Ctrl+C to shut down cleanly.
122
+
109
123
  ## Roadmap
110
- - Spawn a coding agent (Claude Code / OpenCode) in a worktree per issue
111
- - Create PRs linked to issues; wait for checks to go green
112
- - 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)
113
127
  - Close issues + apply `autopilot:done`
114
- - Richer durable state:
115
- - PR number / branch name / last heartbeat comment
116
- - “blocked reason” codified
117
128
 
118
129
  ## Config template
119
130
  See `templates/autopilot.json`.
package/dist/cli.js CHANGED
@@ -6,7 +6,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const commander_1 = require("commander");
8
8
  const node_child_process_1 = require("node:child_process");
9
+ const node_fs_1 = require("node:fs");
9
10
  const node_path_1 = __importDefault(require("node:path"));
11
+ const node_readline_1 = require("node:readline");
12
+ const repoRoot = node_path_1.default.resolve(__dirname, "..");
13
+ const packageJson = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(repoRoot, "package.json"), "utf8"));
14
+ const version = packageJson.version;
15
+ const GLOBAL_CONFIG_DIR = node_path_1.default.join(process.env.HOME || "", ".config", "autopilot");
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");
10
19
  function run(cmd, args) {
11
20
  const res = (0, node_child_process_1.spawnSync)(cmd, args, { stdio: "inherit" });
12
21
  if (res.error)
@@ -22,16 +31,270 @@ function check(cmd, args, label) {
22
31
  }
23
32
  if (typeof res.status === "number" && res.status !== 0) {
24
33
  console.error(`\n${label}: returned exit code ${res.status}`);
25
- process.exit(res.status);
34
+ process.exit(1);
26
35
  }
27
36
  }
28
- const repoRoot = node_path_1.default.resolve(__dirname, "..");
37
+ function loadGlobalConfig() {
38
+ if (!(0, node_fs_1.existsSync)(GLOBAL_CONFIG_FILE)) {
39
+ return null;
40
+ }
41
+ try {
42
+ const data = (0, node_fs_1.readFileSync)(GLOBAL_CONFIG_FILE, "utf8");
43
+ return JSON.parse(data);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ function saveGlobalConfig(config) {
50
+ (0, node_fs_1.mkdirSync)(GLOBAL_CONFIG_DIR, { recursive: true });
51
+ (0, node_fs_1.writeFileSync)(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
52
+ }
53
+ function isGitRepo(dirPath) {
54
+ return (0, node_fs_1.existsSync)(node_path_1.default.join(dirPath, ".git"));
55
+ }
56
+ function prompt(question) {
57
+ const rl = (0, node_readline_1.createInterface)({
58
+ input: process.stdin,
59
+ output: process.stdout,
60
+ });
61
+ return new Promise((resolve) => {
62
+ rl.question(question, (answer) => {
63
+ rl.close();
64
+ resolve(answer.trim());
65
+ });
66
+ });
67
+ }
68
+ function validatePath(pathStr) {
69
+ const resolvedPath = node_path_1.default.resolve(pathStr);
70
+ if (!(0, node_fs_1.existsSync)(resolvedPath)) {
71
+ return { valid: false, error: `Path does not exist: ${pathStr}` };
72
+ }
73
+ if (!isGitRepo(resolvedPath)) {
74
+ return { valid: true, isGit: false };
75
+ }
76
+ return { valid: true, isGit: true };
77
+ }
78
+ async function collectSourceFolders() {
79
+ const folders = [];
80
+ console.log("\n📁 Source Folder Selection");
81
+ console.log("Enter one or more directories containing git repos to scan.");
82
+ console.log("Leave empty when done.\n");
83
+ while (true) {
84
+ const input = await prompt(`Source folder #${folders.length + 1} (or press Enter to finish): `);
85
+ if (input === "") {
86
+ if (folders.length === 0) {
87
+ console.log("At least one source folder is required.");
88
+ continue;
89
+ }
90
+ break;
91
+ }
92
+ const validation = validatePath(input);
93
+ if (!validation.valid) {
94
+ console.error(`❌ ${validation.error}`);
95
+ continue;
96
+ }
97
+ const resolvedPath = node_path_1.default.resolve(input);
98
+ if (folders.includes(resolvedPath)) {
99
+ console.log("⚠️ This folder is already in the list.");
100
+ continue;
101
+ }
102
+ if (validation.isGit) {
103
+ console.log(`✅ Added git repo: ${resolvedPath}`);
104
+ }
105
+ else {
106
+ console.log(`✅ Added folder: ${resolvedPath} (will scan for git repos recursively)`);
107
+ }
108
+ folders.push(resolvedPath);
109
+ }
110
+ return folders;
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
+ }
256
+ async function initCommand() {
257
+ console.log("🚀 Autopilot Setup\n");
258
+ const existingConfig = loadGlobalConfig();
259
+ if (existingConfig && existingConfig.sourceFolders.length > 0) {
260
+ console.log("Existing configuration found:");
261
+ console.log("Current source folders:");
262
+ existingConfig.sourceFolders.forEach((folder, idx) => {
263
+ const isGit = isGitRepo(folder);
264
+ console.log(` ${idx + 1}. ${folder}${isGit ? " (git repo)" : ""}`);
265
+ });
266
+ console.log();
267
+ const overwrite = await prompt("Do you want to replace this configuration? (y/N): ");
268
+ if (overwrite.toLowerCase() !== "y") {
269
+ console.log("Configuration unchanged.");
270
+ return;
271
+ }
272
+ }
273
+ const sourceFolders = await collectSourceFolders();
274
+ const config = {
275
+ sourceFolders,
276
+ };
277
+ saveGlobalConfig(config);
278
+ console.log("\n✅ Configuration saved to:", GLOBAL_CONFIG_FILE);
279
+ console.log("\nYou can now run:");
280
+ console.log(" autopilot scan # Scan repos without claiming issues");
281
+ console.log(" autopilot run-once # Claim and work on one issue");
282
+ }
29
283
  const runnerPath = node_path_1.default.join(repoRoot, "scripts", "run_autopilot.py");
30
284
  const program = new commander_1.Command();
31
285
  program
32
286
  .name("autopilot")
33
287
  .description("Repo-issue–driven autopilot runner (CLI wrapper)")
34
- .version("0.0.0");
288
+ .version(version);
289
+ program
290
+ .command("init")
291
+ .description("Initialize autopilot configuration (select source folders)")
292
+ .action(() => {
293
+ initCommand().catch((err) => {
294
+ console.error("Error:", err);
295
+ process.exit(1);
296
+ });
297
+ });
35
298
  program
36
299
  .command("doctor")
37
300
  .description("Check local prerequisites (gh auth, python3)")
@@ -44,15 +307,105 @@ program
44
307
  program
45
308
  .command("scan")
46
309
  .description("Discover autopilot-enabled repos + show next issue candidate (dry-run)")
47
- .requiredOption("--root <path>", "Root folder that contains git repos")
310
+ .option("--root <paths...>", "Root folder(s) that contain git repos (space-separated)")
311
+ .option("--max-depth <number>", "Maximum depth for recursive repo discovery", "5")
48
312
  .action((opts) => {
49
- run("python3", [runnerPath, "--root", opts.root, "--dry-run"]);
313
+ let rootPaths = opts.root;
314
+ if (!rootPaths || rootPaths.length === 0) {
315
+ const config = loadGlobalConfig();
316
+ if (!config || config.sourceFolders.length === 0) {
317
+ console.error("No source folders configured. Run 'autopilot init' first.");
318
+ process.exit(1);
319
+ }
320
+ rootPaths = config.sourceFolders;
321
+ }
322
+ const args = [runnerPath, "--root", ...rootPaths];
323
+ if (opts.maxDepth)
324
+ args.push("--max-depth", opts.maxDepth);
325
+ args.push("--dry-run");
326
+ run("python3", args);
50
327
  });
51
328
  program
52
329
  .command("run-once")
53
330
  .description("Claim exactly one issue and post a progress comment")
54
- .requiredOption("--root <path>", "Root folder that contains git repos")
331
+ .option("--root <paths...>", "Root folder(s) that contain git repos (space-separated)")
332
+ .option("--max-depth <number>", "Maximum depth for recursive repo discovery", "5")
55
333
  .action((opts) => {
56
- run("python3", [runnerPath, "--root", opts.root]);
334
+ let rootPaths = opts.root;
335
+ if (!rootPaths || rootPaths.length === 0) {
336
+ const config = loadGlobalConfig();
337
+ if (!config || config.sourceFolders.length === 0) {
338
+ console.error("No source folders configured. Run 'autopilot init' first.");
339
+ process.exit(1);
340
+ }
341
+ rootPaths = config.sourceFolders;
342
+ }
343
+ const args = [runnerPath, "--root", ...rootPaths];
344
+ if (opts.maxDepth)
345
+ args.push("--max-depth", opts.maxDepth);
346
+ run("python3", args);
347
+ });
348
+ program
349
+ .command("service")
350
+ .description("Run the autopilot service (foreground mode with loop)")
351
+ .option("--foreground", "Run in foreground mode (log to stdout)")
352
+ .option("--interval-seconds <number>", "Interval between cycles in seconds", "60")
353
+ .option("--root <paths...>", "Root folder(s) that contain git repos (space-separated)")
354
+ .option("--max-depth <number>", "Maximum depth for recursive repo discovery", "5")
355
+ .action((opts) => {
356
+ let rootPaths = opts.root;
357
+ if (!rootPaths || rootPaths.length === 0) {
358
+ const config = loadGlobalConfig();
359
+ if (!config || config.sourceFolders.length === 0) {
360
+ console.error("No source folders configured. Run 'autopilot init' first.");
361
+ process.exit(1);
362
+ }
363
+ rootPaths = config.sourceFolders;
364
+ }
365
+ const args = [runnerPath, "--root", ...rootPaths];
366
+ if (opts.maxDepth)
367
+ args.push("--max-depth", opts.maxDepth);
368
+ if (opts.foreground) {
369
+ const interval = opts.intervalSeconds || "60";
370
+ args.push("--interval-seconds", interval);
371
+ console.log(`Starting autopilot service in foreground mode (interval: ${interval}s)`);
372
+ console.log("Press Ctrl+C to shut down cleanly\n");
373
+ run("python3", args);
374
+ }
375
+ else {
376
+ console.error("Only --foreground mode is supported currently.");
377
+ console.error("For background service mode, use systemd integration (coming soon).");
378
+ process.exit(1);
379
+ }
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();
57
410
  });
58
411
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autopilot-code",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  import argparse
18
18
  import json
19
19
  import os
20
+ import signal
20
21
  import subprocess
21
22
  import sys
22
23
  import time
@@ -27,12 +28,37 @@ from typing import Any
27
28
  STATE_DIR = ".autopilot"
28
29
  STATE_FILE = "state.json"
29
30
  DEFAULT_HEARTBEAT_MAX_AGE_SECS = 60 * 60 # 1h
31
+ DEFAULT_MAX_DEPTH = 5
32
+ IGNORED_DIRS = {
33
+ "node_modules",
34
+ ".venv",
35
+ "venv",
36
+ ".env",
37
+ "env",
38
+ ".git",
39
+ ".idea",
40
+ ".vscode",
41
+ "dist",
42
+ "build",
43
+ "target",
44
+ "bin",
45
+ "obj",
46
+ "__pycache__",
47
+ }
30
48
 
31
49
 
32
50
  def sh(cmd: list[str], cwd: Path | None = None, check: bool = True) -> str:
33
- p = subprocess.run(cmd, cwd=str(cwd) if cwd else None, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
51
+ p = subprocess.run(
52
+ cmd,
53
+ cwd=str(cwd) if cwd else None,
54
+ text=True,
55
+ stdout=subprocess.PIPE,
56
+ stderr=subprocess.STDOUT,
57
+ )
34
58
  if check and p.returncode != 0:
35
- raise RuntimeError(f"command failed ({p.returncode}): {' '.join(cmd)}\n{p.stdout}")
59
+ raise RuntimeError(
60
+ f"command failed ({p.returncode}): {' '.join(cmd)}\n{p.stdout}"
61
+ )
36
62
  return p.stdout
37
63
 
38
64
 
@@ -49,6 +75,14 @@ class RepoConfig:
49
75
  max_parallel: int
50
76
  heartbeat_max_age_secs: int
51
77
  agent: str
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
52
86
 
53
87
 
54
88
  def load_config(repo_root: Path) -> RepoConfig | None:
@@ -62,6 +96,21 @@ def load_config(repo_root: Path) -> RepoConfig | None:
62
96
  labels = data.get("issueLabels", {})
63
97
  queue = labels.get("queue", ["autopilot:todo"])
64
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
+
65
114
  return RepoConfig(
66
115
  root=repo_root,
67
116
  repo=data["repo"],
@@ -72,26 +121,67 @@ def load_config(repo_root: Path) -> RepoConfig | None:
72
121
  label_done=labels.get("done", "autopilot:done"),
73
122
  priority_labels=data.get("priorityLabels", ["p0", "p1", "p2"]),
74
123
  max_parallel=int(data.get("maxParallel", 2)),
75
- heartbeat_max_age_secs=int(data.get("heartbeatMaxAgeSecs", DEFAULT_HEARTBEAT_MAX_AGE_SECS)),
124
+ heartbeat_max_age_secs=int(
125
+ data.get("heartbeatMaxAgeSecs", DEFAULT_HEARTBEAT_MAX_AGE_SECS)
126
+ ),
76
127
  agent=data.get("agent", "none"),
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,
77
136
  )
78
137
 
79
138
 
80
- def discover_repos(root: Path) -> list[RepoConfig]:
139
+ def discover_repos(root: Path, max_depth: int = DEFAULT_MAX_DEPTH) -> list[RepoConfig]:
81
140
  out: list[RepoConfig] = []
82
- for child in root.iterdir():
83
- if not child.is_dir():
84
- continue
85
- if (child / ".git").exists() and (child / ".autopilot" / "autopilot.json").exists():
86
- cfg = load_config(child)
87
- if cfg:
88
- out.append(cfg)
141
+ _discover_repos_recursive(root, 0, max_depth, out)
89
142
  return out
90
143
 
91
144
 
145
+ def _discover_repos_recursive(
146
+ current_dir: Path, current_depth: int, max_depth: int, out: list[RepoConfig]
147
+ ) -> None:
148
+ if current_depth > max_depth:
149
+ return
150
+
151
+ # Check if current directory is a git repo with autopilot config
152
+ if (current_dir / ".git").exists() and (
153
+ current_dir / ".autopilot" / "autopilot.json"
154
+ ).exists():
155
+ cfg = load_config(current_dir)
156
+ if cfg:
157
+ out.append(cfg)
158
+ return
159
+
160
+ # Recursively search subdirectories (depth-first)
161
+ try:
162
+ for child in sorted(current_dir.iterdir(), key=lambda p: p.name.lower()):
163
+ if not child.is_dir():
164
+ continue
165
+ if child.name in IGNORED_DIRS:
166
+ continue
167
+ _discover_repos_recursive(child, current_depth + 1, max_depth, out)
168
+ except PermissionError:
169
+ pass
170
+ except OSError:
171
+ pass
172
+
173
+
92
174
  def list_candidate_issues(cfg: RepoConfig, limit: int = 10) -> list[dict[str, Any]]:
175
+ """List candidate issues in the queue.
176
+
177
+ We exclude issues containing cfg.ignore_issue_labels (e.g. autopilot:backlog).
178
+ """
179
+
93
180
  # Sort by created date asc; prioritize p0/p1/p2 by label presence.
94
181
  q = " ".join([f"label:{l}" for l in cfg.queue_labels])
182
+ excludes = " ".join([f"-label:{l}" for l in cfg.ignore_issue_labels if l])
183
+ search = f"is:open {q} {excludes}".strip()
184
+
95
185
  cmd = [
96
186
  "gh",
97
187
  "issue",
@@ -101,13 +191,21 @@ def list_candidate_issues(cfg: RepoConfig, limit: int = 10) -> list[dict[str, An
101
191
  "--limit",
102
192
  str(limit),
103
193
  "--search",
104
- f"is:open {q}",
194
+ search,
105
195
  "--json",
106
196
  "number,title,labels,updatedAt,createdAt,url",
107
197
  ]
108
198
  raw = sh(cmd)
109
199
  issues = json.loads(raw)
110
200
 
201
+ # Defense in depth in case GitHub search syntax changes.
202
+ ignore_set = set(cfg.ignore_issue_labels)
203
+ issues = [
204
+ it
205
+ for it in issues
206
+ if not ({l["name"] for l in it.get("labels", [])} & ignore_set)
207
+ ]
208
+
111
209
  def pri_rank(it: dict[str, Any]) -> int:
112
210
  labs = {l["name"] for l in it.get("labels", [])}
113
211
  for i, p in enumerate(cfg.priority_labels):
@@ -115,6 +213,11 @@ def list_candidate_issues(cfg: RepoConfig, limit: int = 10) -> list[dict[str, An
115
213
  return i
116
214
  return 999
117
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
+
118
221
  issues.sort(key=lambda x: (pri_rank(x), x.get("createdAt", "")))
119
222
  return issues
120
223
 
@@ -147,7 +250,9 @@ def touch_heartbeat(cfg: RepoConfig, issue_number: int) -> None:
147
250
  """
148
251
  state = load_state(cfg.root)
149
252
  now = int(time.time())
150
- state["activeIssue"] = {
253
+ if "activeIssues" not in state:
254
+ state["activeIssues"] = {}
255
+ state["activeIssues"][str(issue_number)] = {
151
256
  "number": issue_number,
152
257
  "updatedAt": now,
153
258
  "repo": cfg.repo,
@@ -157,7 +262,13 @@ def touch_heartbeat(cfg: RepoConfig, issue_number: int) -> None:
157
262
 
158
263
  def is_heartbeat_fresh(cfg: RepoConfig, issue_number: int) -> bool:
159
264
  state = load_state(cfg.root)
160
- 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]
161
272
  if not isinstance(active, dict):
162
273
  return False
163
274
  if int(active.get("number", -1)) != int(issue_number):
@@ -234,58 +345,157 @@ def maybe_mark_blocked(cfg: RepoConfig, issue: dict[str, Any]) -> None:
234
345
  )
235
346
 
236
347
 
237
- def main() -> int:
238
- ap = argparse.ArgumentParser()
239
- ap.add_argument("--root", default="/mnt/f/Source")
240
- ap.add_argument("--max-parallel", type=int, default=2)
241
- ap.add_argument("--dry-run", action="store_true")
242
- args = ap.parse_args()
348
+ def run_cycle(
349
+ all_configs: list[RepoConfig],
350
+ dry_run: bool = False,
351
+ ) -> int:
352
+ """Run a single scan/claim/act cycle.
243
353
 
244
- root = Path(args.root)
245
- configs = discover_repos(root)
246
- if not configs:
247
- print("No autopilot-enabled repos found.")
248
- return 0
354
+ Returns the number of issues claimed.
355
+ """
356
+ claimed_count = 0
249
357
 
250
- for cfg in configs:
358
+ for cfg in all_configs:
251
359
  # 1) First, check any in-progress issues and mark blocked if stale.
252
360
  inprog = list_in_progress_issues(cfg)
253
361
  for it in inprog:
254
362
  # This is conservative: only mark blocked if we have no fresh local heartbeat.
255
- if not args.dry_run:
363
+ if not dry_run:
256
364
  maybe_mark_blocked(cfg, it)
257
365
 
258
- # 2) If nothing is currently in progress, claim one from the queue.
259
- # (We only support one active issue per repo in this MVP.)
260
- 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:
261
380
  continue
262
381
 
382
+ # 4) Claim new issues from the queue (respecting priority labels)
263
383
  issues = list_candidate_issues(cfg)
264
384
  if not issues:
265
385
  continue
266
386
 
267
- issue = issues[0]
268
- msg = (
269
- f"Autopilot claimed this issue at {time.strftime('%Y-%m-%d %H:%M:%S %Z')}.\n\n"
270
- "Next: implement fix and open PR.\n\n"
271
- "(Durable tracking: this repo will maintain `.autopilot/state.json` as a heartbeat; "
272
- "the runner does not inspect processes.)"
273
- )
274
- print(f"[{cfg.repo}] next issue: #{issue['number']} {issue['title']}")
275
- if args.dry_run:
276
- continue
277
- claim_issue(cfg, issue, msg)
278
-
279
- # If agent==opencode, delegate to bash script
280
- if cfg.agent == "opencode":
281
- sh(
282
- [
283
- "/home/kellye/clawd/repos/autopilot/scripts/run_opencode_issue.sh",
284
- str(cfg.root),
285
- str(issue["number"]),
286
- ]
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.)"
287
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
+ )
410
+
411
+ return claimed_count
412
+
413
+
414
+ def main() -> int:
415
+ ap = argparse.ArgumentParser()
416
+ ap.add_argument(
417
+ "--root",
418
+ nargs="+",
419
+ default=["/mnt/f/Source"],
420
+ help="One or more root folders to scan for git repos (space-separated)",
421
+ )
422
+ ap.add_argument("--max-parallel", type=int, default=2)
423
+ ap.add_argument(
424
+ "--max-depth",
425
+ type=int,
426
+ default=DEFAULT_MAX_DEPTH,
427
+ help=f"Maximum depth for recursive repo discovery (default: {DEFAULT_MAX_DEPTH})",
428
+ )
429
+ ap.add_argument("--dry-run", action="store_true")
430
+ ap.add_argument(
431
+ "--interval-seconds",
432
+ type=int,
433
+ default=None,
434
+ help="Run in loop mode with this interval between cycles (for foreground service mode)",
435
+ )
436
+ args = ap.parse_args()
437
+
438
+ all_configs: list[RepoConfig] = []
439
+ for root_str in args.root:
440
+ root = Path(root_str).resolve()
441
+ if not root.exists():
442
+ print(f"Warning: root path {root} does not exist, skipping")
443
+ continue
444
+ if not root.is_dir():
445
+ print(f"Warning: root path {root} is not a directory, skipping")
446
+ continue
447
+ configs = discover_repos(root, max_depth=args.max_depth)
448
+ all_configs.extend(configs)
449
+
450
+ if not all_configs:
451
+ print("No autopilot-enabled repos found.")
452
+ return 0
453
+
454
+ print(f"Found {len(all_configs)} autopilot-enabled repo(s)")
455
+ for cfg in all_configs:
456
+ print(f" - {cfg.repo} at {cfg.root}")
457
+
458
+ # Loop mode (for foreground service)
459
+ if args.interval_seconds is not None:
460
+ shutdown_requested = False
461
+
462
+ def signal_handler(signum: int, frame: Any) -> None:
463
+ nonlocal shutdown_requested
464
+ print("\nShutdown requested (Ctrl+C), finishing current cycle...")
465
+ shutdown_requested = True
466
+
467
+ signal.signal(signal.SIGINT, signal_handler)
468
+ signal.signal(signal.SIGTERM, signal_handler)
469
+
470
+ print(f"Running in foreground mode with {args.interval_seconds}s interval")
471
+ print("Press Ctrl+C to shut down cleanly\n")
472
+
473
+ cycle_num = 0
474
+ while not shutdown_requested:
475
+ cycle_num += 1
476
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S %Z")
477
+ print(f"\n=== Cycle {cycle_num} at {timestamp} ===")
478
+
479
+ try:
480
+ claimed = run_cycle(all_configs, dry_run=args.dry_run)
481
+ if claimed == 0:
482
+ print("No issues claimed in this cycle")
483
+ else:
484
+ print(f"Claimed {claimed} issue(s) in this cycle")
485
+ except Exception as e:
486
+ print(f"Error in cycle {cycle_num}: {e}")
487
+
488
+ if shutdown_requested:
489
+ break
490
+
491
+ print(f"Sleeping for {args.interval_seconds}s...")
492
+ time.sleep(args.interval_seconds)
493
+
494
+ print("\nShutdown complete")
495
+ return 0
288
496
 
497
+ # Single-shot mode (run-once)
498
+ run_cycle(all_configs, dry_run=args.dry_run)
289
499
  return 0
290
500
 
291
501
 
@@ -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",
@@ -9,6 +11,11 @@
9
11
  },
10
12
  "priorityLabels": ["p0", "p1", "p2"],
11
13
  "maxParallel": 2,
14
+ "ignoreIssueLabels": ["autopilot:backlog"],
12
15
  "branchPrefix": "autopilot/",
13
- "allowedBaseBranch": "main"
16
+ "allowedBaseBranch": "main",
17
+ "autoResolveConflicts": true,
18
+ "conflictResolutionMaxAttempts": 3,
19
+ "autoFixChecks": true,
20
+ "autoFixChecksMaxAttempts": 3
14
21
  }