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,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
+ });