opencode-pilot 0.11.2 → 0.13.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/package.json +1 -1
- package/service/poll-service.js +11 -1
- package/service/poller.js +106 -4
- package/service/readiness.js +50 -0
- package/test/unit/poller.test.js +54 -0
- package/test/unit/readiness.test.js +180 -0
- package/test/unit/repo-config.test.js +2 -1
package/package.json
CHANGED
package/service/poll-service.js
CHANGED
|
@@ -146,7 +146,17 @@ export async function pollOnce(options = {}) {
|
|
|
146
146
|
const repoKey = repoKeys.length > 0 ? repoKeys[0] : null;
|
|
147
147
|
const repoConfig = repoKey ? getRepoConfig(repoKey) : {};
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
// Merge source-level readiness config with repo config
|
|
150
|
+
// Source readiness takes precedence
|
|
151
|
+
const readinessConfig = {
|
|
152
|
+
...repoConfig,
|
|
153
|
+
readiness: {
|
|
154
|
+
...repoConfig.readiness,
|
|
155
|
+
...source.readiness,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const readiness = evaluateReadiness(item, readinessConfig);
|
|
150
160
|
debug(`Item ${item.id}: ready=${readiness.ready}, reason=${readiness.reason || 'none'}`);
|
|
151
161
|
return {
|
|
152
162
|
...item,
|
package/service/poller.js
CHANGED
|
@@ -67,15 +67,33 @@ export function applyMappings(item, mappings) {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Get tool configuration from a source
|
|
70
|
+
* Supports both MCP tools and CLI commands.
|
|
71
|
+
*
|
|
70
72
|
* @param {object} source - Source configuration from config.yaml
|
|
71
|
-
* @returns {object} Tool configuration
|
|
73
|
+
* @returns {object} Tool configuration with type indicator
|
|
72
74
|
*/
|
|
73
75
|
export function getToolConfig(source) {
|
|
74
|
-
if (!source.tool
|
|
75
|
-
throw new Error(`Source '${source.name || 'unknown'}' missing tool configuration
|
|
76
|
+
if (!source.tool) {
|
|
77
|
+
throw new Error(`Source '${source.name || 'unknown'}' missing tool configuration`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// CLI command support
|
|
81
|
+
if (source.tool.command) {
|
|
82
|
+
return {
|
|
83
|
+
type: 'cli',
|
|
84
|
+
command: source.tool.command,
|
|
85
|
+
args: source.args || {},
|
|
86
|
+
idTemplate: source.item?.id || null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// MCP tool support (existing behavior)
|
|
91
|
+
if (!source.tool.mcp || !source.tool.name) {
|
|
92
|
+
throw new Error(`Source '${source.name || 'unknown'}' missing tool configuration (requires tool.mcp and tool.name, or tool.command)`);
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
return {
|
|
96
|
+
type: 'mcp',
|
|
79
97
|
mcpServer: source.tool.mcp,
|
|
80
98
|
toolName: source.tool.name,
|
|
81
99
|
args: source.args || {},
|
|
@@ -214,7 +232,84 @@ function createTimeout(ms, operation) {
|
|
|
214
232
|
}
|
|
215
233
|
|
|
216
234
|
/**
|
|
217
|
-
*
|
|
235
|
+
* Execute a CLI command and return parsed JSON output
|
|
236
|
+
*
|
|
237
|
+
* @param {string|string[]} command - Command to execute (string or array)
|
|
238
|
+
* @param {object} args - Arguments to substitute into command
|
|
239
|
+
* @param {number} timeout - Timeout in ms
|
|
240
|
+
* @returns {Promise<string>} Command output
|
|
241
|
+
*/
|
|
242
|
+
async function executeCliCommand(command, args, timeout) {
|
|
243
|
+
const { exec } = await import('child_process');
|
|
244
|
+
const { promisify } = await import('util');
|
|
245
|
+
const execAsync = promisify(exec);
|
|
246
|
+
|
|
247
|
+
// Build command string
|
|
248
|
+
let cmdStr;
|
|
249
|
+
if (Array.isArray(command)) {
|
|
250
|
+
// Substitute args into command array
|
|
251
|
+
const expandedCmd = command.map(part => {
|
|
252
|
+
if (typeof part === 'string' && part.startsWith('$')) {
|
|
253
|
+
const argName = part.slice(1);
|
|
254
|
+
return args[argName] !== undefined ? String(args[argName]) : part;
|
|
255
|
+
}
|
|
256
|
+
return part;
|
|
257
|
+
});
|
|
258
|
+
// Quote parts with spaces
|
|
259
|
+
cmdStr = expandedCmd.map(p => p.includes(' ') ? `"${p}"` : p).join(' ');
|
|
260
|
+
} else {
|
|
261
|
+
// String command - substitute ${argName} patterns
|
|
262
|
+
cmdStr = command.replace(/\$\{(\w+)\}/g, (_, name) => {
|
|
263
|
+
return args[name] !== undefined ? String(args[name]) : '';
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { stdout } = await Promise.race([
|
|
268
|
+
execAsync(cmdStr, { env: { ...process.env } }),
|
|
269
|
+
createTimeout(timeout, `CLI command: ${cmdStr.slice(0, 50)}...`),
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
return stdout;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Poll a source using CLI command
|
|
277
|
+
*
|
|
278
|
+
* @param {object} source - Source configuration from config.yaml
|
|
279
|
+
* @param {object} toolConfig - Tool config from getToolConfig()
|
|
280
|
+
* @param {object} [options] - Additional options
|
|
281
|
+
* @param {number} [options.timeout] - Timeout in ms (default: 30000)
|
|
282
|
+
* @param {object} [options.toolProviderConfig] - Tool provider config (response_key, mappings)
|
|
283
|
+
* @returns {Promise<Array>} Array of items from the source with IDs and mappings applied
|
|
284
|
+
*/
|
|
285
|
+
async function pollCliSource(source, toolConfig, options = {}) {
|
|
286
|
+
const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
|
|
287
|
+
const toolProviderConfig = options.toolProviderConfig || {};
|
|
288
|
+
const responseKey = toolProviderConfig.response_key;
|
|
289
|
+
const mappings = toolProviderConfig.mappings || null;
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const output = await executeCliCommand(toolConfig.command, toolConfig.args, timeout);
|
|
293
|
+
|
|
294
|
+
if (!output || !output.trim()) return [];
|
|
295
|
+
|
|
296
|
+
const rawItems = parseJsonArray(output, source.name, responseKey);
|
|
297
|
+
|
|
298
|
+
// Apply field mappings before transforming
|
|
299
|
+
const mappedItems = mappings
|
|
300
|
+
? rawItems.map(item => applyMappings(item, mappings))
|
|
301
|
+
: rawItems;
|
|
302
|
+
|
|
303
|
+
// Transform items (add IDs)
|
|
304
|
+
return transformItems(mappedItems, toolConfig.idTemplate);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
console.error(`[poller] CLI command failed for ${source.name}: ${err.message}`);
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Poll a source using MCP tools or CLI commands
|
|
218
313
|
*
|
|
219
314
|
* @param {object} source - Source configuration from config.yaml
|
|
220
315
|
* @param {object} [options] - Additional options
|
|
@@ -225,6 +320,13 @@ function createTimeout(ms, operation) {
|
|
|
225
320
|
*/
|
|
226
321
|
export async function pollGenericSource(source, options = {}) {
|
|
227
322
|
const toolConfig = getToolConfig(source);
|
|
323
|
+
|
|
324
|
+
// Route to CLI handler if command-based
|
|
325
|
+
if (toolConfig.type === 'cli') {
|
|
326
|
+
return pollCliSource(source, toolConfig, options);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// MCP-based polling (existing behavior)
|
|
228
330
|
const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
|
|
229
331
|
const toolProviderConfig = options.toolProviderConfig || {};
|
|
230
332
|
const responseKey = toolProviderConfig.response_key;
|
package/service/readiness.js
CHANGED
|
@@ -134,6 +134,46 @@ export function checkDependencies(issue, config) {
|
|
|
134
134
|
return { ready: true };
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Check if item fields match required values
|
|
139
|
+
*
|
|
140
|
+
* Generic field-based readiness check. Configured via readiness.fields in config.
|
|
141
|
+
* All specified fields must match their required values for the item to be ready.
|
|
142
|
+
*
|
|
143
|
+
* Example config:
|
|
144
|
+
* readiness:
|
|
145
|
+
* fields:
|
|
146
|
+
* has_notes: true
|
|
147
|
+
* type: "meeting"
|
|
148
|
+
*
|
|
149
|
+
* @param {object} item - Item with fields to check
|
|
150
|
+
* @param {object} config - Config with optional readiness.fields
|
|
151
|
+
* @returns {object} { ready: boolean, reason?: string }
|
|
152
|
+
*/
|
|
153
|
+
export function checkFields(item, config) {
|
|
154
|
+
const readinessConfig = config.readiness || {};
|
|
155
|
+
const fieldsConfig = readinessConfig.fields || {};
|
|
156
|
+
|
|
157
|
+
// No fields configured - skip check
|
|
158
|
+
if (Object.keys(fieldsConfig).length === 0) {
|
|
159
|
+
return { ready: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check each required field
|
|
163
|
+
for (const [field, requiredValue] of Object.entries(fieldsConfig)) {
|
|
164
|
+
const actualValue = item[field];
|
|
165
|
+
|
|
166
|
+
if (actualValue !== requiredValue) {
|
|
167
|
+
return {
|
|
168
|
+
ready: false,
|
|
169
|
+
reason: `Field '${field}' is ${JSON.stringify(actualValue)}, required ${JSON.stringify(requiredValue)}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { ready: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
137
177
|
/**
|
|
138
178
|
* Check if a PR/issue has meaningful (non-bot, non-author) comments
|
|
139
179
|
*
|
|
@@ -246,6 +286,16 @@ export function evaluateReadiness(issue, config) {
|
|
|
246
286
|
};
|
|
247
287
|
}
|
|
248
288
|
|
|
289
|
+
// Check required field values
|
|
290
|
+
const fieldsResult = checkFields(issue, config);
|
|
291
|
+
if (!fieldsResult.ready) {
|
|
292
|
+
return {
|
|
293
|
+
ready: false,
|
|
294
|
+
reason: fieldsResult.reason,
|
|
295
|
+
priority: 0,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
249
299
|
// Calculate priority for ready issues
|
|
250
300
|
const priority = calculatePriority(issue, config);
|
|
251
301
|
|
package/test/unit/poller.test.js
CHANGED
|
@@ -57,6 +57,60 @@ describe('poller.js', () => {
|
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
describe('getToolConfig', () => {
|
|
61
|
+
test('returns MCP config for mcp-based sources', async () => {
|
|
62
|
+
const { getToolConfig } = await import('../../service/poller.js');
|
|
63
|
+
|
|
64
|
+
const source = {
|
|
65
|
+
name: 'test-source',
|
|
66
|
+
tool: { mcp: 'github', name: 'search_issues' },
|
|
67
|
+
args: { q: 'is:open' },
|
|
68
|
+
item: { id: '{html_url}' }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const config = getToolConfig(source);
|
|
72
|
+
assert.strictEqual(config.type, 'mcp');
|
|
73
|
+
assert.strictEqual(config.mcpServer, 'github');
|
|
74
|
+
assert.strictEqual(config.toolName, 'search_issues');
|
|
75
|
+
assert.deepStrictEqual(config.args, { q: 'is:open' });
|
|
76
|
+
assert.strictEqual(config.idTemplate, '{html_url}');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('returns CLI config for command-based sources', async () => {
|
|
80
|
+
const { getToolConfig } = await import('../../service/poller.js');
|
|
81
|
+
|
|
82
|
+
const source = {
|
|
83
|
+
name: 'test-source',
|
|
84
|
+
tool: { command: ['granola-cli', 'meetings', 'list', '20'] },
|
|
85
|
+
item: { id: 'meeting:{id}' }
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const config = getToolConfig(source);
|
|
89
|
+
assert.strictEqual(config.type, 'cli');
|
|
90
|
+
assert.deepStrictEqual(config.command, ['granola-cli', 'meetings', 'list', '20']);
|
|
91
|
+
assert.strictEqual(config.idTemplate, 'meeting:{id}');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('throws for sources missing tool config', async () => {
|
|
95
|
+
const { getToolConfig } = await import('../../service/poller.js');
|
|
96
|
+
|
|
97
|
+
const source = { name: 'bad-source' };
|
|
98
|
+
|
|
99
|
+
assert.throws(() => getToolConfig(source), /missing tool configuration/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('throws for mcp sources missing name', async () => {
|
|
103
|
+
const { getToolConfig } = await import('../../service/poller.js');
|
|
104
|
+
|
|
105
|
+
const source = {
|
|
106
|
+
name: 'bad-source',
|
|
107
|
+
tool: { mcp: 'github' } // missing name
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
assert.throws(() => getToolConfig(source), /missing tool configuration/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
60
114
|
describe('createPoller', () => {
|
|
61
115
|
test('creates poller with state tracking', async () => {
|
|
62
116
|
const { createPoller } = await import('../../service/poller.js');
|
|
@@ -172,6 +172,143 @@ describe('readiness.js', () => {
|
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
describe('checkFields', () => {
|
|
176
|
+
test('returns ready when no fields configured', async () => {
|
|
177
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
178
|
+
|
|
179
|
+
const item = { id: 'item-1', has_notes: false };
|
|
180
|
+
const config = {};
|
|
181
|
+
|
|
182
|
+
const result = checkFields(item, config);
|
|
183
|
+
|
|
184
|
+
assert.strictEqual(result.ready, true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('returns ready when field matches required value (boolean)', async () => {
|
|
188
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
189
|
+
|
|
190
|
+
const meeting = { id: 'meeting-1', has_notes: true };
|
|
191
|
+
const config = {
|
|
192
|
+
readiness: {
|
|
193
|
+
fields: {
|
|
194
|
+
has_notes: true
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const result = checkFields(meeting, config);
|
|
200
|
+
|
|
201
|
+
assert.strictEqual(result.ready, true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('returns not ready when field does not match required value', async () => {
|
|
205
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
206
|
+
|
|
207
|
+
const meeting = { id: 'meeting-1', has_notes: false };
|
|
208
|
+
const config = {
|
|
209
|
+
readiness: {
|
|
210
|
+
fields: {
|
|
211
|
+
has_notes: true
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const result = checkFields(meeting, config);
|
|
217
|
+
|
|
218
|
+
assert.strictEqual(result.ready, false);
|
|
219
|
+
assert.ok(result.reason.includes('has_notes'));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('returns not ready when required field is missing', async () => {
|
|
223
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
224
|
+
|
|
225
|
+
const item = { id: 'item-1', title: 'Test' };
|
|
226
|
+
const config = {
|
|
227
|
+
readiness: {
|
|
228
|
+
fields: {
|
|
229
|
+
has_notes: true
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const result = checkFields(item, config);
|
|
235
|
+
|
|
236
|
+
assert.strictEqual(result.ready, false);
|
|
237
|
+
assert.ok(result.reason.includes('has_notes'));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('checks multiple fields (all must match)', async () => {
|
|
241
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
242
|
+
|
|
243
|
+
const item = { id: 'item-1', has_notes: true, type: 'meeting' };
|
|
244
|
+
const config = {
|
|
245
|
+
readiness: {
|
|
246
|
+
fields: {
|
|
247
|
+
has_notes: true,
|
|
248
|
+
type: 'meeting'
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const result = checkFields(item, config);
|
|
254
|
+
|
|
255
|
+
assert.strictEqual(result.ready, true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('fails if any field does not match', async () => {
|
|
259
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
260
|
+
|
|
261
|
+
const item = { id: 'item-1', has_notes: true, type: 'note' };
|
|
262
|
+
const config = {
|
|
263
|
+
readiness: {
|
|
264
|
+
fields: {
|
|
265
|
+
has_notes: true,
|
|
266
|
+
type: 'meeting'
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const result = checkFields(item, config);
|
|
272
|
+
|
|
273
|
+
assert.strictEqual(result.ready, false);
|
|
274
|
+
assert.ok(result.reason.includes('type'));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('supports string field values', async () => {
|
|
278
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
279
|
+
|
|
280
|
+
const item = { id: 'item-1', state: 'open' };
|
|
281
|
+
const config = {
|
|
282
|
+
readiness: {
|
|
283
|
+
fields: {
|
|
284
|
+
state: 'open'
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const result = checkFields(item, config);
|
|
290
|
+
|
|
291
|
+
assert.strictEqual(result.ready, true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('supports numeric field values', async () => {
|
|
295
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
296
|
+
|
|
297
|
+
const item = { id: 'item-1', participant_count: 3 };
|
|
298
|
+
const config = {
|
|
299
|
+
readiness: {
|
|
300
|
+
fields: {
|
|
301
|
+
participant_count: 3
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const result = checkFields(item, config);
|
|
307
|
+
|
|
308
|
+
assert.strictEqual(result.ready, true);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
175
312
|
describe('evaluateReadiness', () => {
|
|
176
313
|
test('checks bot comments when _comments is present', async () => {
|
|
177
314
|
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
@@ -206,5 +343,48 @@ describe('readiness.js', () => {
|
|
|
206
343
|
|
|
207
344
|
assert.strictEqual(result.ready, true);
|
|
208
345
|
});
|
|
346
|
+
|
|
347
|
+
test('checks fields when readiness.fields is configured', async () => {
|
|
348
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
349
|
+
|
|
350
|
+
const meeting = {
|
|
351
|
+
id: 'meeting-123',
|
|
352
|
+
title: 'Team Standup',
|
|
353
|
+
has_notes: false
|
|
354
|
+
};
|
|
355
|
+
const config = {
|
|
356
|
+
readiness: {
|
|
357
|
+
fields: {
|
|
358
|
+
has_notes: true
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const result = evaluateReadiness(meeting, config);
|
|
364
|
+
|
|
365
|
+
assert.strictEqual(result.ready, false);
|
|
366
|
+
assert.ok(result.reason.includes('has_notes'));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('passes when field matches required value', async () => {
|
|
370
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
371
|
+
|
|
372
|
+
const meeting = {
|
|
373
|
+
id: 'meeting-123',
|
|
374
|
+
title: 'Team Standup',
|
|
375
|
+
has_notes: true
|
|
376
|
+
};
|
|
377
|
+
const config = {
|
|
378
|
+
readiness: {
|
|
379
|
+
fields: {
|
|
380
|
+
has_notes: true
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const result = evaluateReadiness(meeting, config);
|
|
386
|
+
|
|
387
|
+
assert.strictEqual(result.ready, true);
|
|
388
|
+
});
|
|
209
389
|
});
|
|
210
390
|
});
|
|
@@ -700,8 +700,9 @@ sources:
|
|
|
700
700
|
loadRepoConfig(configPath);
|
|
701
701
|
const sources = getSources();
|
|
702
702
|
|
|
703
|
-
assert.strictEqual(sources[0].session.name, '{title}', 'linear
|
|
703
|
+
assert.strictEqual(sources[0].session.name, '{title}', 'linear preset should use title');
|
|
704
704
|
});
|
|
705
|
+
|
|
705
706
|
});
|
|
706
707
|
|
|
707
708
|
describe('shorthand syntax', () => {
|