opencode-pilot 0.16.6 → 0.17.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/README.md +3 -1
- package/examples/config.yaml +12 -3
- package/package.json +1 -1
- package/service/actions.js +24 -122
- package/service/poll-service.js +29 -7
- package/service/presets/github.yaml +2 -0
- package/service/repo-config.js +28 -13
- package/service/server.js +14 -0
- package/test/unit/actions.test.js +54 -117
- package/test/unit/repo-config.test.js +23 -0
package/README.md
CHANGED
|
@@ -46,10 +46,12 @@ See [examples/config.yaml](examples/config.yaml) for a complete example with all
|
|
|
46
46
|
### Key Sections
|
|
47
47
|
|
|
48
48
|
- **`server_port`** - Preferred OpenCode server port (e.g., `4096`). When multiple OpenCode instances are running, pilot attaches sessions to this port.
|
|
49
|
+
- **`startup_delay`** - Milliseconds to wait before first poll (default: `10000`). Allows OpenCode server time to fully initialize after restart.
|
|
50
|
+
- **`repos_dir`** - Directory containing git repos (e.g., `~/code`). Pilot auto-discovers repos by scanning git remotes (both `origin` and `upstream` for fork support).
|
|
49
51
|
- **`defaults`** - Default values applied to all sources
|
|
50
52
|
- **`sources`** - What to poll (presets, shorthand, or full config)
|
|
51
53
|
- **`tools`** - Field mappings to normalize different MCP APIs
|
|
52
|
-
- **`repos`** -
|
|
54
|
+
- **`repos`** - Explicit repository paths (overrides auto-discovery from `repos_dir`)
|
|
53
55
|
|
|
54
56
|
### Source Syntax
|
|
55
57
|
|
package/examples/config.yaml
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
# to this port. If not set, pilot discovers servers automatically.
|
|
7
7
|
# server_port: 4096
|
|
8
8
|
|
|
9
|
+
# Startup delay in milliseconds before first poll (default: 10000)
|
|
10
|
+
# Allows OpenCode server time to fully initialize after restart.
|
|
11
|
+
# Set to 0 for immediate polling.
|
|
12
|
+
# startup_delay: 10000
|
|
13
|
+
|
|
14
|
+
# Directory containing your git repos - enables auto-discovery
|
|
15
|
+
# Pilot scans for repos by checking git remotes (origin and upstream)
|
|
16
|
+
# This means PRs from upstream forks will match your local clones
|
|
17
|
+
repos_dir: ~/code
|
|
18
|
+
|
|
9
19
|
defaults:
|
|
10
20
|
agent: plan
|
|
11
21
|
prompt: default
|
|
@@ -16,10 +26,8 @@ sources:
|
|
|
16
26
|
prompt: worktree
|
|
17
27
|
|
|
18
28
|
- preset: github/review-requests
|
|
19
|
-
prompt: review
|
|
20
29
|
|
|
21
30
|
- preset: github/my-prs-feedback
|
|
22
|
-
prompt: review-feedback
|
|
23
31
|
repos:
|
|
24
32
|
- myorg/backend
|
|
25
33
|
- myorg/frontend
|
|
@@ -68,7 +76,8 @@ sources:
|
|
|
68
76
|
# title: name
|
|
69
77
|
# body: notes
|
|
70
78
|
|
|
71
|
-
#
|
|
79
|
+
# Explicit repo mappings (overrides repos_dir auto-discovery)
|
|
80
|
+
# Only needed if a repo isn't in repos_dir or needs custom settings
|
|
72
81
|
# repos:
|
|
73
82
|
# myorg/backend:
|
|
74
83
|
# path: ~/code/backend
|
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -290,131 +290,20 @@ export function getActionConfig(source, repoConfig, defaults) {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
|
-
*
|
|
293
|
+
* Build a display string for dry-run logging
|
|
294
|
+
* Shows what API call would be made
|
|
294
295
|
* @param {object} item - Item to create session for
|
|
295
|
-
* @param {object} config -
|
|
296
|
-
* @
|
|
297
|
-
* @param {string} [serverUrl] - URL of running opencode server to attach to
|
|
298
|
-
* @returns {object} { args: string[], cwd: string }
|
|
296
|
+
* @param {object} config - Repo config with action settings
|
|
297
|
+
* @returns {string} Display string for logging
|
|
299
298
|
*/
|
|
300
|
-
export function
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
const cwd = expandPath(workingDir);
|
|
304
|
-
|
|
305
|
-
// Build session name
|
|
299
|
+
export function buildCommand(item, config) {
|
|
300
|
+
const workingDir = config.working_dir || config.path || config.repo_path;
|
|
301
|
+
const cwd = workingDir ? expandPath(workingDir) : '(no path)';
|
|
306
302
|
const sessionName = config.session?.name
|
|
307
303
|
? buildSessionName(config.session.name, item)
|
|
308
304
|
: (item.title || `session-${Date.now()}`);
|
|
309
|
-
|
|
310
|
-
// Build command args
|
|
311
|
-
const args = ["opencode", "run"];
|
|
312
|
-
|
|
313
|
-
// Add --attach if server URL is provided
|
|
314
|
-
if (serverUrl) {
|
|
315
|
-
args.push("--attach", serverUrl);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Add session title
|
|
319
|
-
args.push("--title", sessionName);
|
|
320
|
-
|
|
321
|
-
// Add agent if specified
|
|
322
|
-
if (config.agent) {
|
|
323
|
-
args.push("--agent", config.agent);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Add model if specified
|
|
327
|
-
if (config.model) {
|
|
328
|
-
args.push("--model", config.model);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Build prompt from template
|
|
332
|
-
const prompt = buildPromptFromTemplate(config.prompt || "default", item, templatesDir);
|
|
333
|
-
if (prompt) {
|
|
334
|
-
args.push(prompt);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
return { args, cwd };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Build the prompt from item and config
|
|
342
|
-
* Uses prompt_template if provided, otherwise combines title and body
|
|
343
|
-
* @param {object} item - Item with title, body, etc.
|
|
344
|
-
* @param {object} config - Config with optional session.prompt_template
|
|
345
|
-
* @returns {string} The prompt to send to opencode
|
|
346
|
-
*/
|
|
347
|
-
function buildPrompt(item, config) {
|
|
348
|
-
// If prompt_template is provided, expand it
|
|
349
|
-
if (config.session?.prompt_template) {
|
|
350
|
-
return expandTemplate(config.session.prompt_template, item);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Default: combine title and body
|
|
354
|
-
const parts = [];
|
|
355
|
-
if (item.title) parts.push(item.title);
|
|
356
|
-
if (item.body) parts.push(item.body);
|
|
357
|
-
return parts.join("\n\n");
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Build command args for action
|
|
362
|
-
* Uses "opencode run" for non-interactive execution
|
|
363
|
-
* @deprecated Legacy function - not currently used. See getCommandInfoNew instead.
|
|
364
|
-
* @returns {object} { args: string[], cwd: string }
|
|
365
|
-
*/
|
|
366
|
-
function buildCommandArgs(item, config) {
|
|
367
|
-
const repoPath = expandPath(config.repo_path || ".");
|
|
368
|
-
const sessionTitle = config.session?.name_template
|
|
369
|
-
? buildSessionName(config.session.name_template, item)
|
|
370
|
-
: (item.title || `session-${Date.now()}`);
|
|
371
|
-
|
|
372
|
-
// Build opencode run command args array (non-interactive)
|
|
373
|
-
// Note: --title sets session title (--session is for continuing existing sessions)
|
|
374
|
-
const args = ["opencode", "run"];
|
|
375
|
-
|
|
376
|
-
// Add title for the session (helps identify it later)
|
|
377
|
-
args.push("--title", sessionTitle);
|
|
378
|
-
|
|
379
|
-
// Add agent if specified
|
|
380
|
-
if (config.session?.agent) {
|
|
381
|
-
args.push("--agent", config.session.agent);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Add prompt (must be last for "run" command)
|
|
385
|
-
const prompt = buildPrompt(item, config);
|
|
386
|
-
if (prompt) {
|
|
387
|
-
args.push(prompt);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
return { args, cwd: repoPath };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Get command info for an action
|
|
395
|
-
* @param {object} item - Item to create session for
|
|
396
|
-
* @param {object} config - Repo config with action settings
|
|
397
|
-
* @returns {object} { args: string[], cwd: string }
|
|
398
|
-
*/
|
|
399
|
-
export function getCommandInfo(item, config) {
|
|
400
|
-
return getCommandInfoNew(item, config);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* Build command string for display/logging
|
|
405
|
-
* @param {object} item - Item to create session for
|
|
406
|
-
* @param {object} config - Repo config with action settings
|
|
407
|
-
* @returns {string} Command string (for display only)
|
|
408
|
-
*/
|
|
409
|
-
export function buildCommand(item, config) {
|
|
410
|
-
const cmdInfo = getCommandInfo(item, config);
|
|
411
|
-
|
|
412
|
-
const quoteArgs = (args) => args.map(a =>
|
|
413
|
-
a.includes(" ") || a.includes("\n") ? `"${a.replace(/"/g, '\\"')}"` : a
|
|
414
|
-
).join(" ");
|
|
415
305
|
|
|
416
|
-
|
|
417
|
-
return cmdInfo.cwd ? `(cd ${cmdInfo.cwd} && ${cmdStr})` : cmdStr;
|
|
306
|
+
return `[API] POST /session?directory=${cwd} (title: "${sessionName}")`;
|
|
418
307
|
}
|
|
419
308
|
|
|
420
309
|
/**
|
|
@@ -562,8 +451,19 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
562
451
|
* @returns {Promise<object>} Result with command, stdout, stderr, exitCode
|
|
563
452
|
*/
|
|
564
453
|
export async function executeAction(item, config, options = {}) {
|
|
565
|
-
// Get base working directory
|
|
566
|
-
const workingDir = config.working_dir || config.path || config.repo_path
|
|
454
|
+
// Get base working directory - require explicit config, don't default to home
|
|
455
|
+
const workingDir = config.working_dir || config.path || config.repo_path;
|
|
456
|
+
|
|
457
|
+
// Fail-safe: require a valid local path to be configured
|
|
458
|
+
if (!workingDir) {
|
|
459
|
+
debug(`executeAction: skipping item - no local path configured`);
|
|
460
|
+
return {
|
|
461
|
+
success: false,
|
|
462
|
+
skipped: true,
|
|
463
|
+
error: 'No local path configured for this repository. Configure repos_dir or add explicit repo config.',
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
567
467
|
const baseCwd = expandPath(workingDir);
|
|
568
468
|
|
|
569
469
|
// Discover running opencode server for this directory
|
|
@@ -573,10 +473,12 @@ export async function executeAction(item, config, options = {}) {
|
|
|
573
473
|
debug(`executeAction: discovered server=${serverUrl} for baseCwd=${baseCwd}`);
|
|
574
474
|
|
|
575
475
|
// Require OpenCode server - pilot runs as a plugin, so server should always be available
|
|
476
|
+
// Mark as skipped (retriable) rather than hard error - server may still be initializing
|
|
576
477
|
if (!serverUrl) {
|
|
577
478
|
return {
|
|
578
479
|
success: false,
|
|
579
|
-
|
|
480
|
+
skipped: true,
|
|
481
|
+
error: 'No OpenCode server found. Will retry on next poll.',
|
|
580
482
|
};
|
|
581
483
|
}
|
|
582
484
|
|
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, resolveRepoForItem, getCleanupTtlDays } from "./repo-config.js";
|
|
12
|
+
import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem, getCleanupTtlDays, getStartupDelay } from "./repo-config.js";
|
|
13
13
|
import { createPoller, pollGenericSource, enrichItemsWithComments } from "./poller.js";
|
|
14
14
|
import { evaluateReadiness, sortByPriority } from "./readiness.js";
|
|
15
15
|
import { executeAction, buildCommand } from "./actions.js";
|
|
@@ -198,6 +198,14 @@ export async function pollOnce(options = {}) {
|
|
|
198
198
|
// Build action config from source and item (resolves repo from item fields)
|
|
199
199
|
const actionConfig = buildActionConfigForItem(source, item);
|
|
200
200
|
|
|
201
|
+
// Skip items with no valid local path (prevents sessions in home directory)
|
|
202
|
+
const hasLocalPath = actionConfig.working_dir || actionConfig.path || actionConfig.repo_path;
|
|
203
|
+
if (!hasLocalPath) {
|
|
204
|
+
debug(`Skipping ${item.id} - no local path configured for repository`);
|
|
205
|
+
console.warn(`[poll] Skipping ${item.id} - no local path configured (repo not in repos_dir or explicit config)`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
201
209
|
// Execute or dry-run
|
|
202
210
|
if (dryRun) {
|
|
203
211
|
const command = buildCommand(item, actionConfig);
|
|
@@ -232,8 +240,13 @@ export async function pollOnce(options = {}) {
|
|
|
232
240
|
} else {
|
|
233
241
|
console.log(`[poll] Started session for ${item.id}`);
|
|
234
242
|
}
|
|
243
|
+
} else if (result.skipped) {
|
|
244
|
+
// Item was skipped (e.g., no local path configured) - use debug level
|
|
245
|
+
// This will retry on next poll, but doesn't spam logs
|
|
246
|
+
debug(`Skipped ${item.id}: ${result.error}`);
|
|
235
247
|
} else {
|
|
236
|
-
|
|
248
|
+
// Real failure - log as error
|
|
249
|
+
console.error(`[poll] Failed to start session for ${item.id}: ${result.error || result.stderr || 'unknown error'}`);
|
|
237
250
|
}
|
|
238
251
|
} catch (err) {
|
|
239
252
|
console.error(`[poll] Error executing action: ${err.message}`);
|
|
@@ -287,12 +300,21 @@ export function startPolling(options = {}) {
|
|
|
287
300
|
console.log(`[poll] Cleaned up ${expiredRemoved} expired state entries (older than ${ttlDays} days)`);
|
|
288
301
|
}
|
|
289
302
|
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
303
|
+
// Delay first poll to allow OpenCode server to fully initialize
|
|
304
|
+
// This prevents race conditions on startup where projects/sandboxes aren't loaded yet
|
|
305
|
+
const startupDelay = getStartupDelay();
|
|
306
|
+
if (startupDelay > 0) {
|
|
307
|
+
console.log(`[poll] Waiting ${startupDelay / 1000}s for server to initialize...`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Schedule first poll after startup delay (or immediately if delay is 0)
|
|
311
|
+
setTimeout(() => {
|
|
312
|
+
pollOnce({ configPath }).catch((err) => {
|
|
313
|
+
console.error("[poll] Error in poll cycle:", err.message);
|
|
314
|
+
});
|
|
315
|
+
}, startupDelay);
|
|
294
316
|
|
|
295
|
-
// Start interval
|
|
317
|
+
// Start interval (runs after startup delay + interval for first scheduled poll)
|
|
296
318
|
pollingInterval = setInterval(() => {
|
|
297
319
|
pollOnce({ configPath }).catch((err) => {
|
|
298
320
|
console.error("[poll] Error in poll cycle:", err.message);
|
|
@@ -33,6 +33,7 @@ review-requests:
|
|
|
33
33
|
item:
|
|
34
34
|
id: "{url}"
|
|
35
35
|
repo: "{repository.nameWithOwner}"
|
|
36
|
+
prompt: review
|
|
36
37
|
session:
|
|
37
38
|
name: "Review: {title}"
|
|
38
39
|
|
|
@@ -44,6 +45,7 @@ my-prs-feedback:
|
|
|
44
45
|
item:
|
|
45
46
|
id: "{url}"
|
|
46
47
|
repo: "{repository.nameWithOwner}"
|
|
48
|
+
prompt: review-feedback
|
|
47
49
|
session:
|
|
48
50
|
name: "Feedback: {title}"
|
|
49
51
|
# Reprocess when PR is updated (new commits pushed, new comments, etc.)
|
package/service/repo-config.js
CHANGED
|
@@ -62,6 +62,7 @@ function parseGitHubRepo(url) {
|
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* Discover repos from a repos_dir by scanning git remotes
|
|
65
|
+
* Checks both 'origin' and 'upstream' remotes to support fork workflows
|
|
65
66
|
* @param {string} reposDir - Directory containing git repositories
|
|
66
67
|
* @returns {Map<string, object>} Map of "owner/repo" -> { path }
|
|
67
68
|
*/
|
|
@@ -90,20 +91,24 @@ function discoverRepos(reposDir) {
|
|
|
90
91
|
// Skip if not a git repo
|
|
91
92
|
if (!fs.existsSync(gitDir)) continue;
|
|
92
93
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
// Check both origin and upstream remotes to support fork workflows
|
|
95
|
+
// e.g., origin = athal7/opencode (fork), upstream = anomalyco/opencode (original)
|
|
96
|
+
// Both should resolve to the same local path
|
|
97
|
+
for (const remote of ['origin', 'upstream']) {
|
|
98
|
+
try {
|
|
99
|
+
const remoteUrl = execSync(`git remote get-url ${remote}`, {
|
|
100
|
+
cwd: repoPath,
|
|
101
|
+
encoding: 'utf-8',
|
|
102
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
103
|
+
}).trim();
|
|
104
|
+
|
|
105
|
+
const repoKey = parseGitHubRepo(remoteUrl);
|
|
106
|
+
if (repoKey) {
|
|
107
|
+
discovered.set(repoKey, { path: repoPath });
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Skip if remote doesn't exist or git errors
|
|
104
111
|
}
|
|
105
|
-
} catch {
|
|
106
|
-
// Skip repos without origin or git errors
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
} catch {
|
|
@@ -426,6 +431,16 @@ export function getServerPort() {
|
|
|
426
431
|
return config?.server_port ?? null;
|
|
427
432
|
}
|
|
428
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Get startup delay from config (ms to wait before first poll)
|
|
436
|
+
* This allows OpenCode server time to fully initialize after restart
|
|
437
|
+
* @returns {number} Startup delay in ms (default: 10000 = 10 seconds)
|
|
438
|
+
*/
|
|
439
|
+
export function getStartupDelay() {
|
|
440
|
+
const config = getRawConfig();
|
|
441
|
+
return config?.startup_delay ?? 10000;
|
|
442
|
+
}
|
|
443
|
+
|
|
429
444
|
/**
|
|
430
445
|
* Clear config cache (for testing)
|
|
431
446
|
*/
|
package/service/server.js
CHANGED
|
@@ -166,6 +166,20 @@ if (isMainModule()) {
|
|
|
166
166
|
|
|
167
167
|
console.log('[opencode-pilot] Starting service...')
|
|
168
168
|
|
|
169
|
+
// Handle uncaught exceptions - log and exit to prevent silent crashes
|
|
170
|
+
process.on('uncaughtException', (err) => {
|
|
171
|
+
console.error('[opencode-pilot] Uncaught exception:', err.message)
|
|
172
|
+
console.error(err.stack)
|
|
173
|
+
process.exit(1)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// Handle unhandled promise rejections - log and exit to prevent silent crashes
|
|
177
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
178
|
+
console.error('[opencode-pilot] Unhandled rejection at:', promise)
|
|
179
|
+
console.error('[opencode-pilot] Reason:', reason)
|
|
180
|
+
process.exit(1)
|
|
181
|
+
})
|
|
182
|
+
|
|
169
183
|
startService(config).then((service) => {
|
|
170
184
|
// Handle graceful shutdown
|
|
171
185
|
process.on('SIGTERM', async () => {
|
|
@@ -160,137 +160,32 @@ describe('actions.js', () => {
|
|
|
160
160
|
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
-
describe('
|
|
164
|
-
test('builds
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const { getCommandInfoNew } = await import('../../service/actions.js');
|
|
163
|
+
describe('buildCommand', () => {
|
|
164
|
+
test('builds display string for API call', async () => {
|
|
165
|
+
const { buildCommand } = await import('../../service/actions.js');
|
|
168
166
|
|
|
169
|
-
const item = { number: 123, title: 'Fix bug'
|
|
167
|
+
const item = { number: 123, title: 'Fix bug' };
|
|
170
168
|
const config = {
|
|
171
169
|
path: '~/code/backend',
|
|
172
|
-
prompt: 'default',
|
|
173
|
-
agent: 'coder',
|
|
174
170
|
session: { name: 'issue-{number}' }
|
|
175
171
|
};
|
|
176
172
|
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
assert.strictEqual(cmdInfo.cwd, join(homedir(), 'code/backend'));
|
|
180
|
-
assert.ok(cmdInfo.args.includes('opencode'));
|
|
181
|
-
assert.ok(cmdInfo.args.includes('run'));
|
|
182
|
-
assert.ok(cmdInfo.args.includes('--title'));
|
|
183
|
-
assert.ok(cmdInfo.args.includes('issue-123'));
|
|
184
|
-
assert.ok(cmdInfo.args.includes('--agent'));
|
|
185
|
-
assert.ok(cmdInfo.args.includes('coder'));
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('uses working_dir when no path', async () => {
|
|
189
|
-
const { getCommandInfoNew } = await import('../../service/actions.js');
|
|
190
|
-
|
|
191
|
-
const item = { id: 'reminder-1', title: 'Do something' };
|
|
192
|
-
const config = {
|
|
193
|
-
working_dir: '~/scratch',
|
|
194
|
-
prompt: 'default'
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
const cmdInfo = getCommandInfoNew(item, config, templatesDir);
|
|
173
|
+
const result = buildCommand(item, config);
|
|
198
174
|
|
|
199
|
-
assert.
|
|
175
|
+
assert.ok(result.includes('[API]'), 'Should indicate API call');
|
|
176
|
+
assert.ok(result.includes('/session'), 'Should include session endpoint');
|
|
177
|
+
assert.ok(result.includes('issue-123'), 'Should include expanded session name');
|
|
200
178
|
});
|
|
201
179
|
|
|
202
|
-
test('
|
|
203
|
-
const {
|
|
180
|
+
test('shows (no path) when path not configured', async () => {
|
|
181
|
+
const { buildCommand } = await import('../../service/actions.js');
|
|
204
182
|
|
|
205
183
|
const item = { title: 'Do something' };
|
|
206
184
|
const config = { prompt: 'default' };
|
|
207
185
|
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
assert.strictEqual(cmdInfo.cwd, homedir());
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test('includes prompt from template as message', async () => {
|
|
214
|
-
writeFileSync(join(templatesDir, 'devcontainer.md'), '/devcontainer issue-{number}\n\n{title}\n\n{body}');
|
|
215
|
-
|
|
216
|
-
const { getCommandInfoNew } = await import('../../service/actions.js');
|
|
217
|
-
|
|
218
|
-
const item = { number: 66, title: 'Fix bug', body: 'Details' };
|
|
219
|
-
const config = {
|
|
220
|
-
path: '~/code/backend',
|
|
221
|
-
prompt: 'devcontainer'
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
const cmdInfo = getCommandInfoNew(item, config, templatesDir);
|
|
225
|
-
|
|
226
|
-
// Should NOT have --command flag (slash command is in template)
|
|
227
|
-
assert.ok(!cmdInfo.args.includes('--command'), 'Should not include --command flag');
|
|
186
|
+
const result = buildCommand(item, config);
|
|
228
187
|
|
|
229
|
-
|
|
230
|
-
const lastArg = cmdInfo.args[cmdInfo.args.length - 1];
|
|
231
|
-
assert.ok(lastArg.includes('/devcontainer issue-66'), 'Prompt should include /devcontainer command');
|
|
232
|
-
assert.ok(lastArg.includes('Fix bug'), 'Prompt should include title');
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
test('includes --attach when serverUrl is provided', async () => {
|
|
236
|
-
const { getCommandInfoNew } = await import('../../service/actions.js');
|
|
237
|
-
|
|
238
|
-
const item = { number: 123, title: 'Fix bug' };
|
|
239
|
-
const config = {
|
|
240
|
-
path: '~/code/backend',
|
|
241
|
-
prompt: 'default'
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
const cmdInfo = getCommandInfoNew(item, config, templatesDir, 'http://localhost:4096');
|
|
245
|
-
|
|
246
|
-
assert.ok(cmdInfo.args.includes('--attach'), 'Should include --attach flag');
|
|
247
|
-
assert.ok(cmdInfo.args.includes('http://localhost:4096'), 'Should include server URL');
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
test('does not include --attach when serverUrl is null', async () => {
|
|
251
|
-
const { getCommandInfoNew } = await import('../../service/actions.js');
|
|
252
|
-
|
|
253
|
-
const item = { number: 123, title: 'Fix bug' };
|
|
254
|
-
const config = {
|
|
255
|
-
path: '~/code/backend',
|
|
256
|
-
prompt: 'default'
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
const cmdInfo = getCommandInfoNew(item, config, templatesDir, null);
|
|
260
|
-
|
|
261
|
-
assert.ok(!cmdInfo.args.includes('--attach'), 'Should not include --attach flag');
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test('uses item title as session name when no session.name configured', async () => {
|
|
265
|
-
const { getCommandInfoNew } = await import('../../service/actions.js');
|
|
266
|
-
|
|
267
|
-
const item = { id: 'reminder-123', title: 'Review quarterly reports' };
|
|
268
|
-
const config = {
|
|
269
|
-
path: '~/code/backend',
|
|
270
|
-
prompt: 'default'
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
const cmdInfo = getCommandInfoNew(item, config, templatesDir);
|
|
274
|
-
|
|
275
|
-
const titleIndex = cmdInfo.args.indexOf('--title');
|
|
276
|
-
assert.ok(titleIndex !== -1, 'Should have --title flag');
|
|
277
|
-
assert.strictEqual(cmdInfo.args[titleIndex + 1], 'Review quarterly reports', 'Should use item title as session name');
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
test('falls back to timestamp when no session.name and no title', async () => {
|
|
281
|
-
const { getCommandInfoNew } = await import('../../service/actions.js');
|
|
282
|
-
|
|
283
|
-
const item = { id: 'item-123' };
|
|
284
|
-
const config = {
|
|
285
|
-
path: '~/code/backend',
|
|
286
|
-
prompt: 'default'
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
const cmdInfo = getCommandInfoNew(item, config, templatesDir);
|
|
290
|
-
|
|
291
|
-
const titleIndex = cmdInfo.args.indexOf('--title');
|
|
292
|
-
assert.ok(titleIndex !== -1, 'Should have --title flag');
|
|
293
|
-
assert.ok(cmdInfo.args[titleIndex + 1].startsWith('session-'), 'Should fall back to session-{timestamp}');
|
|
188
|
+
assert.ok(result.includes('(no path)'), 'Should indicate missing path');
|
|
294
189
|
});
|
|
295
190
|
});
|
|
296
191
|
|
|
@@ -569,6 +464,48 @@ describe('actions.js', () => {
|
|
|
569
464
|
assert.ok(result.error.includes('No OpenCode server'), 'Should have descriptive error');
|
|
570
465
|
});
|
|
571
466
|
|
|
467
|
+
test('skips item when no local path is configured', async () => {
|
|
468
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
469
|
+
|
|
470
|
+
const item = { number: 123, title: 'PR from unknown fork' };
|
|
471
|
+
// Config with no path/working_dir - simulates unknown repo
|
|
472
|
+
const config = {
|
|
473
|
+
prompt: 'default'
|
|
474
|
+
// Note: no path, working_dir, or repo_path - simulates unknown repo
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const result = await executeAction(item, config, { dryRun: true });
|
|
478
|
+
|
|
479
|
+
assert.strictEqual(result.success, false, 'Should fail when no path configured');
|
|
480
|
+
assert.strictEqual(result.skipped, true, 'Should mark as skipped');
|
|
481
|
+
assert.ok(result.error.includes('No local path configured'), 'Should have descriptive error');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('allows any path when working_dir is explicitly set', async () => {
|
|
485
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
486
|
+
const os = await import('os');
|
|
487
|
+
|
|
488
|
+
const item = { number: 123, title: 'Global task' };
|
|
489
|
+
// Explicit working_dir to home - user intentionally wants this
|
|
490
|
+
const config = {
|
|
491
|
+
working_dir: os.homedir(),
|
|
492
|
+
prompt: 'default'
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// Mock server discovery
|
|
496
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
497
|
+
|
|
498
|
+
// Should not skip because working_dir is explicitly set
|
|
499
|
+
const result = await executeAction(item, config, {
|
|
500
|
+
dryRun: true,
|
|
501
|
+
discoverServer: mockDiscoverServer,
|
|
502
|
+
fetch: async () => ({ ok: false, text: async () => 'Not found' })
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// It won't succeed (no valid session endpoint mock) but it shouldn't be skipped
|
|
506
|
+
assert.notStrictEqual(result.skipped, true, 'Should NOT skip when working_dir is explicit');
|
|
507
|
+
});
|
|
508
|
+
|
|
572
509
|
test('creates new worktree when worktree: "new" is configured (dry run)', async () => {
|
|
573
510
|
const { executeAction } = await import('../../service/actions.js');
|
|
574
511
|
|
|
@@ -216,6 +216,29 @@ repos_dir: ${reposDir}
|
|
|
216
216
|
assert.deepStrictEqual(config, {});
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
test('discovers repos from upstream remote for fork workflows', async () => {
|
|
220
|
+
// Create a repo with both origin (fork) and upstream (original) remotes
|
|
221
|
+
const repoPath = join(reposDir, 'opencode');
|
|
222
|
+
mkdirSync(repoPath);
|
|
223
|
+
execSync('git init', { cwd: repoPath, stdio: 'ignore' });
|
|
224
|
+
execSync('git remote add origin https://github.com/athal7/opencode.git', { cwd: repoPath, stdio: 'ignore' });
|
|
225
|
+
execSync('git remote add upstream https://github.com/anomalyco/opencode.git', { cwd: repoPath, stdio: 'ignore' });
|
|
226
|
+
|
|
227
|
+
writeFileSync(configPath, `
|
|
228
|
+
repos_dir: ${reposDir}
|
|
229
|
+
`);
|
|
230
|
+
|
|
231
|
+
const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
|
|
232
|
+
loadRepoConfig(configPath);
|
|
233
|
+
|
|
234
|
+
// Both the fork (origin) and original (upstream) should resolve to the same local path
|
|
235
|
+
const forkConfig = getRepoConfig('athal7/opencode');
|
|
236
|
+
const upstreamConfig = getRepoConfig('anomalyco/opencode');
|
|
237
|
+
|
|
238
|
+
assert.strictEqual(forkConfig.path, repoPath, 'fork (origin) should resolve');
|
|
239
|
+
assert.strictEqual(upstreamConfig.path, repoPath, 'upstream should also resolve');
|
|
240
|
+
});
|
|
241
|
+
|
|
219
242
|
});
|
|
220
243
|
|
|
221
244
|
describe('sources', () => {
|