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,441 @@
1
+ /**
2
+ * Tests for repo-config.js - configuration for repos, sources, tools, templates
3
+ */
4
+
5
+ import { test, describe, beforeEach, afterEach } 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('repo-config.js', () => {
12
+ let tempDir;
13
+ let configPath;
14
+ let templatesDir;
15
+
16
+ beforeEach(async () => {
17
+ tempDir = mkdtempSync(join(tmpdir(), 'opencode-pilot-test-'));
18
+ configPath = join(tempDir, 'config.yaml');
19
+ templatesDir = join(tempDir, 'templates');
20
+ mkdirSync(templatesDir);
21
+
22
+ // Clear module cache to get fresh config each test
23
+ const { clearConfigCache } = await import('../../service/repo-config.js');
24
+ clearConfigCache();
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(tempDir, { recursive: true, force: true });
29
+ });
30
+
31
+ describe('error handling', () => {
32
+ test('handles malformed YAML gracefully', async () => {
33
+ // Write invalid YAML (unbalanced quotes)
34
+ writeFileSync(configPath, `
35
+ repos:
36
+ myorg/backend:
37
+ path: "~/code/backend
38
+ `);
39
+
40
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
41
+
42
+ // Should not throw, should return empty
43
+ loadRepoConfig(configPath);
44
+ const sources = getSources();
45
+
46
+ assert.deepStrictEqual(sources, []);
47
+ });
48
+
49
+ test('handles empty file gracefully', async () => {
50
+ writeFileSync(configPath, '');
51
+
52
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
53
+ loadRepoConfig(configPath);
54
+ const sources = getSources();
55
+
56
+ assert.deepStrictEqual(sources, []);
57
+ });
58
+ });
59
+
60
+ describe('repos', () => {
61
+ test('gets repo config with path', async () => {
62
+ writeFileSync(configPath, `
63
+ repos:
64
+ myorg/backend:
65
+ path: ~/code/backend
66
+ `);
67
+
68
+ const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
69
+ loadRepoConfig(configPath);
70
+ const config = getRepoConfig('myorg/backend');
71
+
72
+ assert.strictEqual(config.path, '~/code/backend');
73
+ assert.strictEqual(config.repo_path, '~/code/backend');
74
+ });
75
+
76
+ test('returns empty object for unknown repo', async () => {
77
+ writeFileSync(configPath, `
78
+ repos:
79
+ myorg/backend:
80
+ path: ~/code/backend
81
+ `);
82
+
83
+ const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
84
+ loadRepoConfig(configPath);
85
+ const config = getRepoConfig('unknown/repo');
86
+
87
+ assert.deepStrictEqual(config, {});
88
+ });
89
+
90
+ test('supports YAML anchors for shared config', async () => {
91
+ writeFileSync(configPath, `
92
+ repos:
93
+ myorg/backend: &default-repo
94
+ path: ~/code/backend
95
+ prompt: devcontainer
96
+ session:
97
+ name: "issue-{number}"
98
+
99
+ myorg/frontend:
100
+ <<: *default-repo
101
+ path: ~/code/frontend
102
+ `);
103
+
104
+ const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
105
+ loadRepoConfig(configPath);
106
+
107
+ const backend = getRepoConfig('myorg/backend');
108
+ const frontend = getRepoConfig('myorg/frontend');
109
+
110
+ // Backend has its values
111
+ assert.strictEqual(backend.path, '~/code/backend');
112
+ assert.strictEqual(backend.prompt, 'devcontainer');
113
+ assert.deepStrictEqual(backend.session, { name: 'issue-{number}' });
114
+
115
+ // Frontend inherits from anchor, overrides path
116
+ assert.strictEqual(frontend.path, '~/code/frontend');
117
+ assert.strictEqual(frontend.prompt, 'devcontainer');
118
+ assert.deepStrictEqual(frontend.session, { name: 'issue-{number}' });
119
+ });
120
+ });
121
+
122
+ describe('sources', () => {
123
+ test('returns sources array from top-level', async () => {
124
+ writeFileSync(configPath, `
125
+ sources:
126
+ - name: my-issues
127
+ tool:
128
+ mcp: github
129
+ name: github_search_issues
130
+ args:
131
+ q: "is:issue assignee:@me"
132
+ item:
133
+ id: "github:{repository.full_name}#{number}"
134
+ repo: "{repository.full_name}"
135
+ `);
136
+
137
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
138
+ loadRepoConfig(configPath);
139
+ const sources = getSources();
140
+
141
+ assert.strictEqual(sources.length, 1);
142
+ assert.strictEqual(sources[0].name, 'my-issues');
143
+ assert.deepStrictEqual(sources[0].tool, { mcp: 'github', name: 'github_search_issues' });
144
+ assert.strictEqual(sources[0].args.q, 'is:issue assignee:@me');
145
+ });
146
+
147
+ test('returns empty array when no sources', async () => {
148
+ writeFileSync(configPath, `
149
+ repos:
150
+ myorg/backend:
151
+ path: ~/code/backend
152
+ `);
153
+
154
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
155
+ loadRepoConfig(configPath);
156
+ const sources = getSources();
157
+
158
+ assert.deepStrictEqual(sources, []);
159
+ });
160
+
161
+ test('source can reference workflow settings', async () => {
162
+ writeFileSync(configPath, `
163
+ sources:
164
+ - name: reviews
165
+ tool:
166
+ mcp: github
167
+ name: github_search_issues
168
+ args:
169
+ q: "is:pr review-requested:@me"
170
+ item:
171
+ id: "github:{repository.full_name}#{number}"
172
+ repo: "{repository.full_name}"
173
+ prompt: review
174
+ agent: reviewer
175
+ `);
176
+
177
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
178
+ loadRepoConfig(configPath);
179
+ const sources = getSources();
180
+
181
+ assert.strictEqual(sources[0].prompt, 'review');
182
+ assert.strictEqual(sources[0].agent, 'reviewer');
183
+ });
184
+
185
+ test('source can specify multiple repos with working_dir', async () => {
186
+ writeFileSync(configPath, `
187
+ sources:
188
+ - name: cross-repo
189
+ tool:
190
+ mcp: github
191
+ name: github_search_issues
192
+ args:
193
+ q: "is:issue label:multi-repo"
194
+ item:
195
+ id: "github:{number}"
196
+ repos:
197
+ - myorg/backend
198
+ - myorg/frontend
199
+ working_dir: ~/code
200
+ `);
201
+
202
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
203
+ loadRepoConfig(configPath);
204
+ const sources = getSources();
205
+
206
+ assert.deepStrictEqual(sources[0].repos, ['myorg/backend', 'myorg/frontend']);
207
+ assert.strictEqual(sources[0].working_dir, '~/code');
208
+ });
209
+
210
+ test('supports YAML anchors for shared source config', async () => {
211
+ writeFileSync(configPath, `
212
+ sources:
213
+ - name: my-issues
214
+ tool: &github-tool
215
+ mcp: github
216
+ name: github_search_issues
217
+ args:
218
+ q: "is:issue assignee:@me"
219
+ item: &github-item
220
+ id: "github:{repository.full_name}#{number}"
221
+ repo: "{repository.full_name}"
222
+
223
+ - name: review-requests
224
+ tool: *github-tool
225
+ args:
226
+ q: "is:pr review-requested:@me"
227
+ item: *github-item
228
+ repo: "{repository.full_name}"
229
+ prompt: review
230
+ `);
231
+
232
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
233
+ loadRepoConfig(configPath);
234
+ const sources = getSources();
235
+
236
+ assert.strictEqual(sources.length, 2);
237
+ // Both use same tool config via anchor
238
+ assert.deepStrictEqual(sources[0].tool, sources[1].tool);
239
+ assert.deepStrictEqual(sources[0].item, sources[1].item);
240
+ // But have different args and prompts
241
+ assert.notStrictEqual(sources[0].args.q, sources[1].args.q);
242
+ assert.strictEqual(sources[1].prompt, 'review');
243
+ });
244
+ });
245
+
246
+ describe('templates', () => {
247
+ test('loads template from templates directory', async () => {
248
+ writeFileSync(join(templatesDir, 'default.md'), '{title}\n\n{body}');
249
+
250
+ const { getTemplate } = await import('../../service/repo-config.js');
251
+ const template = getTemplate('default', templatesDir);
252
+
253
+ assert.strictEqual(template, '{title}\n\n{body}');
254
+ });
255
+
256
+ test('returns null for missing template', async () => {
257
+ const { getTemplate } = await import('../../service/repo-config.js');
258
+ const template = getTemplate('nonexistent', templatesDir);
259
+
260
+ assert.strictEqual(template, null);
261
+ });
262
+ });
263
+
264
+ describe('tools mappings', () => {
265
+ test('returns mappings for a tool provider', async () => {
266
+ writeFileSync(configPath, `
267
+ tools:
268
+ linear:
269
+ mappings:
270
+ number: identifier
271
+ body: description
272
+
273
+ sources: []
274
+ `);
275
+
276
+ const { loadRepoConfig, getToolMappings } = await import('../../service/repo-config.js');
277
+ loadRepoConfig(configPath);
278
+
279
+ const mappings = getToolMappings('linear');
280
+
281
+ assert.deepStrictEqual(mappings, {
282
+ number: 'identifier',
283
+ body: 'description'
284
+ });
285
+ });
286
+
287
+ test('returns null for unknown provider', async () => {
288
+ writeFileSync(configPath, `
289
+ tools:
290
+ github:
291
+ mappings:
292
+ url: html_url
293
+
294
+ sources: []
295
+ `);
296
+
297
+ const { loadRepoConfig, getToolMappings } = await import('../../service/repo-config.js');
298
+ loadRepoConfig(configPath);
299
+
300
+ const mappings = getToolMappings('unknown');
301
+
302
+ assert.strictEqual(mappings, null);
303
+ });
304
+
305
+ test('returns null when no tools section', async () => {
306
+ writeFileSync(configPath, `
307
+ sources: []
308
+ `);
309
+
310
+ const { loadRepoConfig, getToolMappings } = await import('../../service/repo-config.js');
311
+ loadRepoConfig(configPath);
312
+
313
+ const mappings = getToolMappings('github');
314
+
315
+ assert.strictEqual(mappings, null);
316
+ });
317
+ });
318
+
319
+ describe('repo resolution for sources', () => {
320
+ test('resolves repo from simple field reference', async () => {
321
+ writeFileSync(configPath, `
322
+ repos:
323
+ myorg/backend:
324
+ path: ~/code/backend
325
+
326
+ sources:
327
+ - name: my-issues
328
+ tool:
329
+ mcp: github
330
+ name: github_search_issues
331
+ args:
332
+ q: "is:issue"
333
+ item:
334
+ id: "github:{number}"
335
+ repo: "{repository.full_name}"
336
+ `);
337
+
338
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
339
+ loadRepoConfig(configPath);
340
+
341
+ const source = getSources()[0];
342
+ const item = { repository: { full_name: 'myorg/backend' }, number: 123 };
343
+ const repos = resolveRepoForItem(source, item);
344
+
345
+ assert.deepStrictEqual(repos, ['myorg/backend']);
346
+ });
347
+
348
+ test('resolves multiple repos from repos array', async () => {
349
+ writeFileSync(configPath, `
350
+ sources:
351
+ - name: cross-repo
352
+ tool:
353
+ mcp: github
354
+ name: github_search_issues
355
+ item:
356
+ id: "github:{number}"
357
+ repos:
358
+ - myorg/backend
359
+ - myorg/frontend
360
+ `);
361
+
362
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
363
+ loadRepoConfig(configPath);
364
+
365
+ const source = getSources()[0];
366
+ const item = { number: 123 };
367
+ const repos = resolveRepoForItem(source, item);
368
+
369
+ assert.deepStrictEqual(repos, ['myorg/backend', 'myorg/frontend']);
370
+ });
371
+
372
+ test('returns empty array when no repo config', async () => {
373
+ writeFileSync(configPath, `
374
+ sources:
375
+ - name: personal-todos
376
+ tool:
377
+ mcp: reminders
378
+ name: list_reminders
379
+ item:
380
+ id: "reminder:{id}"
381
+ `);
382
+
383
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
384
+ loadRepoConfig(configPath);
385
+
386
+ const source = getSources()[0];
387
+ const item = { id: 'abc123' };
388
+ const repos = resolveRepoForItem(source, item);
389
+
390
+ assert.deepStrictEqual(repos, []);
391
+ });
392
+ });
393
+
394
+ describe('listRepos', () => {
395
+ test('lists all configured repos', async () => {
396
+ writeFileSync(configPath, `
397
+ repos:
398
+ myorg/backend:
399
+ path: ~/code/backend
400
+ myorg/frontend:
401
+ path: ~/code/frontend
402
+ `);
403
+
404
+ const { loadRepoConfig, listRepos } = await import('../../service/repo-config.js');
405
+ loadRepoConfig(configPath);
406
+ const repos = listRepos();
407
+
408
+ assert.deepStrictEqual(repos, ['myorg/backend', 'myorg/frontend']);
409
+ });
410
+ });
411
+
412
+ describe('findRepoByPath', () => {
413
+ test('finds repo by path', async () => {
414
+ writeFileSync(configPath, `
415
+ repos:
416
+ myorg/backend:
417
+ path: ~/code/backend
418
+ `);
419
+
420
+ const { loadRepoConfig, findRepoByPath } = await import('../../service/repo-config.js');
421
+ loadRepoConfig(configPath);
422
+ const repoKey = findRepoByPath('~/code/backend');
423
+
424
+ assert.strictEqual(repoKey, 'myorg/backend');
425
+ });
426
+
427
+ test('returns null for unknown path', async () => {
428
+ writeFileSync(configPath, `
429
+ repos:
430
+ myorg/backend:
431
+ path: ~/code/backend
432
+ `);
433
+
434
+ const { loadRepoConfig, findRepoByPath } = await import('../../service/repo-config.js');
435
+ loadRepoConfig(configPath);
436
+ const repoKey = findRepoByPath('~/code/unknown');
437
+
438
+ assert.strictEqual(repoKey, null);
439
+ });
440
+ });
441
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Tests for utils.js - Shared utility functions
3
+ */
4
+
5
+ import { test, describe } from 'node:test';
6
+ import assert from 'node:assert';
7
+
8
+ describe('utils.js', () => {
9
+ describe('getNestedValue', () => {
10
+ test('gets top-level value', async () => {
11
+ const { getNestedValue } = await import('../../service/utils.js');
12
+
13
+ const obj = { name: 'Test', count: 42 };
14
+
15
+ assert.strictEqual(getNestedValue(obj, 'name'), 'Test');
16
+ assert.strictEqual(getNestedValue(obj, 'count'), 42);
17
+ });
18
+
19
+ test('gets nested value with dot notation', async () => {
20
+ const { getNestedValue } = await import('../../service/utils.js');
21
+
22
+ const obj = {
23
+ repository: {
24
+ full_name: 'myorg/backend',
25
+ owner: { login: 'myorg' }
26
+ }
27
+ };
28
+
29
+ assert.strictEqual(getNestedValue(obj, 'repository.full_name'), 'myorg/backend');
30
+ assert.strictEqual(getNestedValue(obj, 'repository.owner.login'), 'myorg');
31
+ });
32
+
33
+ test('returns undefined for missing path', async () => {
34
+ const { getNestedValue } = await import('../../service/utils.js');
35
+
36
+ const obj = { name: 'Test' };
37
+
38
+ assert.strictEqual(getNestedValue(obj, 'missing'), undefined);
39
+ assert.strictEqual(getNestedValue(obj, 'deep.missing.path'), undefined);
40
+ });
41
+
42
+ test('handles null/undefined in path', async () => {
43
+ const { getNestedValue } = await import('../../service/utils.js');
44
+
45
+ const obj = { name: null, empty: { inner: undefined } };
46
+
47
+ assert.strictEqual(getNestedValue(obj, 'name'), null);
48
+ assert.strictEqual(getNestedValue(obj, 'name.anything'), undefined);
49
+ assert.strictEqual(getNestedValue(obj, 'empty.inner'), undefined);
50
+ assert.strictEqual(getNestedValue(obj, 'empty.inner.deep'), undefined);
51
+ });
52
+ });
53
+ });