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,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for poller.js - generic MCP-based polling
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { tmpdir } from 'os';
|
|
10
|
+
|
|
11
|
+
describe('poller.js', () => {
|
|
12
|
+
let tempDir;
|
|
13
|
+
let stateFile;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tempDir = mkdtempSync(join(tmpdir(), 'opencode-pilot-poller-test-'));
|
|
17
|
+
stateFile = join(tempDir, 'poll-state.json');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('expandItemId', () => {
|
|
25
|
+
test('expands simple field references', async () => {
|
|
26
|
+
const { expandItemId } = await import('../../service/poller.js');
|
|
27
|
+
|
|
28
|
+
const template = 'github:{repository.full_name}#{number}';
|
|
29
|
+
const item = {
|
|
30
|
+
repository: { full_name: 'myorg/backend' },
|
|
31
|
+
number: 123
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const id = expandItemId(template, item);
|
|
35
|
+
assert.strictEqual(id, 'github:myorg/backend#123');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('handles missing fields gracefully', async () => {
|
|
39
|
+
const { expandItemId } = await import('../../service/poller.js');
|
|
40
|
+
|
|
41
|
+
const template = 'github:{repository.full_name}#{number}';
|
|
42
|
+
const item = { number: 123 };
|
|
43
|
+
|
|
44
|
+
const id = expandItemId(template, item);
|
|
45
|
+
// Should keep placeholder for missing field
|
|
46
|
+
assert.strictEqual(id, 'github:{repository.full_name}#123');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('expands top-level fields', async () => {
|
|
50
|
+
const { expandItemId } = await import('../../service/poller.js');
|
|
51
|
+
|
|
52
|
+
const template = 'linear:{identifier}';
|
|
53
|
+
const item = { identifier: 'PROJ-123' };
|
|
54
|
+
|
|
55
|
+
const id = expandItemId(template, item);
|
|
56
|
+
assert.strictEqual(id, 'linear:PROJ-123');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('createPoller', () => {
|
|
61
|
+
test('creates poller with state tracking', async () => {
|
|
62
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
63
|
+
|
|
64
|
+
const poller = createPoller({ stateFile });
|
|
65
|
+
|
|
66
|
+
assert.strictEqual(typeof poller.isProcessed, 'function');
|
|
67
|
+
assert.strictEqual(typeof poller.markProcessed, 'function');
|
|
68
|
+
assert.strictEqual(typeof poller.clearState, 'function');
|
|
69
|
+
assert.strictEqual(poller.getProcessedIds().length, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('tracks processed items', async () => {
|
|
73
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
74
|
+
|
|
75
|
+
const poller = createPoller({ stateFile });
|
|
76
|
+
|
|
77
|
+
assert.strictEqual(poller.isProcessed('item-1'), false);
|
|
78
|
+
|
|
79
|
+
poller.markProcessed('item-1', { source: 'test' });
|
|
80
|
+
|
|
81
|
+
assert.strictEqual(poller.isProcessed('item-1'), true);
|
|
82
|
+
assert.strictEqual(poller.getProcessedIds().length, 1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('persists state across instances', async () => {
|
|
86
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
87
|
+
|
|
88
|
+
const poller1 = createPoller({ stateFile });
|
|
89
|
+
poller1.markProcessed('item-1');
|
|
90
|
+
|
|
91
|
+
const poller2 = createPoller({ stateFile });
|
|
92
|
+
assert.strictEqual(poller2.isProcessed('item-1'), true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('clearState removes all processed items', async () => {
|
|
96
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
97
|
+
|
|
98
|
+
const poller = createPoller({ stateFile });
|
|
99
|
+
poller.markProcessed('item-1');
|
|
100
|
+
poller.markProcessed('item-2');
|
|
101
|
+
|
|
102
|
+
assert.strictEqual(poller.getProcessedIds().length, 2);
|
|
103
|
+
|
|
104
|
+
poller.clearState();
|
|
105
|
+
|
|
106
|
+
assert.strictEqual(poller.getProcessedIds().length, 0);
|
|
107
|
+
assert.strictEqual(poller.isProcessed('item-1'), false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('pollGenericSource', () => {
|
|
112
|
+
test('extracts tool config from source', async () => {
|
|
113
|
+
const { getToolConfig } = await import('../../service/poller.js');
|
|
114
|
+
|
|
115
|
+
const source = {
|
|
116
|
+
name: 'my-issues',
|
|
117
|
+
tool: {
|
|
118
|
+
mcp: 'github',
|
|
119
|
+
name: 'github_search_issues'
|
|
120
|
+
},
|
|
121
|
+
args: {
|
|
122
|
+
q: 'is:issue assignee:@me'
|
|
123
|
+
},
|
|
124
|
+
item: {
|
|
125
|
+
id: 'github:{repository.full_name}#{number}'
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const toolConfig = getToolConfig(source);
|
|
130
|
+
|
|
131
|
+
assert.strictEqual(toolConfig.mcpServer, 'github');
|
|
132
|
+
assert.strictEqual(toolConfig.toolName, 'github_search_issues');
|
|
133
|
+
assert.deepStrictEqual(toolConfig.args, { q: 'is:issue assignee:@me' });
|
|
134
|
+
assert.strictEqual(toolConfig.idTemplate, 'github:{repository.full_name}#{number}');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('throws for missing tool config', async () => {
|
|
138
|
+
const { getToolConfig } = await import('../../service/poller.js');
|
|
139
|
+
|
|
140
|
+
const source = {
|
|
141
|
+
name: 'bad-source'
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
assert.throws(() => getToolConfig(source), /tool configuration/);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('transformItems', () => {
|
|
149
|
+
test('adds id to items using template', async () => {
|
|
150
|
+
const { transformItems } = await import('../../service/poller.js');
|
|
151
|
+
|
|
152
|
+
const items = [
|
|
153
|
+
{ repository: { full_name: 'myorg/backend' }, number: 1, title: 'Issue 1' },
|
|
154
|
+
{ repository: { full_name: 'myorg/backend' }, number: 2, title: 'Issue 2' },
|
|
155
|
+
];
|
|
156
|
+
const idTemplate = 'github:{repository.full_name}#{number}';
|
|
157
|
+
|
|
158
|
+
const transformed = transformItems(items, idTemplate);
|
|
159
|
+
|
|
160
|
+
assert.strictEqual(transformed[0].id, 'github:myorg/backend#1');
|
|
161
|
+
assert.strictEqual(transformed[1].id, 'github:myorg/backend#2');
|
|
162
|
+
// Original fields preserved
|
|
163
|
+
assert.strictEqual(transformed[0].title, 'Issue 1');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('preserves existing id if no template', async () => {
|
|
167
|
+
const { transformItems } = await import('../../service/poller.js');
|
|
168
|
+
|
|
169
|
+
const items = [
|
|
170
|
+
{ id: 'existing-id', title: 'Issue 1' },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const transformed = transformItems(items, null);
|
|
174
|
+
|
|
175
|
+
assert.strictEqual(transformed[0].id, 'existing-id');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('generates fallback id if no template and no existing id', async () => {
|
|
179
|
+
const { transformItems } = await import('../../service/poller.js');
|
|
180
|
+
|
|
181
|
+
const items = [
|
|
182
|
+
{ title: 'Issue 1' },
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const transformed = transformItems(items, null);
|
|
186
|
+
|
|
187
|
+
// Should have some id (even if auto-generated)
|
|
188
|
+
assert.ok(transformed[0].id);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('applyMappings', () => {
|
|
193
|
+
test('maps fields using simple dot notation', async () => {
|
|
194
|
+
const { applyMappings } = await import('../../service/poller.js');
|
|
195
|
+
|
|
196
|
+
const item = {
|
|
197
|
+
identifier: 'ODIN-123',
|
|
198
|
+
title: 'Fix the bug',
|
|
199
|
+
description: 'Details here'
|
|
200
|
+
};
|
|
201
|
+
const mappings = {
|
|
202
|
+
number: 'identifier',
|
|
203
|
+
title: 'title',
|
|
204
|
+
body: 'description'
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const mapped = applyMappings(item, mappings);
|
|
208
|
+
|
|
209
|
+
assert.strictEqual(mapped.number, 'ODIN-123');
|
|
210
|
+
assert.strictEqual(mapped.title, 'Fix the bug');
|
|
211
|
+
assert.strictEqual(mapped.body, 'Details here');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('maps nested fields', async () => {
|
|
215
|
+
const { applyMappings } = await import('../../service/poller.js');
|
|
216
|
+
|
|
217
|
+
const item = {
|
|
218
|
+
repository: { full_name: 'myorg/backend', name: 'backend' },
|
|
219
|
+
number: 42
|
|
220
|
+
};
|
|
221
|
+
const mappings = {
|
|
222
|
+
repo: 'repository.full_name',
|
|
223
|
+
number: 'number'
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const mapped = applyMappings(item, mappings);
|
|
227
|
+
|
|
228
|
+
assert.strictEqual(mapped.repo, 'myorg/backend');
|
|
229
|
+
assert.strictEqual(mapped.number, 42);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('preserves unmapped fields', async () => {
|
|
233
|
+
const { applyMappings } = await import('../../service/poller.js');
|
|
234
|
+
|
|
235
|
+
const item = {
|
|
236
|
+
identifier: 'ODIN-123',
|
|
237
|
+
title: 'Fix the bug',
|
|
238
|
+
url: 'https://linear.app/...'
|
|
239
|
+
};
|
|
240
|
+
const mappings = {
|
|
241
|
+
number: 'identifier'
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const mapped = applyMappings(item, mappings);
|
|
245
|
+
|
|
246
|
+
// Mapped field
|
|
247
|
+
assert.strictEqual(mapped.number, 'ODIN-123');
|
|
248
|
+
// Original fields preserved
|
|
249
|
+
assert.strictEqual(mapped.title, 'Fix the bug');
|
|
250
|
+
assert.strictEqual(mapped.url, 'https://linear.app/...');
|
|
251
|
+
assert.strictEqual(mapped.identifier, 'ODIN-123');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('handles missing source fields gracefully', async () => {
|
|
255
|
+
const { applyMappings } = await import('../../service/poller.js');
|
|
256
|
+
|
|
257
|
+
const item = {
|
|
258
|
+
title: 'Fix the bug'
|
|
259
|
+
};
|
|
260
|
+
const mappings = {
|
|
261
|
+
body: 'description' // description doesn't exist
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const mapped = applyMappings(item, mappings);
|
|
265
|
+
|
|
266
|
+
// Missing field should be undefined, not error
|
|
267
|
+
assert.strictEqual(mapped.body, undefined);
|
|
268
|
+
assert.strictEqual(mapped.title, 'Fix the bug');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('returns original item when no mappings', async () => {
|
|
272
|
+
const { applyMappings } = await import('../../service/poller.js');
|
|
273
|
+
|
|
274
|
+
const item = { title: 'Test', number: 1 };
|
|
275
|
+
|
|
276
|
+
const mapped = applyMappings(item, null);
|
|
277
|
+
|
|
278
|
+
assert.deepStrictEqual(mapped, item);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('extracts value using regex syntax', async () => {
|
|
282
|
+
const { applyMappings } = await import('../../service/poller.js');
|
|
283
|
+
|
|
284
|
+
const item = {
|
|
285
|
+
title: 'Fix the bug',
|
|
286
|
+
url: 'https://linear.app/0din/issue/0DIN-683/attack-technique-detection'
|
|
287
|
+
};
|
|
288
|
+
const mappings = {
|
|
289
|
+
number: 'url:/([A-Z0-9]+-[0-9]+)/' // Matches 0DIN-683
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const mapped = applyMappings(item, mappings);
|
|
293
|
+
|
|
294
|
+
assert.strictEqual(mapped.number, '0DIN-683');
|
|
295
|
+
assert.strictEqual(mapped.title, 'Fix the bug');
|
|
296
|
+
assert.strictEqual(mapped.url, 'https://linear.app/0din/issue/0DIN-683/attack-technique-detection');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('regex extraction returns undefined for no match', async () => {
|
|
300
|
+
const { applyMappings } = await import('../../service/poller.js');
|
|
301
|
+
|
|
302
|
+
const item = {
|
|
303
|
+
url: 'https://example.com/no-match'
|
|
304
|
+
};
|
|
305
|
+
const mappings = {
|
|
306
|
+
number: 'url:/([A-Z0-9]+-[0-9]+)/'
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const mapped = applyMappings(item, mappings);
|
|
310
|
+
|
|
311
|
+
assert.strictEqual(mapped.number, undefined);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('transformItems with mappings', () => {
|
|
316
|
+
test('applies mappings to all items', async () => {
|
|
317
|
+
const { transformItems, applyMappings } = await import('../../service/poller.js');
|
|
318
|
+
|
|
319
|
+
const items = [
|
|
320
|
+
{ identifier: 'PROJ-1', title: 'First', description: 'Desc 1' },
|
|
321
|
+
{ identifier: 'PROJ-2', title: 'Second', description: 'Desc 2' },
|
|
322
|
+
];
|
|
323
|
+
const mappings = {
|
|
324
|
+
number: 'identifier',
|
|
325
|
+
body: 'description'
|
|
326
|
+
};
|
|
327
|
+
const idTemplate = 'linear:{identifier}';
|
|
328
|
+
|
|
329
|
+
// First apply mappings, then transform
|
|
330
|
+
const mappedItems = items.map(item => applyMappings(item, mappings));
|
|
331
|
+
const transformed = transformItems(mappedItems, idTemplate);
|
|
332
|
+
|
|
333
|
+
// Should have mapped fields
|
|
334
|
+
assert.strictEqual(transformed[0].number, 'PROJ-1');
|
|
335
|
+
assert.strictEqual(transformed[0].body, 'Desc 1');
|
|
336
|
+
assert.strictEqual(transformed[0].id, 'linear:PROJ-1');
|
|
337
|
+
|
|
338
|
+
assert.strictEqual(transformed[1].number, 'PROJ-2');
|
|
339
|
+
assert.strictEqual(transformed[1].body, 'Desc 2');
|
|
340
|
+
assert.strictEqual(transformed[1].id, 'linear:PROJ-2');
|
|
341
|
+
|
|
342
|
+
// Original fields preserved
|
|
343
|
+
assert.strictEqual(transformed[0].identifier, 'PROJ-1');
|
|
344
|
+
assert.strictEqual(transformed[0].title, 'First');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
});
|