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.
Files changed (50) hide show
  1. package/.devcontainer/devcontainer.json +16 -0
  2. package/.github/workflows/ci.yml +67 -0
  3. package/.releaserc.cjs +28 -0
  4. package/AGENTS.md +71 -0
  5. package/CONTRIBUTING.md +102 -0
  6. package/LICENSE +21 -0
  7. package/README.md +72 -0
  8. package/bin/opencode-pilot +809 -0
  9. package/dist/opencode-ntfy.tar.gz +0 -0
  10. package/examples/config.yaml +73 -0
  11. package/examples/templates/default.md +7 -0
  12. package/examples/templates/devcontainer.md +7 -0
  13. package/examples/templates/review-feedback.md +7 -0
  14. package/examples/templates/review.md +15 -0
  15. package/install.sh +246 -0
  16. package/package.json +40 -0
  17. package/plugin/config.js +76 -0
  18. package/plugin/index.js +260 -0
  19. package/plugin/logger.js +125 -0
  20. package/plugin/notifier.js +110 -0
  21. package/service/actions.js +334 -0
  22. package/service/io.opencode.ntfy.plist +29 -0
  23. package/service/logger.js +82 -0
  24. package/service/poll-service.js +246 -0
  25. package/service/poller.js +339 -0
  26. package/service/readiness.js +234 -0
  27. package/service/repo-config.js +222 -0
  28. package/service/server.js +1523 -0
  29. package/service/utils.js +21 -0
  30. package/test/run_tests.bash +34 -0
  31. package/test/test_actions.bash +263 -0
  32. package/test/test_cli.bash +161 -0
  33. package/test/test_config.bash +438 -0
  34. package/test/test_helper.bash +140 -0
  35. package/test/test_logger.bash +401 -0
  36. package/test/test_notifier.bash +310 -0
  37. package/test/test_plist.bash +125 -0
  38. package/test/test_plugin.bash +952 -0
  39. package/test/test_poll_service.bash +179 -0
  40. package/test/test_poller.bash +120 -0
  41. package/test/test_readiness.bash +313 -0
  42. package/test/test_repo_config.bash +406 -0
  43. package/test/test_service.bash +1342 -0
  44. package/test/unit/actions.test.js +235 -0
  45. package/test/unit/config.test.js +86 -0
  46. package/test/unit/paths.test.js +77 -0
  47. package/test/unit/poll-service.test.js +142 -0
  48. package/test/unit/poller.test.js +347 -0
  49. package/test/unit/repo-config.test.js +441 -0
  50. 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
+ }