opencode-pilot 0.5.1 → 0.6.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/AGENTS.md CHANGED
@@ -36,7 +36,15 @@ gh release list -R athal7/opencode-pilot -L 1
36
36
  npm view opencode-pilot version
37
37
  ```
38
38
 
39
- ### 3. Restart Service
39
+ ### 3. Clear OpenCode Plugin Cache
40
+
41
+ OpenCode caches plugins at `~/.cache/opencode/node_modules/`. Clear the cache to pick up the new version:
42
+
43
+ ```bash
44
+ rm -rf ~/.cache/opencode/node_modules/opencode-pilot
45
+ ```
46
+
47
+ ### 4. Restart Service
40
48
 
41
49
  If the service is running, restart it:
42
50
 
@@ -45,7 +53,7 @@ If the service is running, restart it:
45
53
  npx opencode-pilot start
46
54
  ```
47
55
 
48
- ### 4. Verify Upgrade
56
+ ### 5. Verify Upgrade
49
57
 
50
58
  ```bash
51
59
  npx opencode-pilot status
@@ -361,30 +361,29 @@ async function configCommand() {
361
361
  // ============================================================================
362
362
 
363
363
  async function testSourceCommand(sourceName) {
364
+ // Load sources using getSources() to expand presets
365
+ const { loadRepoConfig, getSources } = await import(join(serviceDir, "repo-config.js"));
366
+ loadRepoConfig(PILOT_CONFIG_FILE);
367
+
368
+ let sources;
369
+ try {
370
+ sources = getSources();
371
+ } catch (err) {
372
+ console.error(`Failed to load sources: ${err.message}`);
373
+ process.exit(1);
374
+ }
375
+
364
376
  if (!sourceName) {
365
377
  console.error("Usage: opencode-pilot test-source <source-name>");
366
378
  console.error("");
367
379
  console.error("Available sources:");
368
380
 
369
- // Load and show available sources
370
- if (existsSync(PILOT_CONFIG_FILE)) {
371
- try {
372
- const { default: YAML } = await import("yaml");
373
- const content = readFileSync(PILOT_CONFIG_FILE, "utf8");
374
- const config = YAML.parse(content);
375
- const sources = config.sources || [];
376
- if (sources.length === 0) {
377
- console.error(" (no sources configured)");
378
- } else {
379
- for (const s of sources) {
380
- console.error(` ${s.name}`);
381
- }
382
- }
383
- } catch (err) {
384
- console.error(` Error loading config: ${err.message}`);
385
- }
381
+ if (sources.length === 0) {
382
+ console.error(" (no sources configured)");
386
383
  } else {
387
- console.error(" (config file not found)");
384
+ for (const s of sources) {
385
+ console.error(` ${s.name}`);
386
+ }
388
387
  }
389
388
  process.exit(1);
390
389
  }
@@ -393,18 +392,7 @@ async function testSourceCommand(sourceName) {
393
392
  console.log("=".repeat(40));
394
393
  console.log("");
395
394
 
396
- // Load config
397
- if (!existsSync(PILOT_CONFIG_FILE)) {
398
- console.error(`Config file not found: ${PILOT_CONFIG_FILE}`);
399
- process.exit(1);
400
- }
401
-
402
- const { default: YAML } = await import("yaml");
403
- const content = readFileSync(PILOT_CONFIG_FILE, "utf8");
404
- const config = YAML.parse(content);
405
-
406
395
  // Find source
407
- const sources = config.sources || [];
408
396
  const source = sources.find(s => s.name === sourceName);
409
397
 
410
398
  if (!source) {
@@ -429,19 +417,24 @@ async function testSourceCommand(sourceName) {
429
417
  if (source.prompt) console.log(` prompt: ${source.prompt}`);
430
418
  if (source.agent) console.log(` agent: ${source.agent}`);
431
419
 
432
- // Show mappings
433
- const tools = config.tools || {};
420
+ // Show mappings (using getToolProviderConfig which includes preset defaults)
421
+ const { getToolProviderConfig } = await import(join(serviceDir, "repo-config.js"));
434
422
  const provider = source.tool?.mcp;
435
- const mappings = tools[provider]?.mappings;
423
+ const toolProviderConfig = getToolProviderConfig(provider);
424
+ const mappings = toolProviderConfig?.mappings;
436
425
 
437
426
  console.log("");
438
427
  console.log("Field Mappings:");
439
- if (mappings) {
428
+ if (mappings && Object.keys(mappings).length > 0) {
440
429
  for (const [target, sourcePath] of Object.entries(mappings)) {
441
430
  console.log(` ${target} ← ${sourcePath}`);
442
431
  }
443
432
  } else {
444
- console.log(` (no mappings configured for provider '${provider}')`);
433
+ console.log(` (no mappings for provider '${provider}')`);
434
+ }
435
+
436
+ if (toolProviderConfig?.response_key) {
437
+ console.log(` response_key: ${toolProviderConfig.response_key}`);
445
438
  }
446
439
 
447
440
  // Fetch items
@@ -453,8 +446,8 @@ async function testSourceCommand(sourceName) {
453
446
  const pollerPath = join(serviceDir, "poller.js");
454
447
  const { pollGenericSource, applyMappings } = await import(pollerPath);
455
448
 
456
- // Fetch items with mappings applied
457
- const items = await pollGenericSource(source, { mappings });
449
+ // Fetch items with tool provider config (includes mappings and response_key)
450
+ const items = await pollGenericSource(source, { toolProviderConfig });
458
451
 
459
452
  console.log(`Found ${items.length} item(s)`);
460
453
  console.log("");
@@ -29,9 +29,12 @@ sources:
29
29
  - preset: github/review-requests
30
30
  prompt: review
31
31
 
32
- # My PRs that have change requests
32
+ # My PRs that have change requests (filter to specific repos)
33
33
  - preset: github/my-prs-feedback
34
34
  prompt: review-feedback
35
+ repos: # Optional: filter to specific repos
36
+ - myorg/backend
37
+ - myorg/frontend
35
38
 
36
39
  # Linear issues (requires teamId and assigneeId)
37
40
  # Find IDs via: opencode-pilot discover linear
@@ -82,6 +85,19 @@ sources:
82
85
  # title: name
83
86
  # body: notes
84
87
 
88
+ # =============================================================================
89
+ # REPOS - Map GitHub repos to local paths (OPTIONAL)
90
+ #
91
+ # GitHub presets automatically resolve the repo from each item's
92
+ # repository.full_name field. Add entries here to map repos to local paths.
93
+ # =============================================================================
94
+ # repos:
95
+ # myorg/backend:
96
+ # path: ~/code/backend
97
+ #
98
+ # myorg/frontend:
99
+ # path: ~/code/frontend
100
+
85
101
  # =============================================================================
86
102
  # Available presets (no tools config needed):
87
103
  # github/my-issues - Issues assigned to me
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -9,7 +9,7 @@
9
9
  * 5. Track processed items to avoid duplicates
10
10
  */
11
11
 
12
- import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig } from "./repo-config.js";
12
+ import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem } from "./repo-config.js";
13
13
  import { createPoller, pollGenericSource } from "./poller.js";
14
14
  import { evaluateReadiness, sortByPriority } from "./readiness.js";
15
15
  import { executeAction, buildCommand } from "./actions.js";
@@ -52,6 +52,25 @@ export function buildActionConfigFromSource(source, repoConfig) {
52
52
  };
53
53
  }
54
54
 
55
+ /**
56
+ * Build action config for a specific item, resolving repo from item fields
57
+ * Uses source.repo template (e.g., "{repository.full_name}") to look up repo config
58
+ * @param {object} source - Source configuration
59
+ * @param {object} item - Item from the source (contains repo info)
60
+ * @returns {object} Merged action config
61
+ */
62
+ export function buildActionConfigForItem(source, item) {
63
+ // Resolve repo key from item using source.repo template
64
+ const repoKeys = resolveRepoForItem(source, item);
65
+ const repoKey = repoKeys.length > 0 ? repoKeys[0] : null;
66
+
67
+ // Get repo config (returns empty object if repo not configured)
68
+ const repoConfig = repoKey ? getRepoConfig(repoKey) : {};
69
+
70
+ // Build config with repo config as base, source overrides on top
71
+ return buildActionConfigFromSource(source, repoConfig);
72
+ }
73
+
55
74
  // Global state
56
75
  let pollingInterval = null;
57
76
  let pollerInstance = null;
@@ -87,8 +106,6 @@ export async function pollOnce(options = {}) {
87
106
  // Process each source
88
107
  for (const source of sources) {
89
108
  const sourceName = source.name || 'unknown';
90
- const repoKey = source.name || 'default';
91
- const repoConfig = getRepoConfig(repoKey) || {};
92
109
 
93
110
  if (!hasToolConfig(source)) {
94
111
  console.error(`[poll] Source '${sourceName}' missing tool configuration (requires tool.mcp and tool.name)`);
@@ -112,21 +129,28 @@ export async function pollOnce(options = {}) {
112
129
  // Evaluate readiness and filter
113
130
  const readyItems = items
114
131
  .map((item) => {
132
+ // Resolve repo from item for per-item config
133
+ const repoKeys = resolveRepoForItem(source, item);
134
+ const repoKey = repoKeys.length > 0 ? repoKeys[0] : null;
135
+ const repoConfig = repoKey ? getRepoConfig(repoKey) : {};
136
+
115
137
  const readiness = evaluateReadiness(item, repoConfig);
116
138
  debug(`Item ${item.id}: ready=${readiness.ready}, reason=${readiness.reason || 'none'}`);
117
139
  return {
118
140
  ...item,
119
- repo_key: repoKey,
120
- repo_short: repoKey.split("/").pop(),
141
+ repo_key: repoKey || sourceName,
142
+ repo_short: repoKey ? repoKey.split("/").pop() : sourceName,
121
143
  _readiness: readiness,
144
+ _repoConfig: repoConfig,
122
145
  };
123
146
  })
124
147
  .filter((item) => item._readiness.ready);
125
148
 
126
149
  debug(`${readyItems.length} items ready out of ${items.length}`);
127
150
 
128
- // Sort by priority
129
- const sortedItems = sortByPriority(readyItems, repoConfig);
151
+ // Sort by priority (use first item's repo config or empty)
152
+ const sortConfig = readyItems.length > 0 ? readyItems[0]._repoConfig : {};
153
+ const sortedItems = sortByPriority(readyItems, sortConfig);
130
154
 
131
155
  // Process ready items
132
156
  debug(`Processing ${sortedItems.length} sorted items`);
@@ -138,8 +162,8 @@ export async function pollOnce(options = {}) {
138
162
  }
139
163
 
140
164
  debug(`Executing action for ${item.id}`);
141
- // Build action config from source (includes agent, model, prompt, working_dir)
142
- const actionConfig = buildActionConfigFromSource(source, repoConfig);
165
+ // Build action config from source and item (resolves repo from item fields)
166
+ const actionConfig = buildActionConfigForItem(source, item);
143
167
 
144
168
  // Execute or dry-run
145
169
  if (dryRun) {
@@ -15,6 +15,7 @@ my-issues:
15
15
  q: "is:issue assignee:@me state:open"
16
16
  item:
17
17
  id: "{html_url}"
18
+ repo: "{repository.full_name}"
18
19
 
19
20
  review-requests:
20
21
  name: review-requests
@@ -25,6 +26,7 @@ review-requests:
25
26
  q: "is:pr review-requested:@me state:open"
26
27
  item:
27
28
  id: "{html_url}"
29
+ repo: "{repository.full_name}"
28
30
 
29
31
  my-prs-feedback:
30
32
  name: my-prs-feedback
@@ -35,3 +37,4 @@ my-prs-feedback:
35
37
  q: "is:pr author:@me state:open review:changes_requested"
36
38
  item:
37
39
  id: "{html_url}"
40
+ repo: "{repository.full_name}"
@@ -234,20 +234,31 @@ export function getTemplate(templateName, templatesDir) {
234
234
  * @returns {Array<string>} Array of repo keys
235
235
  */
236
236
  export function resolveRepoForItem(source, item) {
237
- // Multi-repo: explicit repos array
238
- if (Array.isArray(source.repos)) {
239
- return source.repos;
240
- }
241
-
242
- // Single repo from field reference (e.g., "{repository.full_name}")
237
+ // Resolve repo from item using template (e.g., "{repository.full_name}")
238
+ let resolvedRepo = null;
243
239
  if (typeof source.repo === "string") {
244
240
  const resolved = expandTemplate(source.repo, item);
245
- // Only return if actually resolved (not still a template)
241
+ // Only use if actually resolved (not still a template)
246
242
  if (resolved && !resolved.includes("{")) {
247
- return [resolved];
243
+ resolvedRepo = resolved;
248
244
  }
249
245
  }
250
246
 
247
+ // If source.repos is an array, use it as an allowlist filter
248
+ if (Array.isArray(source.repos)) {
249
+ // If we resolved a repo from the item, check if it's in the allowlist
250
+ if (resolvedRepo) {
251
+ return source.repos.includes(resolvedRepo) ? [resolvedRepo] : [];
252
+ }
253
+ // No repo template - return empty (can't match without item context)
254
+ return [];
255
+ }
256
+
257
+ // No allowlist - return the resolved repo if we have one
258
+ if (resolvedRepo) {
259
+ return [resolvedRepo];
260
+ }
261
+
251
262
  // No repo configuration - repo-agnostic source
252
263
  return [];
253
264
  }
@@ -139,4 +139,142 @@ sources:
139
139
  });
140
140
 
141
141
  });
142
+
143
+ describe('per-item repo resolution', () => {
144
+ test('resolves repo config from item using source.repo template', async () => {
145
+ const config = `
146
+ repos:
147
+ myorg/backend:
148
+ path: ~/code/backend
149
+ prompt: worktree
150
+ session:
151
+ name: "issue-{number}"
152
+
153
+ sources:
154
+ - preset: github/my-issues
155
+ `;
156
+ writeFileSync(configPath, config);
157
+
158
+ const { loadRepoConfig, getSources, getRepoConfig, resolveRepoForItem } = await import('../../service/repo-config.js');
159
+ loadRepoConfig(configPath);
160
+
161
+ const source = getSources()[0];
162
+ const item = {
163
+ repository: { full_name: 'myorg/backend' },
164
+ number: 123,
165
+ html_url: 'https://github.com/myorg/backend/issues/123'
166
+ };
167
+
168
+ // Source should have repo field from preset
169
+ assert.strictEqual(source.repo, '{repository.full_name}');
170
+
171
+ // resolveRepoForItem should extract repo key from item
172
+ const repoKeys = resolveRepoForItem(source, item);
173
+ assert.deepStrictEqual(repoKeys, ['myorg/backend']);
174
+
175
+ // getRepoConfig should return the repo settings
176
+ const repoConfig = getRepoConfig(repoKeys[0]);
177
+ assert.strictEqual(repoConfig.path, '~/code/backend');
178
+ assert.strictEqual(repoConfig.prompt, 'worktree');
179
+ assert.deepStrictEqual(repoConfig.session, { name: 'issue-{number}' });
180
+ });
181
+
182
+ test('falls back gracefully when repo not in config', async () => {
183
+ const config = `
184
+ repos:
185
+ myorg/backend:
186
+ path: ~/code/backend
187
+
188
+ sources:
189
+ - preset: github/my-issues
190
+ `;
191
+ writeFileSync(configPath, config);
192
+
193
+ const { loadRepoConfig, getSources, getRepoConfig, resolveRepoForItem } = await import('../../service/repo-config.js');
194
+ loadRepoConfig(configPath);
195
+
196
+ const source = getSources()[0];
197
+ const item = {
198
+ repository: { full_name: 'unknown/repo' },
199
+ number: 456
200
+ };
201
+
202
+ // resolveRepoForItem should extract repo key from item
203
+ const repoKeys = resolveRepoForItem(source, item);
204
+ assert.deepStrictEqual(repoKeys, ['unknown/repo']);
205
+
206
+ // getRepoConfig should return empty object for unknown repo
207
+ const repoConfig = getRepoConfig(repoKeys[0]);
208
+ assert.deepStrictEqual(repoConfig, {});
209
+ });
210
+ });
211
+
212
+ describe('buildActionConfigForItem', () => {
213
+ test('uses repo config resolved from item', async () => {
214
+ const config = `
215
+ repos:
216
+ myorg/backend:
217
+ path: ~/code/backend
218
+ prompt: repo-prompt
219
+ session:
220
+ name: "issue-{number}"
221
+
222
+ sources:
223
+ - preset: github/my-issues
224
+ prompt: source-prompt
225
+ `;
226
+ writeFileSync(configPath, config);
227
+
228
+ const { loadRepoConfig } = await import('../../service/repo-config.js');
229
+ const { buildActionConfigForItem } = await import('../../service/poll-service.js');
230
+ loadRepoConfig(configPath);
231
+
232
+ const source = {
233
+ name: 'my-issues',
234
+ repo: '{repository.full_name}',
235
+ prompt: 'source-prompt'
236
+ };
237
+ const item = {
238
+ repository: { full_name: 'myorg/backend' },
239
+ number: 123
240
+ };
241
+
242
+ const actionConfig = buildActionConfigForItem(source, item);
243
+
244
+ // Should use repo path from repos config
245
+ assert.strictEqual(actionConfig.repo_path, '~/code/backend');
246
+ // Source prompt should override repo prompt
247
+ assert.strictEqual(actionConfig.prompt, 'source-prompt');
248
+ // Session should come from repo config
249
+ assert.deepStrictEqual(actionConfig.session, { name: 'issue-{number}' });
250
+ });
251
+
252
+ test('falls back to source working_dir when repo not configured', async () => {
253
+ const config = `
254
+ sources:
255
+ - preset: github/my-issues
256
+ working_dir: ~/default/path
257
+ `;
258
+ writeFileSync(configPath, config);
259
+
260
+ const { loadRepoConfig } = await import('../../service/repo-config.js');
261
+ const { buildActionConfigForItem } = await import('../../service/poll-service.js');
262
+ loadRepoConfig(configPath);
263
+
264
+ const source = {
265
+ name: 'my-issues',
266
+ repo: '{repository.full_name}',
267
+ working_dir: '~/default/path'
268
+ };
269
+ const item = {
270
+ repository: { full_name: 'unknown/repo' },
271
+ number: 456
272
+ };
273
+
274
+ const actionConfig = buildActionConfigForItem(source, item);
275
+
276
+ // Should use source working_dir since repo not in config
277
+ assert.strictEqual(actionConfig.working_dir, '~/default/path');
278
+ });
279
+ });
142
280
  });
@@ -423,7 +423,7 @@ sources:
423
423
  assert.deepStrictEqual(repos, ['myorg/backend']);
424
424
  });
425
425
 
426
- test('resolves multiple repos from repos array', async () => {
426
+ test('repos array without repo template returns empty (needs item context)', async () => {
427
427
  writeFileSync(configPath, `
428
428
  sources:
429
429
  - name: cross-repo
@@ -444,7 +444,8 @@ sources:
444
444
  const item = { number: 123 };
445
445
  const repos = resolveRepoForItem(source, item);
446
446
 
447
- assert.deepStrictEqual(repos, ['myorg/backend', 'myorg/frontend']);
447
+ // Without a repo template, can't resolve from item - returns empty
448
+ assert.deepStrictEqual(repos, []);
448
449
  });
449
450
 
450
451
  test('returns empty array when no repo config', async () => {
@@ -614,6 +615,50 @@ sources:
614
615
 
615
616
  assert.throws(() => getSources(), /Unknown preset: unknown\/preset/);
616
617
  });
618
+
619
+ test('github presets include repo field for automatic resolution', async () => {
620
+ writeFileSync(configPath, `
621
+ sources:
622
+ - preset: github/my-issues
623
+ - preset: github/review-requests
624
+ - preset: github/my-prs-feedback
625
+ `);
626
+
627
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
628
+ loadRepoConfig(configPath);
629
+ const sources = getSources();
630
+
631
+ // All GitHub presets should have repo field for automatic resolution
632
+ const mockItem = { repository: { full_name: 'myorg/backend' } };
633
+
634
+ for (const source of sources) {
635
+ assert.strictEqual(source.repo, '{repository.full_name}', `Preset ${source.name} should have repo field`);
636
+ const repos = resolveRepoForItem(source, mockItem);
637
+ assert.deepStrictEqual(repos, ['myorg/backend'], `Preset ${source.name} should resolve repo from item`);
638
+ }
639
+ });
640
+
641
+ test('source.repos acts as allowlist filter', async () => {
642
+ writeFileSync(configPath, `
643
+ sources:
644
+ - preset: github/my-issues
645
+ repos:
646
+ - myorg/backend
647
+ - myorg/frontend
648
+ `);
649
+
650
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
651
+ loadRepoConfig(configPath);
652
+ const source = getSources()[0];
653
+
654
+ // Item from allowed repo should resolve
655
+ const allowedItem = { repository: { full_name: 'myorg/backend' } };
656
+ assert.deepStrictEqual(resolveRepoForItem(source, allowedItem), ['myorg/backend']);
657
+
658
+ // Item from non-allowed repo should return empty (filtered out)
659
+ const filteredItem = { repository: { full_name: 'other/repo' } };
660
+ assert.deepStrictEqual(resolveRepoForItem(source, filteredItem), []);
661
+ });
617
662
  });
618
663
 
619
664
  describe('shorthand syntax', () => {