opencode-pilot 0.5.2 → 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 +10 -2
- package/examples/config.yaml +17 -1
- package/package.json +1 -1
- package/service/poll-service.js +33 -9
- package/service/presets/github.yaml +3 -0
- package/service/repo-config.js +19 -8
- package/test/unit/poll-service.test.js +138 -0
- package/test/unit/repo-config.test.js +47 -2
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.
|
|
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
|
-
###
|
|
56
|
+
### 5. Verify Upgrade
|
|
49
57
|
|
|
50
58
|
```bash
|
|
51
59
|
npx opencode-pilot status
|
package/examples/config.yaml
CHANGED
|
@@ -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
package/service/poll-service.js
CHANGED
|
@@ -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
|
|
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 (
|
|
142
|
-
const actionConfig =
|
|
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}"
|
package/service/repo-config.js
CHANGED
|
@@ -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
|
-
//
|
|
238
|
-
|
|
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
|
|
241
|
+
// Only use if actually resolved (not still a template)
|
|
246
242
|
if (resolved && !resolved.includes("{")) {
|
|
247
|
-
|
|
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('
|
|
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
|
-
|
|
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', () => {
|