opencode-pilot 0.1.0
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/.devcontainer/devcontainer.json +16 -0
- package/.github/workflows/ci.yml +67 -0
- package/.releaserc.cjs +28 -0
- package/AGENTS.md +71 -0
- package/CONTRIBUTING.md +102 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/opencode-pilot +809 -0
- package/dist/opencode-ntfy.tar.gz +0 -0
- package/examples/config.yaml +73 -0
- package/examples/templates/default.md +7 -0
- package/examples/templates/devcontainer.md +7 -0
- package/examples/templates/review-feedback.md +7 -0
- package/examples/templates/review.md +15 -0
- package/install.sh +246 -0
- package/package.json +40 -0
- package/plugin/config.js +76 -0
- package/plugin/index.js +260 -0
- package/plugin/logger.js +125 -0
- package/plugin/notifier.js +110 -0
- package/service/actions.js +334 -0
- package/service/io.opencode.ntfy.plist +29 -0
- package/service/logger.js +82 -0
- package/service/poll-service.js +246 -0
- package/service/poller.js +339 -0
- package/service/readiness.js +234 -0
- package/service/repo-config.js +222 -0
- package/service/server.js +1523 -0
- package/service/utils.js +21 -0
- package/test/run_tests.bash +34 -0
- package/test/test_actions.bash +263 -0
- package/test/test_cli.bash +161 -0
- package/test/test_config.bash +438 -0
- package/test/test_helper.bash +140 -0
- package/test/test_logger.bash +401 -0
- package/test/test_notifier.bash +310 -0
- package/test/test_plist.bash +125 -0
- package/test/test_plugin.bash +952 -0
- package/test/test_poll_service.bash +179 -0
- package/test/test_poller.bash +120 -0
- package/test/test_readiness.bash +313 -0
- package/test/test_repo_config.bash +406 -0
- package/test/test_service.bash +1342 -0
- package/test/unit/actions.test.js +235 -0
- package/test/unit/config.test.js +86 -0
- package/test/unit/paths.test.js +77 -0
- package/test/unit/poll-service.test.js +142 -0
- package/test/unit/poller.test.js +347 -0
- package/test/unit/repo-config.test.js +441 -0
- package/test/unit/utils.test.js +53 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* actions.js - Action system for starting sessions
|
|
3
|
+
*
|
|
4
|
+
* Starts OpenCode sessions with configurable prompts.
|
|
5
|
+
* Supports prompt_template for custom prompts (e.g., to invoke /devcontainer).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
import { readFileSync, existsSync } from "fs";
|
|
10
|
+
import { debug } from "./logger.js";
|
|
11
|
+
import { getNestedValue } from "./utils.js";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import os from "os";
|
|
14
|
+
|
|
15
|
+
// Default templates directory
|
|
16
|
+
const DEFAULT_TEMPLATES_DIR = path.join(
|
|
17
|
+
os.homedir(),
|
|
18
|
+
".config/opencode-pilot/templates"
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Expand ~ to home directory
|
|
23
|
+
*/
|
|
24
|
+
function expandPath(p) {
|
|
25
|
+
if (p.startsWith("~")) {
|
|
26
|
+
return path.join(os.homedir(), p.slice(1));
|
|
27
|
+
}
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Expand a template string with item fields
|
|
33
|
+
* Supports both simple ({field}) and nested ({field.subfield}) references
|
|
34
|
+
* @param {string} template - Template with {placeholders}
|
|
35
|
+
* @param {object} item - Item with fields to substitute
|
|
36
|
+
* @returns {string} Expanded string
|
|
37
|
+
*/
|
|
38
|
+
export function expandTemplate(template, item) {
|
|
39
|
+
return template.replace(/\{([\w.]+)\}/g, (match, key) => {
|
|
40
|
+
const value = getNestedValue(item, key);
|
|
41
|
+
return value !== undefined ? String(value) : match;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build session name from template
|
|
47
|
+
* @param {string} template - Template with {placeholders}
|
|
48
|
+
* @param {object} item - Item with fields to substitute
|
|
49
|
+
* @returns {string} Expanded session name
|
|
50
|
+
*/
|
|
51
|
+
export function buildSessionName(template, item) {
|
|
52
|
+
return expandTemplate(template, item);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load a template file and expand it with item fields
|
|
57
|
+
* @param {string} templateName - Template name (without .md extension)
|
|
58
|
+
* @param {object} item - Item with fields to substitute
|
|
59
|
+
* @param {string} [templatesDir] - Templates directory path (for testing)
|
|
60
|
+
* @returns {string} Expanded template, or fallback to title+body
|
|
61
|
+
*/
|
|
62
|
+
export function buildPromptFromTemplate(templateName, item, templatesDir) {
|
|
63
|
+
const dir = templatesDir || DEFAULT_TEMPLATES_DIR;
|
|
64
|
+
const templatePath = path.join(dir, `${templateName}.md`);
|
|
65
|
+
|
|
66
|
+
let template;
|
|
67
|
+
if (existsSync(templatePath)) {
|
|
68
|
+
template = readFileSync(templatePath, "utf-8");
|
|
69
|
+
} else {
|
|
70
|
+
// Fallback: combine title and body
|
|
71
|
+
const parts = [];
|
|
72
|
+
if (item.title) parts.push(item.title);
|
|
73
|
+
if (item.body) parts.push(item.body);
|
|
74
|
+
return parts.join("\n\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return expandTemplate(template, item);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Merge source, repo config, and defaults into action config
|
|
82
|
+
* Priority: source > repo > defaults
|
|
83
|
+
* @param {object} source - Source configuration
|
|
84
|
+
* @param {object} repoConfig - Repository configuration
|
|
85
|
+
* @param {object} defaults - Default configuration
|
|
86
|
+
* @returns {object} Merged action config
|
|
87
|
+
*/
|
|
88
|
+
export function getActionConfig(source, repoConfig, defaults) {
|
|
89
|
+
return {
|
|
90
|
+
// Defaults first
|
|
91
|
+
...defaults,
|
|
92
|
+
// Repo config overrides defaults
|
|
93
|
+
...repoConfig,
|
|
94
|
+
// Preserve nested session config
|
|
95
|
+
session: {
|
|
96
|
+
...(defaults.session || {}),
|
|
97
|
+
...(repoConfig.session || {}),
|
|
98
|
+
},
|
|
99
|
+
// Source-level overrides (highest priority)
|
|
100
|
+
...(source.prompt && { prompt: source.prompt }),
|
|
101
|
+
...(source.agent && { agent: source.agent }),
|
|
102
|
+
...(source.model && { model: source.model }),
|
|
103
|
+
...(source.working_dir && { working_dir: source.working_dir }),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get command info using new config format
|
|
109
|
+
* @param {object} item - Item to create session for
|
|
110
|
+
* @param {object} config - Merged action config
|
|
111
|
+
* @param {string} [templatesDir] - Templates directory path (for testing)
|
|
112
|
+
* @returns {object} { args: string[], cwd: string }
|
|
113
|
+
*/
|
|
114
|
+
export function getCommandInfoNew(item, config, templatesDir) {
|
|
115
|
+
// Determine working directory: working_dir > path > repo_path > home
|
|
116
|
+
const workingDir = config.working_dir || config.path || config.repo_path || "~";
|
|
117
|
+
const cwd = expandPath(workingDir);
|
|
118
|
+
|
|
119
|
+
// Build session name
|
|
120
|
+
const sessionName = config.session?.name
|
|
121
|
+
? buildSessionName(config.session.name, item)
|
|
122
|
+
: `session-${item.number || item.id || Date.now()}`;
|
|
123
|
+
|
|
124
|
+
// Build command args
|
|
125
|
+
const args = ["opencode", "run"];
|
|
126
|
+
|
|
127
|
+
// Add session title
|
|
128
|
+
args.push("--title", sessionName);
|
|
129
|
+
|
|
130
|
+
// Add agent if specified
|
|
131
|
+
if (config.agent) {
|
|
132
|
+
args.push("--agent", config.agent);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Add model if specified
|
|
136
|
+
if (config.model) {
|
|
137
|
+
args.push("--model", config.model);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Build prompt from template
|
|
141
|
+
const prompt = buildPromptFromTemplate(config.prompt || "default", item, templatesDir);
|
|
142
|
+
if (prompt) {
|
|
143
|
+
args.push(prompt);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { args, cwd };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build the prompt from item and config
|
|
151
|
+
* Uses prompt_template if provided, otherwise combines title and body
|
|
152
|
+
* @param {object} item - Item with title, body, etc.
|
|
153
|
+
* @param {object} config - Config with optional session.prompt_template
|
|
154
|
+
* @returns {string} The prompt to send to opencode
|
|
155
|
+
*/
|
|
156
|
+
function buildPrompt(item, config) {
|
|
157
|
+
// If prompt_template is provided, expand it
|
|
158
|
+
if (config.session?.prompt_template) {
|
|
159
|
+
return expandTemplate(config.session.prompt_template, item);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Default: combine title and body
|
|
163
|
+
const parts = [];
|
|
164
|
+
if (item.title) parts.push(item.title);
|
|
165
|
+
if (item.body) parts.push(item.body);
|
|
166
|
+
return parts.join("\n\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build command args for action
|
|
171
|
+
* Uses "opencode run" for non-interactive execution
|
|
172
|
+
* @returns {object} { args: string[], cwd: string }
|
|
173
|
+
*/
|
|
174
|
+
function buildCommandArgs(item, config) {
|
|
175
|
+
const repoPath = expandPath(config.repo_path || ".");
|
|
176
|
+
const sessionTitle = config.session?.name_template
|
|
177
|
+
? buildSessionName(config.session.name_template, item)
|
|
178
|
+
: `issue-${item.number || Date.now()}`;
|
|
179
|
+
|
|
180
|
+
// Build opencode run command args array (non-interactive)
|
|
181
|
+
// Note: --title sets session title (--session is for continuing existing sessions)
|
|
182
|
+
const args = ["opencode", "run"];
|
|
183
|
+
|
|
184
|
+
// Add title for the session (helps identify it later)
|
|
185
|
+
args.push("--title", sessionTitle);
|
|
186
|
+
|
|
187
|
+
// Add agent if specified
|
|
188
|
+
if (config.session?.agent) {
|
|
189
|
+
args.push("--agent", config.session.agent);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Add prompt (must be last for "run" command)
|
|
193
|
+
const prompt = buildPrompt(item, config);
|
|
194
|
+
if (prompt) {
|
|
195
|
+
args.push(prompt);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { args, cwd: repoPath };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get command info for an action
|
|
203
|
+
* @param {object} item - Item to create session for
|
|
204
|
+
* @param {object} config - Repo config with action settings
|
|
205
|
+
* @returns {object} { args: string[], cwd: string }
|
|
206
|
+
*/
|
|
207
|
+
export function getCommandInfo(item, config) {
|
|
208
|
+
return getCommandInfoNew(item, config);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build command string for display/logging
|
|
213
|
+
* @param {object} item - Item to create session for
|
|
214
|
+
* @param {object} config - Repo config with action settings
|
|
215
|
+
* @returns {string} Command string (for display only)
|
|
216
|
+
*/
|
|
217
|
+
export function buildCommand(item, config) {
|
|
218
|
+
const cmdInfo = getCommandInfo(item, config);
|
|
219
|
+
|
|
220
|
+
const quoteArgs = (args) => args.map(a =>
|
|
221
|
+
a.includes(" ") || a.includes("\n") ? `"${a.replace(/"/g, '\\"')}"` : a
|
|
222
|
+
).join(" ");
|
|
223
|
+
|
|
224
|
+
const cmdStr = quoteArgs(cmdInfo.args);
|
|
225
|
+
return cmdInfo.cwd ? `(cd ${cmdInfo.cwd} && ${cmdStr})` : cmdStr;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Execute a spawn command and return a promise
|
|
230
|
+
*/
|
|
231
|
+
function runSpawn(args, options = {}) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const [cmd, ...cmdArgs] = args;
|
|
234
|
+
const spawnOpts = {
|
|
235
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
236
|
+
...options,
|
|
237
|
+
};
|
|
238
|
+
const child = spawn(cmd, cmdArgs, spawnOpts);
|
|
239
|
+
|
|
240
|
+
let stdout = "";
|
|
241
|
+
let stderr = "";
|
|
242
|
+
|
|
243
|
+
child.stdout.on("data", (data) => {
|
|
244
|
+
stdout += data.toString();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
child.stderr.on("data", (data) => {
|
|
248
|
+
stderr += data.toString();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
child.on("close", (code) => {
|
|
252
|
+
resolve({ stdout, stderr, exitCode: code, success: code === 0 });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
child.on("error", (err) => {
|
|
256
|
+
reject(err);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Execute an action
|
|
263
|
+
* @param {object} item - Item to create session for
|
|
264
|
+
* @param {object} config - Repo config with action settings
|
|
265
|
+
* @param {object} [options] - Execution options
|
|
266
|
+
* @param {boolean} [options.dryRun] - If true, return command without executing
|
|
267
|
+
* @returns {Promise<object>} Result with command, stdout, stderr, exitCode
|
|
268
|
+
*/
|
|
269
|
+
export async function executeAction(item, config, options = {}) {
|
|
270
|
+
const cmdInfo = getCommandInfo(item, config);
|
|
271
|
+
const command = buildCommand(item, config); // For logging/display
|
|
272
|
+
|
|
273
|
+
debug(`executeAction: command=${command}`);
|
|
274
|
+
debug(`executeAction: args=${JSON.stringify(cmdInfo.args)}, cwd=${cmdInfo.cwd}`);
|
|
275
|
+
|
|
276
|
+
if (options.dryRun) {
|
|
277
|
+
return {
|
|
278
|
+
command,
|
|
279
|
+
dryRun: true,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Execute opencode run in background (detached)
|
|
284
|
+
// We don't wait for completion since sessions can run for a long time
|
|
285
|
+
debug(`executeAction: spawning opencode run (detached)`);
|
|
286
|
+
const [cmd, ...cmdArgs] = cmdInfo.args;
|
|
287
|
+
const child = spawn(cmd, cmdArgs, {
|
|
288
|
+
cwd: cmdInfo.cwd,
|
|
289
|
+
detached: true,
|
|
290
|
+
stdio: 'ignore',
|
|
291
|
+
});
|
|
292
|
+
child.unref();
|
|
293
|
+
|
|
294
|
+
debug(`executeAction: spawned pid=${child.pid}`);
|
|
295
|
+
return {
|
|
296
|
+
command,
|
|
297
|
+
success: true,
|
|
298
|
+
pid: child.pid,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Check if opencode is available
|
|
304
|
+
* @returns {Promise<boolean>}
|
|
305
|
+
*/
|
|
306
|
+
export async function checkOpencode() {
|
|
307
|
+
return new Promise((resolve) => {
|
|
308
|
+
const child = spawn("which", ["opencode"]);
|
|
309
|
+
child.on("close", (code) => {
|
|
310
|
+
resolve(code === 0);
|
|
311
|
+
});
|
|
312
|
+
child.on("error", () => {
|
|
313
|
+
resolve(false);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Validate that required tools are available
|
|
320
|
+
* @returns {Promise<object>} { valid: boolean, missing?: string[] }
|
|
321
|
+
*/
|
|
322
|
+
export async function validateTools() {
|
|
323
|
+
const missing = [];
|
|
324
|
+
|
|
325
|
+
const hasOpencode = await checkOpencode();
|
|
326
|
+
if (!hasOpencode) {
|
|
327
|
+
missing.push("opencode");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
valid: missing.length === 0,
|
|
332
|
+
missing,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key>
|
|
6
|
+
<string>io.opencode.ntfy</string>
|
|
7
|
+
|
|
8
|
+
<key>ProgramArguments</key>
|
|
9
|
+
<array>
|
|
10
|
+
<string>/usr/local/bin/node</string>
|
|
11
|
+
<string>/usr/local/opt/opencode-ntfy/libexec/server.js</string>
|
|
12
|
+
</array>
|
|
13
|
+
|
|
14
|
+
<key>RunAtLoad</key>
|
|
15
|
+
<true/>
|
|
16
|
+
|
|
17
|
+
<key>KeepAlive</key>
|
|
18
|
+
<true/>
|
|
19
|
+
|
|
20
|
+
<key>StandardOutPath</key>
|
|
21
|
+
<string>/usr/local/var/log/opencode-ntfy.log</string>
|
|
22
|
+
|
|
23
|
+
<key>StandardErrorPath</key>
|
|
24
|
+
<string>/usr/local/var/log/opencode-ntfy.log</string>
|
|
25
|
+
|
|
26
|
+
<key>WorkingDirectory</key>
|
|
27
|
+
<string>/usr/local/opt/opencode-ntfy/libexec</string>
|
|
28
|
+
</dict>
|
|
29
|
+
</plist>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Debug logging module for opencode-pilot service
|
|
2
|
+
// Writes to ~/.local/share/opencode-pilot/debug.log when enabled via PILOT_DEBUG=true
|
|
3
|
+
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs'
|
|
5
|
+
import { join, dirname } from 'path'
|
|
6
|
+
import { homedir } from 'os'
|
|
7
|
+
|
|
8
|
+
// Maximum log file size before rotation (1MB)
|
|
9
|
+
export const MAX_LOG_SIZE = 1024 * 1024
|
|
10
|
+
|
|
11
|
+
// Default log path
|
|
12
|
+
const DEFAULT_LOG_PATH = join(homedir(), '.local', 'share', 'opencode-pilot', 'debug.log')
|
|
13
|
+
|
|
14
|
+
// Module state
|
|
15
|
+
let enabled = false
|
|
16
|
+
let logPath = DEFAULT_LOG_PATH
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Initialize the logger
|
|
20
|
+
* Checks PILOT_DEBUG env var
|
|
21
|
+
*/
|
|
22
|
+
export function initLogger() {
|
|
23
|
+
const envDebug = process.env.PILOT_DEBUG
|
|
24
|
+
enabled = envDebug !== undefined && envDebug !== '' && envDebug !== 'false' && envDebug !== '0'
|
|
25
|
+
|
|
26
|
+
if (enabled) {
|
|
27
|
+
try {
|
|
28
|
+
const dir = dirname(logPath)
|
|
29
|
+
if (!existsSync(dir)) {
|
|
30
|
+
mkdirSync(dir, { recursive: true })
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// Silently ignore
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write a debug log entry
|
|
40
|
+
* @param {string} message - Log message
|
|
41
|
+
* @param {Object} [data] - Optional data to include
|
|
42
|
+
*/
|
|
43
|
+
export function debug(message, data) {
|
|
44
|
+
if (!enabled) return
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
rotateIfNeeded()
|
|
48
|
+
|
|
49
|
+
const timestamp = new Date().toISOString()
|
|
50
|
+
let entry = `[${timestamp}] ${message}`
|
|
51
|
+
|
|
52
|
+
if (data !== undefined) {
|
|
53
|
+
entry += typeof data === 'object' ? ' ' + JSON.stringify(data) : ' ' + String(data)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
entry += '\n'
|
|
57
|
+
|
|
58
|
+
const dir = dirname(logPath)
|
|
59
|
+
if (!existsSync(dir)) {
|
|
60
|
+
mkdirSync(dir, { recursive: true })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
appendFileSync(logPath, entry)
|
|
64
|
+
} catch {
|
|
65
|
+
// Silently ignore
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function rotateIfNeeded() {
|
|
70
|
+
try {
|
|
71
|
+
if (!existsSync(logPath)) return
|
|
72
|
+
const stats = statSync(logPath)
|
|
73
|
+
if (stats.size > MAX_LOG_SIZE) {
|
|
74
|
+
unlinkSync(logPath)
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Silently ignore
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Auto-init on import
|
|
82
|
+
initLogger()
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* poll-service.js - Polling orchestration service
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the polling loop:
|
|
5
|
+
* 1. Load repo configuration
|
|
6
|
+
* 2. Fetch items from sources via MCP
|
|
7
|
+
* 3. Evaluate readiness
|
|
8
|
+
* 4. Execute actions for ready items
|
|
9
|
+
* 5. Track processed items to avoid duplicates
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { loadRepoConfig, getRepoConfig, getAllSources, getToolMappings } from "./repo-config.js";
|
|
13
|
+
import { createPoller, pollGenericSource } from "./poller.js";
|
|
14
|
+
import { evaluateReadiness, sortByPriority } from "./readiness.js";
|
|
15
|
+
import { executeAction, buildCommand } from "./actions.js";
|
|
16
|
+
import { debug } from "./logger.js";
|
|
17
|
+
import path from "path";
|
|
18
|
+
import os from "os";
|
|
19
|
+
|
|
20
|
+
// Default configuration
|
|
21
|
+
const DEFAULT_POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if a source has tool configuration
|
|
25
|
+
* @param {object} source - Source configuration
|
|
26
|
+
* @returns {boolean} True if source has tool.mcp and tool.name
|
|
27
|
+
*/
|
|
28
|
+
export function hasToolConfig(source) {
|
|
29
|
+
return !!(source.tool && source.tool.mcp && source.tool.name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build action config from source and repo config
|
|
34
|
+
* Source fields override repo config fields
|
|
35
|
+
* @param {object} source - Source configuration
|
|
36
|
+
* @param {object} repoConfig - Repository configuration
|
|
37
|
+
* @returns {object} Merged action config
|
|
38
|
+
*/
|
|
39
|
+
export function buildActionConfigFromSource(source, repoConfig) {
|
|
40
|
+
return {
|
|
41
|
+
// Repo config as base
|
|
42
|
+
...repoConfig,
|
|
43
|
+
// Normalize path to repo_path
|
|
44
|
+
repo_path: source.working_dir || repoConfig.path || repoConfig.repo_path,
|
|
45
|
+
// Session from source or repo
|
|
46
|
+
session: source.session || repoConfig.session || {},
|
|
47
|
+
// Source-level overrides (highest priority)
|
|
48
|
+
...(source.prompt && { prompt: source.prompt }),
|
|
49
|
+
...(source.agent && { agent: source.agent }),
|
|
50
|
+
...(source.model && { model: source.model }),
|
|
51
|
+
...(source.working_dir && { working_dir: source.working_dir }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Global state
|
|
56
|
+
let pollingInterval = null;
|
|
57
|
+
let pollerInstance = null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run a single poll cycle
|
|
61
|
+
* @param {object} options - Poll options
|
|
62
|
+
* @param {boolean} [options.dryRun] - If true, don't execute actions
|
|
63
|
+
* @param {boolean} [options.skipMcp] - If true, skip MCP fetching (for testing)
|
|
64
|
+
* @param {string} [options.configPath] - Path to config.yaml
|
|
65
|
+
* @returns {Promise<Array>} Results of actions taken
|
|
66
|
+
*/
|
|
67
|
+
export async function pollOnce(options = {}) {
|
|
68
|
+
const {
|
|
69
|
+
dryRun = false,
|
|
70
|
+
skipMcp = false,
|
|
71
|
+
configPath,
|
|
72
|
+
} = options;
|
|
73
|
+
|
|
74
|
+
const results = [];
|
|
75
|
+
|
|
76
|
+
// Load configuration
|
|
77
|
+
loadRepoConfig(configPath);
|
|
78
|
+
|
|
79
|
+
// Get all sources
|
|
80
|
+
const sources = getAllSources();
|
|
81
|
+
|
|
82
|
+
if (sources.length === 0) {
|
|
83
|
+
debug("No sources configured");
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Process each source
|
|
88
|
+
for (const source of sources) {
|
|
89
|
+
const sourceName = source.name || 'unknown';
|
|
90
|
+
const repoKey = source.name || 'default';
|
|
91
|
+
const repoConfig = getRepoConfig(repoKey) || {};
|
|
92
|
+
|
|
93
|
+
if (!hasToolConfig(source)) {
|
|
94
|
+
console.error(`[poll] Source '${sourceName}' missing tool configuration (requires tool.mcp and tool.name)`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let items = [];
|
|
99
|
+
|
|
100
|
+
// Fetch items from source
|
|
101
|
+
if (!skipMcp) {
|
|
102
|
+
try {
|
|
103
|
+
const mappings = getToolMappings(source.tool.mcp);
|
|
104
|
+
items = await pollGenericSource(source, { mappings });
|
|
105
|
+
debug(`Fetched ${items.length} items from ${sourceName}`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`[poll] Error fetching from ${sourceName}: ${err.message}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Evaluate readiness and filter
|
|
113
|
+
const readyItems = items
|
|
114
|
+
.map((item) => {
|
|
115
|
+
const readiness = evaluateReadiness(item, repoConfig);
|
|
116
|
+
debug(`Item ${item.id}: ready=${readiness.ready}, reason=${readiness.reason || 'none'}`);
|
|
117
|
+
return {
|
|
118
|
+
...item,
|
|
119
|
+
repo_key: repoKey,
|
|
120
|
+
repo_short: repoKey.split("/").pop(),
|
|
121
|
+
_readiness: readiness,
|
|
122
|
+
};
|
|
123
|
+
})
|
|
124
|
+
.filter((item) => item._readiness.ready);
|
|
125
|
+
|
|
126
|
+
debug(`${readyItems.length} items ready out of ${items.length}`);
|
|
127
|
+
|
|
128
|
+
// Sort by priority
|
|
129
|
+
const sortedItems = sortByPriority(readyItems, repoConfig);
|
|
130
|
+
|
|
131
|
+
// Process ready items
|
|
132
|
+
debug(`Processing ${sortedItems.length} sorted items`);
|
|
133
|
+
for (const item of sortedItems) {
|
|
134
|
+
// Check if already processed
|
|
135
|
+
if (pollerInstance && pollerInstance.isProcessed(item.id)) {
|
|
136
|
+
debug(`Skipping ${item.id} - already processed`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
debug(`Executing action for ${item.id}`);
|
|
141
|
+
// Build action config from source (includes agent, model, prompt, working_dir)
|
|
142
|
+
const actionConfig = buildActionConfigFromSource(source, repoConfig);
|
|
143
|
+
|
|
144
|
+
// Execute or dry-run
|
|
145
|
+
if (dryRun) {
|
|
146
|
+
const command = buildCommand(item, actionConfig);
|
|
147
|
+
results.push({
|
|
148
|
+
item,
|
|
149
|
+
command,
|
|
150
|
+
dryRun: true,
|
|
151
|
+
});
|
|
152
|
+
console.log(`[poll] Would execute: ${command}`);
|
|
153
|
+
} else {
|
|
154
|
+
try {
|
|
155
|
+
const result = await executeAction(item, actionConfig);
|
|
156
|
+
results.push({
|
|
157
|
+
item,
|
|
158
|
+
...result,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (result.success) {
|
|
162
|
+
// Mark as processed to avoid re-triggering
|
|
163
|
+
if (pollerInstance) {
|
|
164
|
+
pollerInstance.markProcessed(item.id, { repoKey, command: result.command });
|
|
165
|
+
}
|
|
166
|
+
console.log(`[poll] Started session for ${item.id}`);
|
|
167
|
+
} else {
|
|
168
|
+
console.error(`[poll] Failed to start session: ${result.stderr}`);
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error(`[poll] Error executing action: ${err.message}`);
|
|
172
|
+
results.push({
|
|
173
|
+
item,
|
|
174
|
+
error: err.message,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return results;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Start the polling loop
|
|
186
|
+
* @param {object} options - Polling options
|
|
187
|
+
* @param {number} [options.interval] - Poll interval in ms
|
|
188
|
+
* @param {string} [options.configPath] - Path to config.yaml
|
|
189
|
+
* @returns {object} Polling state with stop() method
|
|
190
|
+
*/
|
|
191
|
+
export function startPolling(options = {}) {
|
|
192
|
+
const { interval = DEFAULT_POLL_INTERVAL, configPath } = options;
|
|
193
|
+
|
|
194
|
+
// Initialize poller for state tracking
|
|
195
|
+
pollerInstance = createPoller({ configPath });
|
|
196
|
+
|
|
197
|
+
// Run first poll immediately
|
|
198
|
+
pollOnce({ configPath }).catch((err) => {
|
|
199
|
+
console.error("[poll] Error in poll cycle:", err.message);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Start interval
|
|
203
|
+
pollingInterval = setInterval(() => {
|
|
204
|
+
pollOnce({ configPath }).catch((err) => {
|
|
205
|
+
console.error("[poll] Error in poll cycle:", err.message);
|
|
206
|
+
});
|
|
207
|
+
}, interval);
|
|
208
|
+
|
|
209
|
+
console.log(`[poll] Started polling every ${interval / 1000}s`);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
interval: pollingInterval,
|
|
213
|
+
poller: pollerInstance,
|
|
214
|
+
stop: stopPolling,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Stop the polling loop
|
|
220
|
+
*/
|
|
221
|
+
export function stopPolling() {
|
|
222
|
+
if (pollingInterval) {
|
|
223
|
+
clearInterval(pollingInterval);
|
|
224
|
+
pollingInterval = null;
|
|
225
|
+
console.log("[poll] Stopped polling");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Clear processed state for an item (e.g., when issue is closed/reopened)
|
|
231
|
+
* @param {string} itemId - Item ID to clear
|
|
232
|
+
*/
|
|
233
|
+
export function clearProcessed(itemId) {
|
|
234
|
+
if (pollerInstance) {
|
|
235
|
+
// Access the poller's internal state - need to expose this
|
|
236
|
+
console.log(`[poll] Cleared processed state for ${itemId}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get the poller instance (for external state management)
|
|
242
|
+
* @returns {object|null} Poller instance or null if not started
|
|
243
|
+
*/
|
|
244
|
+
export function getPoller() {
|
|
245
|
+
return pollerInstance;
|
|
246
|
+
}
|