opencode-pilot 0.5.2 → 0.6.1

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
@@ -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.2",
3
+ "version": "0.6.1",
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) {
@@ -161,7 +185,7 @@ export async function pollOnce(options = {}) {
161
185
  if (result.success) {
162
186
  // Mark as processed to avoid re-triggering
163
187
  if (pollerInstance) {
164
- pollerInstance.markProcessed(item.id, { repoKey, command: result.command });
188
+ pollerInstance.markProcessed(item.id, { repoKey: item.repo_key, command: result.command });
165
189
  }
166
190
  console.log(`[poll] Started session for ${item.id}`);
167
191
  } else {
@@ -3,7 +3,9 @@
3
3
  # Provider-level config (applies to all GitHub presets)
4
4
  _provider:
5
5
  response_key: items
6
- mappings: {}
6
+ mappings:
7
+ # Extract repo full name from repository_url (e.g., "https://api.github.com/repos/owner/repo")
8
+ repository_full_name: "repository_url:/repos\\/([^/]+\\/[^/]+)$/"
7
9
 
8
10
  # Presets
9
11
  my-issues:
@@ -15,6 +17,7 @@ my-issues:
15
17
  q: "is:issue assignee:@me state:open"
16
18
  item:
17
19
  id: "{html_url}"
20
+ repo: "{repository_full_name}"
18
21
 
19
22
  review-requests:
20
23
  name: review-requests
@@ -25,6 +28,7 @@ review-requests:
25
28
  q: "is:pr review-requested:@me state:open"
26
29
  item:
27
30
  id: "{html_url}"
31
+ repo: "{repository_full_name}"
28
32
 
29
33
  my-prs-feedback:
30
34
  name: my-prs-feedback
@@ -35,3 +39,4 @@ my-prs-feedback:
35
39
  q: "is:pr author:@me state:open review:changes_requested"
36
40
  item:
37
41
  id: "{html_url}"
42
+ 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,144 @@ 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
+ // Item with repository_full_name (mapped from repository_url by GitHub provider)
163
+ const item = {
164
+ repository_full_name: 'myorg/backend',
165
+ number: 123,
166
+ html_url: 'https://github.com/myorg/backend/issues/123'
167
+ };
168
+
169
+ // Source should have repo field from preset (uses mapped field)
170
+ assert.strictEqual(source.repo, '{repository_full_name}');
171
+
172
+ // resolveRepoForItem should extract repo key from item
173
+ const repoKeys = resolveRepoForItem(source, item);
174
+ assert.deepStrictEqual(repoKeys, ['myorg/backend']);
175
+
176
+ // getRepoConfig should return the repo settings
177
+ const repoConfig = getRepoConfig(repoKeys[0]);
178
+ assert.strictEqual(repoConfig.path, '~/code/backend');
179
+ assert.strictEqual(repoConfig.prompt, 'worktree');
180
+ assert.deepStrictEqual(repoConfig.session, { name: 'issue-{number}' });
181
+ });
182
+
183
+ test('falls back gracefully when repo not in config', async () => {
184
+ const config = `
185
+ repos:
186
+ myorg/backend:
187
+ path: ~/code/backend
188
+
189
+ sources:
190
+ - preset: github/my-issues
191
+ `;
192
+ writeFileSync(configPath, config);
193
+
194
+ const { loadRepoConfig, getSources, getRepoConfig, resolveRepoForItem } = await import('../../service/repo-config.js');
195
+ loadRepoConfig(configPath);
196
+
197
+ const source = getSources()[0];
198
+ // Item with repository_full_name (mapped from repository_url by GitHub provider)
199
+ const item = {
200
+ repository_full_name: 'unknown/repo',
201
+ number: 456
202
+ };
203
+
204
+ // resolveRepoForItem should extract repo key from item
205
+ const repoKeys = resolveRepoForItem(source, item);
206
+ assert.deepStrictEqual(repoKeys, ['unknown/repo']);
207
+
208
+ // getRepoConfig should return empty object for unknown repo
209
+ const repoConfig = getRepoConfig(repoKeys[0]);
210
+ assert.deepStrictEqual(repoConfig, {});
211
+ });
212
+ });
213
+
214
+ describe('buildActionConfigForItem', () => {
215
+ test('uses repo config resolved from item', async () => {
216
+ const config = `
217
+ repos:
218
+ myorg/backend:
219
+ path: ~/code/backend
220
+ prompt: repo-prompt
221
+ session:
222
+ name: "issue-{number}"
223
+
224
+ sources:
225
+ - preset: github/my-issues
226
+ prompt: source-prompt
227
+ `;
228
+ writeFileSync(configPath, config);
229
+
230
+ const { loadRepoConfig } = await import('../../service/repo-config.js');
231
+ const { buildActionConfigForItem } = await import('../../service/poll-service.js');
232
+ loadRepoConfig(configPath);
233
+
234
+ const source = {
235
+ name: 'my-issues',
236
+ repo: '{repository.full_name}',
237
+ prompt: 'source-prompt'
238
+ };
239
+ const item = {
240
+ repository: { full_name: 'myorg/backend' },
241
+ number: 123
242
+ };
243
+
244
+ const actionConfig = buildActionConfigForItem(source, item);
245
+
246
+ // Should use repo path from repos config
247
+ assert.strictEqual(actionConfig.repo_path, '~/code/backend');
248
+ // Source prompt should override repo prompt
249
+ assert.strictEqual(actionConfig.prompt, 'source-prompt');
250
+ // Session should come from repo config
251
+ assert.deepStrictEqual(actionConfig.session, { name: 'issue-{number}' });
252
+ });
253
+
254
+ test('falls back to source working_dir when repo not configured', async () => {
255
+ const config = `
256
+ sources:
257
+ - preset: github/my-issues
258
+ working_dir: ~/default/path
259
+ `;
260
+ writeFileSync(configPath, config);
261
+
262
+ const { loadRepoConfig } = await import('../../service/repo-config.js');
263
+ const { buildActionConfigForItem } = await import('../../service/poll-service.js');
264
+ loadRepoConfig(configPath);
265
+
266
+ const source = {
267
+ name: 'my-issues',
268
+ repo: '{repository.full_name}',
269
+ working_dir: '~/default/path'
270
+ };
271
+ const item = {
272
+ repository: { full_name: 'unknown/repo' },
273
+ number: 456
274
+ };
275
+
276
+ const actionConfig = buildActionConfigForItem(source, item);
277
+
278
+ // Should use source working_dir since repo not in config
279
+ assert.strictEqual(actionConfig.working_dir, '~/default/path');
280
+ });
281
+ });
142
282
  });
@@ -352,7 +352,9 @@ sources: []
352
352
 
353
353
  // GitHub preset has response_key: items, user config doesn't override it
354
354
  assert.strictEqual(toolConfig.response_key, 'items');
355
- assert.deepStrictEqual(toolConfig.mappings, { url: 'html_url' });
355
+ // GitHub provider has mapping for repository_full_name extraction from URL
356
+ assert.strictEqual(toolConfig.mappings.url, 'html_url');
357
+ assert.ok(toolConfig.mappings.repository_full_name, 'Should have repository_full_name mapping');
356
358
  });
357
359
 
358
360
  test('getToolProviderConfig falls back to preset provider config', async () => {
@@ -423,7 +425,7 @@ sources:
423
425
  assert.deepStrictEqual(repos, ['myorg/backend']);
424
426
  });
425
427
 
426
- test('resolves multiple repos from repos array', async () => {
428
+ test('repos array without repo template returns empty (needs item context)', async () => {
427
429
  writeFileSync(configPath, `
428
430
  sources:
429
431
  - name: cross-repo
@@ -444,7 +446,8 @@ sources:
444
446
  const item = { number: 123 };
445
447
  const repos = resolveRepoForItem(source, item);
446
448
 
447
- assert.deepStrictEqual(repos, ['myorg/backend', 'myorg/frontend']);
449
+ // Without a repo template, can't resolve from item - returns empty
450
+ assert.deepStrictEqual(repos, []);
448
451
  });
449
452
 
450
453
  test('returns empty array when no repo config', async () => {
@@ -614,6 +617,51 @@ sources:
614
617
 
615
618
  assert.throws(() => getSources(), /Unknown preset: unknown\/preset/);
616
619
  });
620
+
621
+ test('github presets include repo field for automatic resolution', async () => {
622
+ writeFileSync(configPath, `
623
+ sources:
624
+ - preset: github/my-issues
625
+ - preset: github/review-requests
626
+ - preset: github/my-prs-feedback
627
+ `);
628
+
629
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
630
+ loadRepoConfig(configPath);
631
+ const sources = getSources();
632
+
633
+ // All GitHub presets should have repo field that references repository_full_name
634
+ // (which is mapped from repository_url by the GitHub provider)
635
+ const mockItem = { repository_full_name: 'myorg/backend' };
636
+
637
+ for (const source of sources) {
638
+ assert.strictEqual(source.repo, '{repository_full_name}', `Preset ${source.name} should have repo field`);
639
+ const repos = resolveRepoForItem(source, mockItem);
640
+ assert.deepStrictEqual(repos, ['myorg/backend'], `Preset ${source.name} should resolve repo from item`);
641
+ }
642
+ });
643
+
644
+ test('source.repos acts as allowlist filter', async () => {
645
+ writeFileSync(configPath, `
646
+ sources:
647
+ - preset: github/my-issues
648
+ repos:
649
+ - myorg/backend
650
+ - myorg/frontend
651
+ `);
652
+
653
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
654
+ loadRepoConfig(configPath);
655
+ const source = getSources()[0];
656
+
657
+ // Item from allowed repo should resolve (repository_full_name is mapped from repository_url)
658
+ const allowedItem = { repository_full_name: 'myorg/backend' };
659
+ assert.deepStrictEqual(resolveRepoForItem(source, allowedItem), ['myorg/backend']);
660
+
661
+ // Item from non-allowed repo should return empty (filtered out)
662
+ const filteredItem = { repository_full_name: 'other/repo' };
663
+ assert.deepStrictEqual(resolveRepoForItem(source, filteredItem), []);
664
+ });
617
665
  });
618
666
 
619
667
  describe('shorthand syntax', () => {