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.
- package/.autopilot/autopilot.json +18 -3
- package/README.md +17 -6
- package/dist/cli.js +360 -7
- package/package.json +1 -1
- package/scripts/run_autopilot.py +260 -50
- package/scripts/run_opencode_issue.sh +292 -1
- package/templates/autopilot.json +8 -1
|
@@ -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": [
|
|
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": [
|
|
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(
|
|
34
|
+
process.exit(1);
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
|
-
|
|
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(
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
package/scripts/run_autopilot.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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
|
|
363
|
+
if not dry_run:
|
|
256
364
|
maybe_mark_blocked(cfg, it)
|
|
257
365
|
|
|
258
|
-
# 2)
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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"
|
package/templates/autopilot.json
CHANGED
|
@@ -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
|
}
|