opencode-pilot 0.15.2 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.15.2",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -10,7 +10,7 @@ import { readFileSync, existsSync } from "fs";
10
10
  import { debug } from "./logger.js";
11
11
  import { getNestedValue } from "./utils.js";
12
12
  import { getServerPort } from "./repo-config.js";
13
- import { resolveWorktreeDirectory } from "./worktree.js";
13
+ import { resolveWorktreeDirectory, getProjectInfo } from "./worktree.js";
14
14
  import path from "path";
15
15
  import os from "os";
16
16
 
@@ -573,8 +573,20 @@ export async function executeAction(item, config, options = {}) {
573
573
 
574
574
  // Resolve worktree directory if configured
575
575
  // This allows creating sessions in isolated worktrees instead of the main project
576
+ let worktreeMode = config.worktree;
577
+
578
+ // Auto-detect worktree support: if not explicitly configured and server is running,
579
+ // check if the project has sandboxes (indicating worktree workflow is set up)
580
+ if (!worktreeMode && serverUrl) {
581
+ const projectInfo = await getProjectInfo(serverUrl, { fetch: options.fetch });
582
+ if (projectInfo?.sandboxes?.length > 0) {
583
+ debug(`executeAction: auto-detected worktree support (${projectInfo.sandboxes.length} sandboxes)`);
584
+ worktreeMode = 'new';
585
+ }
586
+ }
587
+
576
588
  const worktreeConfig = {
577
- worktree: config.worktree,
589
+ worktree: worktreeMode,
578
590
  // Expand worktree_name template with item fields (e.g., "issue-{number}")
579
591
  worktreeName: config.worktree_name ? expandTemplate(config.worktree_name, item) : undefined,
580
592
  };
@@ -14,6 +14,7 @@ import fs from "fs";
14
14
  import path from "path";
15
15
  import os from "os";
16
16
  import YAML from "yaml";
17
+ import { execSync } from "child_process";
17
18
  import { getNestedValue } from "./utils.js";
18
19
  import { expandPreset, expandGitHubShorthand, getProviderConfig } from "./presets/index.js";
19
20
 
@@ -32,6 +33,86 @@ const DEFAULT_TEMPLATES_DIR = path.join(
32
33
  // In-memory config cache (for testing and runtime)
33
34
  let configCache = null;
34
35
 
36
+ // Cache for discovered repos from repos_dir
37
+ let discoveredReposCache = null;
38
+
39
+ /**
40
+ * Parse GitHub owner/repo from a git remote URL
41
+ * Supports HTTPS and SSH formats
42
+ * @param {string} url - Git remote URL
43
+ * @returns {string|null} "owner/repo" or null if not a GitHub URL
44
+ */
45
+ function parseGitHubRepo(url) {
46
+ if (!url) return null;
47
+
48
+ // HTTPS: https://github.com/owner/repo.git
49
+ const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/);
50
+ if (httpsMatch) {
51
+ return `${httpsMatch[1]}/${httpsMatch[2]}`;
52
+ }
53
+
54
+ // SSH: git@github.com:owner/repo.git
55
+ const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/);
56
+ if (sshMatch) {
57
+ return `${sshMatch[1]}/${sshMatch[2]}`;
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Discover repos from a repos_dir by scanning git remotes
65
+ * @param {string} reposDir - Directory containing git repositories
66
+ * @returns {Map<string, object>} Map of "owner/repo" -> { path }
67
+ */
68
+ function discoverRepos(reposDir) {
69
+ const discovered = new Map();
70
+
71
+ if (!reposDir) {
72
+ return discovered;
73
+ }
74
+
75
+ const normalizedDir = reposDir.replace(/^~/, os.homedir());
76
+
77
+ if (!fs.existsSync(normalizedDir)) {
78
+ return discovered;
79
+ }
80
+
81
+ try {
82
+ const entries = fs.readdirSync(normalizedDir, { withFileTypes: true });
83
+
84
+ for (const entry of entries) {
85
+ if (!entry.isDirectory()) continue;
86
+
87
+ const repoPath = path.join(normalizedDir, entry.name);
88
+ const gitDir = path.join(repoPath, '.git');
89
+
90
+ // Skip if not a git repo
91
+ if (!fs.existsSync(gitDir)) continue;
92
+
93
+ // Get remote origin URL via git API
94
+ try {
95
+ const remoteUrl = execSync('git remote get-url origin', {
96
+ cwd: repoPath,
97
+ encoding: 'utf-8',
98
+ stdio: ['pipe', 'pipe', 'pipe']
99
+ }).trim();
100
+
101
+ const repoKey = parseGitHubRepo(remoteUrl);
102
+ if (repoKey) {
103
+ discovered.set(repoKey, { path: repoPath });
104
+ }
105
+ } catch {
106
+ // Skip repos without origin or git errors
107
+ }
108
+ }
109
+ } catch {
110
+ // Directory read error
111
+ }
112
+
113
+ return discovered;
114
+ }
115
+
35
116
  /**
36
117
  * Expand template string with item fields
37
118
  * Supports {field} and {field.nested} syntax
@@ -53,6 +134,10 @@ export function loadRepoConfig(configOrPath) {
53
134
  if (typeof configOrPath === "object") {
54
135
  // Direct config object (for testing)
55
136
  configCache = configOrPath;
137
+ // Discover repos if repos_dir is set
138
+ discoveredReposCache = configCache.repos_dir
139
+ ? discoverRepos(configCache.repos_dir)
140
+ : null;
56
141
  return configCache;
57
142
  }
58
143
 
@@ -60,16 +145,22 @@ export function loadRepoConfig(configOrPath) {
60
145
 
61
146
  if (!fs.existsSync(configPath)) {
62
147
  configCache = emptyConfig;
148
+ discoveredReposCache = null;
63
149
  return configCache;
64
150
  }
65
151
 
66
152
  try {
67
153
  const content = fs.readFileSync(configPath, "utf-8");
68
154
  configCache = YAML.parse(content, { merge: true }) || emptyConfig;
155
+ // Discover repos if repos_dir is set
156
+ discoveredReposCache = configCache.repos_dir
157
+ ? discoverRepos(configCache.repos_dir)
158
+ : null;
69
159
  } catch (err) {
70
160
  // Log error but continue with empty config to allow graceful degradation
71
161
  console.error(`Warning: Failed to parse config at ${configPath}: ${err.message}`);
72
162
  configCache = emptyConfig;
163
+ discoveredReposCache = null;
73
164
  }
74
165
  return configCache;
75
166
  }
@@ -86,20 +177,31 @@ function getRawConfig() {
86
177
 
87
178
  /**
88
179
  * Get configuration for a specific repo
180
+ * Checks explicit repos config first, then falls back to auto-discovered repos
89
181
  * @param {string} repoKey - Repository identifier (e.g., "myorg/backend")
90
182
  * @returns {object} Repository configuration or empty object
91
183
  */
92
184
  export function getRepoConfig(repoKey) {
93
185
  const config = getRawConfig();
94
186
  const repos = config.repos || {};
95
- const repoConfig = repos[repoKey] || {};
96
-
97
- // Normalize: support both 'path' and 'repo_path' keys
98
- if (repoConfig.path && !repoConfig.repo_path) {
99
- return { ...repoConfig, repo_path: repoConfig.path };
187
+
188
+ // Check explicit repos config first
189
+ if (repos[repoKey]) {
190
+ const repoConfig = repos[repoKey];
191
+ // Normalize: support both 'path' and 'repo_path' keys
192
+ if (repoConfig.path && !repoConfig.repo_path) {
193
+ return { ...repoConfig, repo_path: repoConfig.path };
194
+ }
195
+ return repoConfig;
196
+ }
197
+
198
+ // Fall back to auto-discovered repos from repos_dir
199
+ if (discoveredReposCache && discoveredReposCache.has(repoKey)) {
200
+ const discovered = discoveredReposCache.get(repoKey);
201
+ return { ...discovered, repo_path: discovered.path };
100
202
  }
101
203
 
102
- return repoConfig;
204
+ return {};
103
205
  }
104
206
 
105
207
  /**
@@ -324,4 +426,5 @@ export function getServerPort() {
324
426
  */
325
427
  export function clearConfigCache() {
326
428
  configCache = null;
429
+ discoveredReposCache = null;
327
430
  }
@@ -693,6 +693,114 @@ describe('actions.js', () => {
693
693
  assert.ok(result.command.includes(tempDir),
694
694
  'Should fall back to base directory when worktree creation fails');
695
695
  });
696
+
697
+ test('auto-detects worktree support when project has sandboxes (dry run)', async () => {
698
+ const { executeAction } = await import('../../service/actions.js');
699
+
700
+ const item = { number: 456, title: 'New feature' };
701
+ const config = {
702
+ path: tempDir,
703
+ prompt: 'default'
704
+ // Note: no worktree config - should be auto-detected
705
+ };
706
+
707
+ // Mock server discovery
708
+ const mockDiscoverServer = async () => 'http://localhost:4096';
709
+
710
+ // Track API calls
711
+ let projectInfoCalled = false;
712
+ let worktreeCreateCalled = false;
713
+
714
+ const mockFetch = async (url, opts) => {
715
+ // Project info endpoint - returns sandboxes indicating worktree workflow
716
+ if (url === 'http://localhost:4096/project/current') {
717
+ projectInfoCalled = true;
718
+ return {
719
+ ok: true,
720
+ json: async () => ({
721
+ id: 'proj-123',
722
+ worktree: tempDir,
723
+ sandboxes: ['/data/worktree/proj-123/sandbox-1'],
724
+ time: { created: 1 }
725
+ })
726
+ };
727
+ }
728
+ // Worktree creation endpoint
729
+ if (url === 'http://localhost:4096/experimental/worktree' && opts?.method === 'POST') {
730
+ worktreeCreateCalled = true;
731
+ return {
732
+ ok: true,
733
+ json: async () => ({
734
+ name: 'new-sandbox',
735
+ branch: 'opencode/new-sandbox',
736
+ directory: '/data/worktree/proj-123/new-sandbox'
737
+ })
738
+ };
739
+ }
740
+ return { ok: false, text: async () => 'Not found' };
741
+ };
742
+
743
+ const result = await executeAction(item, config, {
744
+ dryRun: true,
745
+ discoverServer: mockDiscoverServer,
746
+ fetch: mockFetch
747
+ });
748
+
749
+ assert.ok(result.dryRun);
750
+ assert.ok(projectInfoCalled, 'Should call project/current to check for sandboxes');
751
+ assert.ok(worktreeCreateCalled, 'Should auto-create worktree when sandboxes detected');
752
+ assert.ok(result.command.includes('/data/worktree/proj-123/new-sandbox'),
753
+ 'Should use newly created worktree directory');
754
+ });
755
+
756
+ test('does not auto-create worktree when project has no sandboxes (dry run)', async () => {
757
+ const { executeAction } = await import('../../service/actions.js');
758
+
759
+ const item = { number: 789, title: 'Simple fix' };
760
+ const config = {
761
+ path: tempDir,
762
+ prompt: 'default'
763
+ // Note: no worktree config
764
+ };
765
+
766
+ // Mock server discovery
767
+ const mockDiscoverServer = async () => 'http://localhost:4096';
768
+
769
+ let projectInfoCalled = false;
770
+ let worktreeCreateCalled = false;
771
+
772
+ const mockFetch = async (url, opts) => {
773
+ // Project info endpoint - returns empty sandboxes (no worktree workflow)
774
+ if (url === 'http://localhost:4096/project/current') {
775
+ projectInfoCalled = true;
776
+ return {
777
+ ok: true,
778
+ json: async () => ({
779
+ id: 'proj-456',
780
+ worktree: tempDir,
781
+ sandboxes: [],
782
+ time: { created: 1 }
783
+ })
784
+ };
785
+ }
786
+ if (url === 'http://localhost:4096/experimental/worktree' && opts?.method === 'POST') {
787
+ worktreeCreateCalled = true;
788
+ }
789
+ return { ok: false, text: async () => 'Not found' };
790
+ };
791
+
792
+ const result = await executeAction(item, config, {
793
+ dryRun: true,
794
+ discoverServer: mockDiscoverServer,
795
+ fetch: mockFetch
796
+ });
797
+
798
+ assert.ok(result.dryRun);
799
+ assert.ok(projectInfoCalled, 'Should call project/current to check for sandboxes');
800
+ assert.ok(!worktreeCreateCalled, 'Should NOT create worktree when no sandboxes');
801
+ assert.ok(result.command.includes(tempDir),
802
+ 'Should use base directory when no worktree workflow detected');
803
+ });
696
804
  });
697
805
 
698
806
  describe('createSessionViaApi', () => {
@@ -7,6 +7,7 @@ import assert from 'node:assert';
7
7
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { tmpdir } from 'os';
10
+ import { execSync } from 'child_process';
10
11
 
11
12
  describe('repo-config.js', () => {
12
13
  let tempDir;
@@ -119,6 +120,104 @@ repos:
119
120
  });
120
121
  });
121
122
 
123
+ describe('repos_dir auto-discovery', () => {
124
+ let reposDir;
125
+
126
+ beforeEach(() => {
127
+ reposDir = join(tempDir, 'code');
128
+ mkdirSync(reposDir);
129
+ });
130
+
131
+ function createGitRepo(name, remoteUrl) {
132
+ const repoPath = join(reposDir, name);
133
+ mkdirSync(repoPath);
134
+ execSync('git init', { cwd: repoPath, stdio: 'ignore' });
135
+ execSync(`git remote add origin ${remoteUrl}`, { cwd: repoPath, stdio: 'ignore' });
136
+ return repoPath;
137
+ }
138
+
139
+ test('discovers repos from git remote origin', async () => {
140
+ const repoPath = createGitRepo('my-project', 'https://github.com/myorg/my-project.git');
141
+
142
+ writeFileSync(configPath, `
143
+ repos_dir: ${reposDir}
144
+ `);
145
+
146
+ const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
147
+ loadRepoConfig(configPath);
148
+
149
+ const config = getRepoConfig('myorg/my-project');
150
+ assert.strictEqual(config.path, repoPath);
151
+ assert.strictEqual(config.repo_path, repoPath);
152
+ });
153
+
154
+ test('handles SSH git URLs', async () => {
155
+ const repoPath = createGitRepo('backend', 'git@github.com:myorg/backend.git');
156
+
157
+ writeFileSync(configPath, `
158
+ repos_dir: ${reposDir}
159
+ `);
160
+
161
+ const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
162
+ loadRepoConfig(configPath);
163
+
164
+ const config = getRepoConfig('myorg/backend');
165
+ assert.strictEqual(config.path, repoPath);
166
+ });
167
+
168
+ test('explicit repos override auto-discovered', async () => {
169
+ createGitRepo('my-project', 'https://github.com/myorg/my-project.git');
170
+
171
+ writeFileSync(configPath, `
172
+ repos_dir: ${reposDir}
173
+ repos:
174
+ myorg/my-project:
175
+ path: /custom/path
176
+ prompt: custom
177
+ `);
178
+
179
+ const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
180
+ loadRepoConfig(configPath);
181
+
182
+ const config = getRepoConfig('myorg/my-project');
183
+ // Explicit config should win
184
+ assert.strictEqual(config.path, '/custom/path');
185
+ assert.strictEqual(config.prompt, 'custom');
186
+ });
187
+
188
+ test('skips directories without .git', async () => {
189
+ const notARepo = join(reposDir, 'not-a-repo');
190
+ mkdirSync(notARepo);
191
+ writeFileSync(join(notARepo, 'file.txt'), 'hello');
192
+
193
+ writeFileSync(configPath, `
194
+ repos_dir: ${reposDir}
195
+ `);
196
+
197
+ const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
198
+ loadRepoConfig(configPath);
199
+
200
+ // Should not find this as a repo
201
+ const config = getRepoConfig('not-a-repo');
202
+ assert.deepStrictEqual(config, {});
203
+ });
204
+
205
+ test('returns empty for unknown repo even with repos_dir', async () => {
206
+ createGitRepo('my-project', 'https://github.com/myorg/my-project.git');
207
+
208
+ writeFileSync(configPath, `
209
+ repos_dir: ${reposDir}
210
+ `);
211
+
212
+ const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
213
+ loadRepoConfig(configPath);
214
+
215
+ const config = getRepoConfig('unknown/repo');
216
+ assert.deepStrictEqual(config, {});
217
+ });
218
+
219
+ });
220
+
122
221
  describe('sources', () => {
123
222
  test('returns sources array from top-level', async () => {
124
223
  writeFileSync(configPath, `