autopilot-code 0.0.4 ā 0.0.5
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/README.md +10 -0
- package/dist/cli.js +184 -7
- package/package.json +1 -1
- package/scripts/run_autopilot.py +181 -26
- package/templates/autopilot.json +1 -0
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ Example:
|
|
|
39
39
|
Notes:
|
|
40
40
|
- `repo` must be the GitHub `owner/name`.
|
|
41
41
|
- `agent` (optional, default `"none"`): set to `"opencode"` to enable OpenCode integration after claiming issues.
|
|
42
|
+
- `ignoreIssueLabels` (optional, default `["autopilot:backlog"]`): issues with any of these labels will be ignored by the runner.
|
|
42
43
|
- `heartbeatMaxAgeSecs` controls how long an in-progress issue can go without a heartbeat before it's considered stale.
|
|
43
44
|
|
|
44
45
|
## Workflow (labels)
|
|
@@ -86,7 +87,11 @@ On each loop:
|
|
|
86
87
|
## Running locally
|
|
87
88
|
### Python runner
|
|
88
89
|
```bash
|
|
90
|
+
# Run a single scan/claim/act cycle
|
|
89
91
|
python3 scripts/run_autopilot.py --root /mnt/f/Source
|
|
92
|
+
|
|
93
|
+
# Run in foreground loop mode (dev-friendly)
|
|
94
|
+
python3 scripts/run_autopilot.py --root /mnt/f/Source --interval-seconds 60
|
|
90
95
|
```
|
|
91
96
|
|
|
92
97
|
### Node CLI wrapper
|
|
@@ -104,8 +109,13 @@ node dist/cli.js scan --root /mnt/f/Source
|
|
|
104
109
|
|
|
105
110
|
# claim exactly one issue + comment
|
|
106
111
|
node dist/cli.js run-once --root /mnt/f/Source
|
|
112
|
+
|
|
113
|
+
# run service in foreground mode (dev-friendly)
|
|
114
|
+
node dist/cli.js service --foreground --interval-seconds 60 --root /mnt/f/Source
|
|
107
115
|
```
|
|
108
116
|
|
|
117
|
+
The foreground service mode runs continuously with the specified interval and logs to stdout. Press Ctrl+C to shut down cleanly.
|
|
118
|
+
|
|
109
119
|
## Roadmap
|
|
110
120
|
- Spawn a coding agent (Claude Code / OpenCode) in a worktree per issue
|
|
111
121
|
- Create PRs linked to issues; wait for checks to go green
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,14 @@ 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");
|
|
10
17
|
function run(cmd, args) {
|
|
11
18
|
const res = (0, node_child_process_1.spawnSync)(cmd, args, { stdio: "inherit" });
|
|
12
19
|
if (res.error)
|
|
@@ -22,16 +29,126 @@ function check(cmd, args, label) {
|
|
|
22
29
|
}
|
|
23
30
|
if (typeof res.status === "number" && res.status !== 0) {
|
|
24
31
|
console.error(`\n${label}: returned exit code ${res.status}`);
|
|
25
|
-
process.exit(
|
|
32
|
+
process.exit(1);
|
|
26
33
|
}
|
|
27
34
|
}
|
|
28
|
-
|
|
35
|
+
function loadGlobalConfig() {
|
|
36
|
+
if (!(0, node_fs_1.existsSync)(GLOBAL_CONFIG_FILE)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const data = (0, node_fs_1.readFileSync)(GLOBAL_CONFIG_FILE, "utf8");
|
|
41
|
+
return JSON.parse(data);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function saveGlobalConfig(config) {
|
|
48
|
+
(0, node_fs_1.mkdirSync)(GLOBAL_CONFIG_DIR, { recursive: true });
|
|
49
|
+
(0, node_fs_1.writeFileSync)(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
50
|
+
}
|
|
51
|
+
function isGitRepo(dirPath) {
|
|
52
|
+
return (0, node_fs_1.existsSync)(node_path_1.default.join(dirPath, ".git"));
|
|
53
|
+
}
|
|
54
|
+
function prompt(question) {
|
|
55
|
+
const rl = (0, node_readline_1.createInterface)({
|
|
56
|
+
input: process.stdin,
|
|
57
|
+
output: process.stdout,
|
|
58
|
+
});
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
rl.question(question, (answer) => {
|
|
61
|
+
rl.close();
|
|
62
|
+
resolve(answer.trim());
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function validatePath(pathStr) {
|
|
67
|
+
const resolvedPath = node_path_1.default.resolve(pathStr);
|
|
68
|
+
if (!(0, node_fs_1.existsSync)(resolvedPath)) {
|
|
69
|
+
return { valid: false, error: `Path does not exist: ${pathStr}` };
|
|
70
|
+
}
|
|
71
|
+
if (!isGitRepo(resolvedPath)) {
|
|
72
|
+
return { valid: true, isGit: false };
|
|
73
|
+
}
|
|
74
|
+
return { valid: true, isGit: true };
|
|
75
|
+
}
|
|
76
|
+
async function collectSourceFolders() {
|
|
77
|
+
const folders = [];
|
|
78
|
+
console.log("\nš Source Folder Selection");
|
|
79
|
+
console.log("Enter one or more directories containing git repos to scan.");
|
|
80
|
+
console.log("Leave empty when done.\n");
|
|
81
|
+
while (true) {
|
|
82
|
+
const input = await prompt(`Source folder #${folders.length + 1} (or press Enter to finish): `);
|
|
83
|
+
if (input === "") {
|
|
84
|
+
if (folders.length === 0) {
|
|
85
|
+
console.log("At least one source folder is required.");
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
const validation = validatePath(input);
|
|
91
|
+
if (!validation.valid) {
|
|
92
|
+
console.error(`ā ${validation.error}`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const resolvedPath = node_path_1.default.resolve(input);
|
|
96
|
+
if (folders.includes(resolvedPath)) {
|
|
97
|
+
console.log("ā ļø This folder is already in the list.");
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (validation.isGit) {
|
|
101
|
+
console.log(`ā
Added git repo: ${resolvedPath}`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
console.log(`ā
Added folder: ${resolvedPath} (will scan for git repos recursively)`);
|
|
105
|
+
}
|
|
106
|
+
folders.push(resolvedPath);
|
|
107
|
+
}
|
|
108
|
+
return folders;
|
|
109
|
+
}
|
|
110
|
+
async function initCommand() {
|
|
111
|
+
console.log("š Autopilot Setup\n");
|
|
112
|
+
const existingConfig = loadGlobalConfig();
|
|
113
|
+
if (existingConfig && existingConfig.sourceFolders.length > 0) {
|
|
114
|
+
console.log("Existing configuration found:");
|
|
115
|
+
console.log("Current source folders:");
|
|
116
|
+
existingConfig.sourceFolders.forEach((folder, idx) => {
|
|
117
|
+
const isGit = isGitRepo(folder);
|
|
118
|
+
console.log(` ${idx + 1}. ${folder}${isGit ? " (git repo)" : ""}`);
|
|
119
|
+
});
|
|
120
|
+
console.log();
|
|
121
|
+
const overwrite = await prompt("Do you want to replace this configuration? (y/N): ");
|
|
122
|
+
if (overwrite.toLowerCase() !== "y") {
|
|
123
|
+
console.log("Configuration unchanged.");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const sourceFolders = await collectSourceFolders();
|
|
128
|
+
const config = {
|
|
129
|
+
sourceFolders,
|
|
130
|
+
};
|
|
131
|
+
saveGlobalConfig(config);
|
|
132
|
+
console.log("\nā
Configuration saved to:", GLOBAL_CONFIG_FILE);
|
|
133
|
+
console.log("\nYou can now run:");
|
|
134
|
+
console.log(" autopilot scan # Scan repos without claiming issues");
|
|
135
|
+
console.log(" autopilot run-once # Claim and work on one issue");
|
|
136
|
+
}
|
|
29
137
|
const runnerPath = node_path_1.default.join(repoRoot, "scripts", "run_autopilot.py");
|
|
30
138
|
const program = new commander_1.Command();
|
|
31
139
|
program
|
|
32
140
|
.name("autopilot")
|
|
33
141
|
.description("Repo-issueādriven autopilot runner (CLI wrapper)")
|
|
34
|
-
.version(
|
|
142
|
+
.version(version);
|
|
143
|
+
program
|
|
144
|
+
.command("init")
|
|
145
|
+
.description("Initialize autopilot configuration (select source folders)")
|
|
146
|
+
.action(() => {
|
|
147
|
+
initCommand().catch((err) => {
|
|
148
|
+
console.error("Error:", err);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
35
152
|
program
|
|
36
153
|
.command("doctor")
|
|
37
154
|
.description("Check local prerequisites (gh auth, python3)")
|
|
@@ -44,15 +161,75 @@ program
|
|
|
44
161
|
program
|
|
45
162
|
.command("scan")
|
|
46
163
|
.description("Discover autopilot-enabled repos + show next issue candidate (dry-run)")
|
|
47
|
-
.
|
|
164
|
+
.option("--root <paths...>", "Root folder(s) that contain git repos (space-separated)")
|
|
165
|
+
.option("--max-depth <number>", "Maximum depth for recursive repo discovery", "5")
|
|
48
166
|
.action((opts) => {
|
|
49
|
-
|
|
167
|
+
let rootPaths = opts.root;
|
|
168
|
+
if (!rootPaths || rootPaths.length === 0) {
|
|
169
|
+
const config = loadGlobalConfig();
|
|
170
|
+
if (!config || config.sourceFolders.length === 0) {
|
|
171
|
+
console.error("No source folders configured. Run 'autopilot init' first.");
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
rootPaths = config.sourceFolders;
|
|
175
|
+
}
|
|
176
|
+
const args = [runnerPath, "--root", ...rootPaths];
|
|
177
|
+
if (opts.maxDepth)
|
|
178
|
+
args.push("--max-depth", opts.maxDepth);
|
|
179
|
+
args.push("--dry-run");
|
|
180
|
+
run("python3", args);
|
|
50
181
|
});
|
|
51
182
|
program
|
|
52
183
|
.command("run-once")
|
|
53
184
|
.description("Claim exactly one issue and post a progress comment")
|
|
54
|
-
.
|
|
185
|
+
.option("--root <paths...>", "Root folder(s) that contain git repos (space-separated)")
|
|
186
|
+
.option("--max-depth <number>", "Maximum depth for recursive repo discovery", "5")
|
|
55
187
|
.action((opts) => {
|
|
56
|
-
|
|
188
|
+
let rootPaths = opts.root;
|
|
189
|
+
if (!rootPaths || rootPaths.length === 0) {
|
|
190
|
+
const config = loadGlobalConfig();
|
|
191
|
+
if (!config || config.sourceFolders.length === 0) {
|
|
192
|
+
console.error("No source folders configured. Run 'autopilot init' first.");
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
rootPaths = config.sourceFolders;
|
|
196
|
+
}
|
|
197
|
+
const args = [runnerPath, "--root", ...rootPaths];
|
|
198
|
+
if (opts.maxDepth)
|
|
199
|
+
args.push("--max-depth", opts.maxDepth);
|
|
200
|
+
run("python3", args);
|
|
201
|
+
});
|
|
202
|
+
program
|
|
203
|
+
.command("service")
|
|
204
|
+
.description("Run the autopilot service (foreground mode with loop)")
|
|
205
|
+
.option("--foreground", "Run in foreground mode (log to stdout)")
|
|
206
|
+
.option("--interval-seconds <number>", "Interval between cycles in seconds", "60")
|
|
207
|
+
.option("--root <paths...>", "Root folder(s) that contain git repos (space-separated)")
|
|
208
|
+
.option("--max-depth <number>", "Maximum depth for recursive repo discovery", "5")
|
|
209
|
+
.action((opts) => {
|
|
210
|
+
let rootPaths = opts.root;
|
|
211
|
+
if (!rootPaths || rootPaths.length === 0) {
|
|
212
|
+
const config = loadGlobalConfig();
|
|
213
|
+
if (!config || config.sourceFolders.length === 0) {
|
|
214
|
+
console.error("No source folders configured. Run 'autopilot init' first.");
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
rootPaths = config.sourceFolders;
|
|
218
|
+
}
|
|
219
|
+
const args = [runnerPath, "--root", ...rootPaths];
|
|
220
|
+
if (opts.maxDepth)
|
|
221
|
+
args.push("--max-depth", opts.maxDepth);
|
|
222
|
+
if (opts.foreground) {
|
|
223
|
+
const interval = opts.intervalSeconds || "60";
|
|
224
|
+
args.push("--interval-seconds", interval);
|
|
225
|
+
console.log(`Starting autopilot service in foreground mode (interval: ${interval}s)`);
|
|
226
|
+
console.log("Press Ctrl+C to shut down cleanly\n");
|
|
227
|
+
run("python3", args);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
console.error("Only --foreground mode is supported currently.");
|
|
231
|
+
console.error("For background service mode, use systemd integration (coming soon).");
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
57
234
|
});
|
|
58
235
|
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,7 @@ class RepoConfig:
|
|
|
49
75
|
max_parallel: int
|
|
50
76
|
heartbeat_max_age_secs: int
|
|
51
77
|
agent: str
|
|
78
|
+
ignore_issue_labels: list[str]
|
|
52
79
|
|
|
53
80
|
|
|
54
81
|
def load_config(repo_root: Path) -> RepoConfig | None:
|
|
@@ -72,26 +99,60 @@ def load_config(repo_root: Path) -> RepoConfig | None:
|
|
|
72
99
|
label_done=labels.get("done", "autopilot:done"),
|
|
73
100
|
priority_labels=data.get("priorityLabels", ["p0", "p1", "p2"]),
|
|
74
101
|
max_parallel=int(data.get("maxParallel", 2)),
|
|
75
|
-
heartbeat_max_age_secs=int(
|
|
102
|
+
heartbeat_max_age_secs=int(
|
|
103
|
+
data.get("heartbeatMaxAgeSecs", DEFAULT_HEARTBEAT_MAX_AGE_SECS)
|
|
104
|
+
),
|
|
76
105
|
agent=data.get("agent", "none"),
|
|
106
|
+
ignore_issue_labels=list(data.get("ignoreIssueLabels", ["autopilot:backlog"])),
|
|
77
107
|
)
|
|
78
108
|
|
|
79
109
|
|
|
80
|
-
def discover_repos(root: Path) -> list[RepoConfig]:
|
|
110
|
+
def discover_repos(root: Path, max_depth: int = DEFAULT_MAX_DEPTH) -> list[RepoConfig]:
|
|
81
111
|
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)
|
|
112
|
+
_discover_repos_recursive(root, 0, max_depth, out)
|
|
89
113
|
return out
|
|
90
114
|
|
|
91
115
|
|
|
116
|
+
def _discover_repos_recursive(
|
|
117
|
+
current_dir: Path, current_depth: int, max_depth: int, out: list[RepoConfig]
|
|
118
|
+
) -> None:
|
|
119
|
+
if current_depth > max_depth:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Check if current directory is a git repo with autopilot config
|
|
123
|
+
if (current_dir / ".git").exists() and (
|
|
124
|
+
current_dir / ".autopilot" / "autopilot.json"
|
|
125
|
+
).exists():
|
|
126
|
+
cfg = load_config(current_dir)
|
|
127
|
+
if cfg:
|
|
128
|
+
out.append(cfg)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Recursively search subdirectories (depth-first)
|
|
132
|
+
try:
|
|
133
|
+
for child in sorted(current_dir.iterdir(), key=lambda p: p.name.lower()):
|
|
134
|
+
if not child.is_dir():
|
|
135
|
+
continue
|
|
136
|
+
if child.name in IGNORED_DIRS:
|
|
137
|
+
continue
|
|
138
|
+
_discover_repos_recursive(child, current_depth + 1, max_depth, out)
|
|
139
|
+
except PermissionError:
|
|
140
|
+
pass
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
|
|
92
145
|
def list_candidate_issues(cfg: RepoConfig, limit: int = 10) -> list[dict[str, Any]]:
|
|
146
|
+
"""List candidate issues in the queue.
|
|
147
|
+
|
|
148
|
+
We exclude issues containing cfg.ignore_issue_labels (e.g. autopilot:backlog).
|
|
149
|
+
"""
|
|
150
|
+
|
|
93
151
|
# Sort by created date asc; prioritize p0/p1/p2 by label presence.
|
|
94
152
|
q = " ".join([f"label:{l}" for l in cfg.queue_labels])
|
|
153
|
+
excludes = " ".join([f"-label:{l}" for l in cfg.ignore_issue_labels if l])
|
|
154
|
+
search = f"is:open {q} {excludes}".strip()
|
|
155
|
+
|
|
95
156
|
cmd = [
|
|
96
157
|
"gh",
|
|
97
158
|
"issue",
|
|
@@ -101,13 +162,21 @@ def list_candidate_issues(cfg: RepoConfig, limit: int = 10) -> list[dict[str, An
|
|
|
101
162
|
"--limit",
|
|
102
163
|
str(limit),
|
|
103
164
|
"--search",
|
|
104
|
-
|
|
165
|
+
search,
|
|
105
166
|
"--json",
|
|
106
167
|
"number,title,labels,updatedAt,createdAt,url",
|
|
107
168
|
]
|
|
108
169
|
raw = sh(cmd)
|
|
109
170
|
issues = json.loads(raw)
|
|
110
171
|
|
|
172
|
+
# Defense in depth in case GitHub search syntax changes.
|
|
173
|
+
ignore_set = set(cfg.ignore_issue_labels)
|
|
174
|
+
issues = [
|
|
175
|
+
it
|
|
176
|
+
for it in issues
|
|
177
|
+
if not ({l["name"] for l in it.get("labels", [])} & ignore_set)
|
|
178
|
+
]
|
|
179
|
+
|
|
111
180
|
def pri_rank(it: dict[str, Any]) -> int:
|
|
112
181
|
labs = {l["name"] for l in it.get("labels", [])}
|
|
113
182
|
for i, p in enumerate(cfg.priority_labels):
|
|
@@ -234,25 +303,22 @@ def maybe_mark_blocked(cfg: RepoConfig, issue: dict[str, Any]) -> None:
|
|
|
234
303
|
)
|
|
235
304
|
|
|
236
305
|
|
|
237
|
-
def
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
args = ap.parse_args()
|
|
306
|
+
def run_cycle(
|
|
307
|
+
all_configs: list[RepoConfig],
|
|
308
|
+
dry_run: bool = False,
|
|
309
|
+
) -> int:
|
|
310
|
+
"""Run a single scan/claim/act cycle.
|
|
243
311
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
print("No autopilot-enabled repos found.")
|
|
248
|
-
return 0
|
|
312
|
+
Returns the number of issues claimed.
|
|
313
|
+
"""
|
|
314
|
+
claimed_count = 0
|
|
249
315
|
|
|
250
|
-
for cfg in
|
|
316
|
+
for cfg in all_configs:
|
|
251
317
|
# 1) First, check any in-progress issues and mark blocked if stale.
|
|
252
318
|
inprog = list_in_progress_issues(cfg)
|
|
253
319
|
for it in inprog:
|
|
254
320
|
# This is conservative: only mark blocked if we have no fresh local heartbeat.
|
|
255
|
-
if not
|
|
321
|
+
if not dry_run:
|
|
256
322
|
maybe_mark_blocked(cfg, it)
|
|
257
323
|
|
|
258
324
|
# 2) If nothing is currently in progress, claim one from the queue.
|
|
@@ -272,9 +338,10 @@ def main() -> int:
|
|
|
272
338
|
"the runner does not inspect processes.)"
|
|
273
339
|
)
|
|
274
340
|
print(f"[{cfg.repo}] next issue: #{issue['number']} {issue['title']}")
|
|
275
|
-
if
|
|
341
|
+
if dry_run:
|
|
276
342
|
continue
|
|
277
343
|
claim_issue(cfg, issue, msg)
|
|
344
|
+
claimed_count += 1
|
|
278
345
|
|
|
279
346
|
# If agent==opencode, delegate to bash script
|
|
280
347
|
if cfg.agent == "opencode":
|
|
@@ -286,6 +353,94 @@ def main() -> int:
|
|
|
286
353
|
]
|
|
287
354
|
)
|
|
288
355
|
|
|
356
|
+
return claimed_count
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def main() -> int:
|
|
360
|
+
ap = argparse.ArgumentParser()
|
|
361
|
+
ap.add_argument(
|
|
362
|
+
"--root",
|
|
363
|
+
nargs="+",
|
|
364
|
+
default=["/mnt/f/Source"],
|
|
365
|
+
help="One or more root folders to scan for git repos (space-separated)",
|
|
366
|
+
)
|
|
367
|
+
ap.add_argument("--max-parallel", type=int, default=2)
|
|
368
|
+
ap.add_argument(
|
|
369
|
+
"--max-depth",
|
|
370
|
+
type=int,
|
|
371
|
+
default=DEFAULT_MAX_DEPTH,
|
|
372
|
+
help=f"Maximum depth for recursive repo discovery (default: {DEFAULT_MAX_DEPTH})",
|
|
373
|
+
)
|
|
374
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
375
|
+
ap.add_argument(
|
|
376
|
+
"--interval-seconds",
|
|
377
|
+
type=int,
|
|
378
|
+
default=None,
|
|
379
|
+
help="Run in loop mode with this interval between cycles (for foreground service mode)",
|
|
380
|
+
)
|
|
381
|
+
args = ap.parse_args()
|
|
382
|
+
|
|
383
|
+
all_configs: list[RepoConfig] = []
|
|
384
|
+
for root_str in args.root:
|
|
385
|
+
root = Path(root_str).resolve()
|
|
386
|
+
if not root.exists():
|
|
387
|
+
print(f"Warning: root path {root} does not exist, skipping")
|
|
388
|
+
continue
|
|
389
|
+
if not root.is_dir():
|
|
390
|
+
print(f"Warning: root path {root} is not a directory, skipping")
|
|
391
|
+
continue
|
|
392
|
+
configs = discover_repos(root, max_depth=args.max_depth)
|
|
393
|
+
all_configs.extend(configs)
|
|
394
|
+
|
|
395
|
+
if not all_configs:
|
|
396
|
+
print("No autopilot-enabled repos found.")
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
print(f"Found {len(all_configs)} autopilot-enabled repo(s)")
|
|
400
|
+
for cfg in all_configs:
|
|
401
|
+
print(f" - {cfg.repo} at {cfg.root}")
|
|
402
|
+
|
|
403
|
+
# Loop mode (for foreground service)
|
|
404
|
+
if args.interval_seconds is not None:
|
|
405
|
+
shutdown_requested = False
|
|
406
|
+
|
|
407
|
+
def signal_handler(signum: int, frame: Any) -> None:
|
|
408
|
+
nonlocal shutdown_requested
|
|
409
|
+
print("\nShutdown requested (Ctrl+C), finishing current cycle...")
|
|
410
|
+
shutdown_requested = True
|
|
411
|
+
|
|
412
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
413
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
414
|
+
|
|
415
|
+
print(f"Running in foreground mode with {args.interval_seconds}s interval")
|
|
416
|
+
print("Press Ctrl+C to shut down cleanly\n")
|
|
417
|
+
|
|
418
|
+
cycle_num = 0
|
|
419
|
+
while not shutdown_requested:
|
|
420
|
+
cycle_num += 1
|
|
421
|
+
timestamp = time.strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
422
|
+
print(f"\n=== Cycle {cycle_num} at {timestamp} ===")
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
claimed = run_cycle(all_configs, dry_run=args.dry_run)
|
|
426
|
+
if claimed == 0:
|
|
427
|
+
print("No issues claimed in this cycle")
|
|
428
|
+
else:
|
|
429
|
+
print(f"Claimed {claimed} issue(s) in this cycle")
|
|
430
|
+
except Exception as e:
|
|
431
|
+
print(f"Error in cycle {cycle_num}: {e}")
|
|
432
|
+
|
|
433
|
+
if shutdown_requested:
|
|
434
|
+
break
|
|
435
|
+
|
|
436
|
+
print(f"Sleeping for {args.interval_seconds}s...")
|
|
437
|
+
time.sleep(args.interval_seconds)
|
|
438
|
+
|
|
439
|
+
print("\nShutdown complete")
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
# Single-shot mode (run-once)
|
|
443
|
+
run_cycle(all_configs, dry_run=args.dry_run)
|
|
289
444
|
return 0
|
|
290
445
|
|
|
291
446
|
|