opencode-pilot 0.4.0 → 0.5.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/README.md CHANGED
@@ -9,6 +9,7 @@ Automation daemon for [OpenCode](https://github.com/sst/opencode) - polls for wo
9
9
  - **Polling automation** - Automatically start sessions from GitHub issues, Linear tickets, etc.
10
10
  - **Readiness evaluation** - Check labels, dependencies, and priority before starting work
11
11
  - **Template-based prompts** - Customize prompts with placeholders for issue data
12
+ - **Built-in presets** - Common patterns like "my GitHub issues" work out of the box
12
13
 
13
14
  ## Installation
14
15
 
@@ -44,10 +45,26 @@ See [examples/config.yaml](examples/config.yaml) for a complete example with all
44
45
 
45
46
  ### Key Sections
46
47
 
47
- - **`sources`** - What to poll (GitHub issues, Linear tickets, etc.)
48
+ - **`defaults`** - Default values applied to all sources
49
+ - **`sources`** - What to poll (presets, shorthand, or full config)
48
50
  - **`tools`** - Field mappings to normalize different MCP APIs
49
51
  - **`repos`** - Repository paths and settings (use YAML anchors to share config)
50
52
 
53
+ ### Source Syntax
54
+
55
+ Three ways to configure sources, from simplest to most flexible:
56
+
57
+ 1. **Presets** - Built-in definitions for common patterns (`github/my-issues`, `github/review-requests`, etc.)
58
+ 2. **GitHub shorthand** - Simple `github: "query"` syntax for custom GitHub searches
59
+ 3. **Full syntax** - Complete control with `tool`, `args`, and `item` for any MCP source
60
+
61
+ ### Available Presets
62
+
63
+ - `github/my-issues` - Issues assigned to me
64
+ - `github/review-requests` - PRs needing my review
65
+ - `github/my-prs-feedback` - My PRs with change requests
66
+ - `linear/my-issues` - Linear tickets (requires `teamId`, `assigneeId`)
67
+
51
68
  ### Prompt Templates
52
69
 
53
70
  Create prompt templates as markdown files in `~/.config/opencode-pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
@@ -4,75 +4,88 @@
4
4
  # Also create templates/ directory with prompt template files
5
5
 
6
6
  # =============================================================================
7
- # SERVICE - Daemon configuration
7
+ # DEFAULTS - Applied to all sources (source values override these)
8
8
  # =============================================================================
9
- # port: 4097 # HTTP port for health checks (default: 4097)
9
+ defaults:
10
+ agent: plan # Default agent for all sources
11
+ prompt: default # Default prompt template
10
12
 
11
13
  # =============================================================================
12
- # TOOLS - Field mappings for MCP servers (normalize different APIs)
14
+ # SOURCES - What to poll
13
15
  #
14
- # Each tool provider can specify:
15
- # response_key: Key to extract array from response (e.g., "reminders", "nodes")
16
- # mappings: Field name translations (e.g., { body: "description" })
17
- # =============================================================================
18
- tools:
19
- github:
20
- response_key: items # GitHub search returns { items: [...] }
21
- mappings: {} # GitHub already uses standard field names
22
-
23
- linear:
24
- response_key: nodes # Linear returns { nodes: [...] }
25
- mappings:
26
- body: title # Use title as body
27
- number: "url:/([A-Z0-9]+-[0-9]+)/" # Extract PROJ-123 from URL
28
-
29
- apple-reminders:
30
- response_key: reminders # Apple Reminders returns { reminders: [...] }
31
- mappings:
32
- title: name # Reminders uses 'name' not 'title'
33
- body: notes # Reminders uses 'notes' for details
34
-
35
- # =============================================================================
36
- # SOURCES - What to poll (generic MCP tool references)
16
+ # Three syntax options:
17
+ # 1. Presets: Use built-in presets for common patterns
18
+ # 2. Shorthand: Use `github: "query"` for GitHub sources
19
+ # 3. Full: Specify tool, args, item for custom sources
37
20
  # =============================================================================
38
21
  sources:
22
+ # ----- PRESET SYNTAX (recommended for common patterns) -----
23
+
39
24
  # GitHub issues assigned to me
40
- - name: my-issues
41
- tool:
42
- mcp: github
43
- name: search_issues
44
- args:
45
- q: "is:issue assignee:@me state:open"
46
- item:
47
- id: "{html_url}"
25
+ - preset: github/my-issues
48
26
  prompt: worktree
49
- agent: plan
50
27
 
51
- # GitHub PRs needing review
52
- - name: review-requests
53
- tool:
54
- mcp: github
55
- name: search_issues
56
- args:
57
- q: "is:pr review-requested:@me state:open"
58
- item:
59
- id: "{html_url}"
28
+ # GitHub PRs needing my review
29
+ - preset: github/review-requests
60
30
  prompt: review
61
- agent: plan
62
31
 
63
- # Linear issues
64
- # NOTE: Replace teamId and assigneeId with your actual Linear UUIDs
65
- # Find these via: linear_list_teams and check user IDs in team members
66
- - name: linear-work
67
- tool:
68
- mcp: linear
69
- name: list_issues
32
+ # My PRs that have change requests
33
+ - preset: github/my-prs-feedback
34
+ prompt: review-feedback
35
+
36
+ # Linear issues (requires teamId and assigneeId)
37
+ # Find IDs via: opencode-pilot discover linear
38
+ - preset: linear/my-issues
70
39
  args:
71
40
  teamId: "your-team-uuid" # Replace with actual team UUID
72
41
  assigneeId: "your-user-uuid" # Replace with actual user UUID
73
- status: "Todo"
74
- item:
75
- id: "linear:{id}"
42
+ status: "In Progress"
76
43
  working_dir: ~/code/myproject
77
44
  prompt: worktree
78
- agent: plan
45
+
46
+ # ----- SHORTHAND SYNTAX (for custom GitHub queries) -----
47
+
48
+ # Custom GitHub query with shorthand
49
+ # - name: urgent-issues
50
+ # github: "is:issue assignee:@me label:urgent state:open"
51
+ # prompt: worktree
52
+
53
+ # ----- FULL SYNTAX (for non-GitHub sources or full control) -----
54
+
55
+ # Apple Reminders example (requires tools config below)
56
+ # - name: agent-tasks
57
+ # tool:
58
+ # mcp: apple-reminders
59
+ # name: list_reminders
60
+ # args:
61
+ # list_name: "Agent Tasks"
62
+ # completed: false
63
+ # item:
64
+ # id: "reminder:{id}"
65
+ # prompt: agent-planning
66
+ # working_dir: ~
67
+
68
+ # =============================================================================
69
+ # TOOLS - Field mappings for custom MCP servers (OPTIONAL)
70
+ #
71
+ # Note: GitHub and Linear presets include built-in mappings.
72
+ # Only needed for custom sources or to override preset defaults.
73
+ #
74
+ # Each tool provider can specify:
75
+ # response_key: Key to extract array from response (e.g., "reminders")
76
+ # mappings: Field name translations (e.g., { body: "notes" })
77
+ # =============================================================================
78
+ # tools:
79
+ # apple-reminders:
80
+ # response_key: reminders
81
+ # mappings:
82
+ # title: name
83
+ # body: notes
84
+
85
+ # =============================================================================
86
+ # Available presets (no tools config needed):
87
+ # github/my-issues - Issues assigned to me
88
+ # github/review-requests - PRs needing my review
89
+ # github/my-prs-feedback - My PRs with change requests
90
+ # linear/my-issues - Linear tickets (requires teamId, assigneeId)
91
+ # =============================================================================
@@ -1,12 +1,9 @@
1
+ /devcontainer {html_url}
2
+
1
3
  Work on this issue in an isolated devcontainer:
2
4
 
3
5
  {title}
4
6
 
5
7
  {body}
6
8
 
7
- First, set up an isolated workspace:
8
- 1. Create a shallow clone in a temp directory for branch `issue-{number}`
9
- 2. Open the devcontainer in that clone
10
- 3. Work from inside the devcontainer
11
-
12
9
  Complete the implementation and create a PR when ready.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -0,0 +1,37 @@
1
+ # GitHub source presets
2
+
3
+ # Provider-level config (applies to all GitHub presets)
4
+ _provider:
5
+ response_key: items
6
+ mappings: {}
7
+
8
+ # Presets
9
+ my-issues:
10
+ name: my-issues
11
+ tool:
12
+ mcp: github
13
+ name: search_issues
14
+ args:
15
+ q: "is:issue assignee:@me state:open"
16
+ item:
17
+ id: "{html_url}"
18
+
19
+ review-requests:
20
+ name: review-requests
21
+ tool:
22
+ mcp: github
23
+ name: search_issues
24
+ args:
25
+ q: "is:pr review-requested:@me state:open"
26
+ item:
27
+ id: "{html_url}"
28
+
29
+ my-prs-feedback:
30
+ name: my-prs-feedback
31
+ tool:
32
+ mcp: github
33
+ name: search_issues
34
+ args:
35
+ q: "is:pr author:@me state:open review:changes_requested"
36
+ item:
37
+ id: "{html_url}"
@@ -0,0 +1,157 @@
1
+ /**
2
+ * presets/index.js - Built-in source presets for common patterns
3
+ *
4
+ * Presets reduce config verbosity by providing sensible defaults
5
+ * for common polling sources like GitHub issues and Linear tickets.
6
+ *
7
+ * Presets are loaded from YAML files in this directory.
8
+ * Each file can include a _provider key with provider-level config
9
+ * (response_key, mappings) that applies to all presets for that provider.
10
+ */
11
+
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import { fileURLToPath } from "url";
15
+ import YAML from "yaml";
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ // Cache for loaded provider data
20
+ const providerCache = {};
21
+
22
+ /**
23
+ * Load provider data from a YAML file
24
+ * @param {string} provider - Provider name (e.g., "github", "linear")
25
+ * @returns {object} Provider data with presets and _provider config
26
+ */
27
+ function loadProviderData(provider) {
28
+ if (providerCache[provider]) {
29
+ return providerCache[provider];
30
+ }
31
+
32
+ const filePath = path.join(__dirname, `${provider}.yaml`);
33
+ if (!fs.existsSync(filePath)) {
34
+ providerCache[provider] = { presets: {}, providerConfig: null };
35
+ return providerCache[provider];
36
+ }
37
+
38
+ try {
39
+ const content = fs.readFileSync(filePath, "utf-8");
40
+ const data = YAML.parse(content) || {};
41
+
42
+ // Extract _provider config and presets
43
+ const { _provider, ...presets } = data;
44
+
45
+ providerCache[provider] = {
46
+ presets,
47
+ providerConfig: _provider || null,
48
+ };
49
+ return providerCache[provider];
50
+ } catch (err) {
51
+ console.error(`Warning: Failed to load presets from ${filePath}: ${err.message}`);
52
+ providerCache[provider] = { presets: {}, providerConfig: null };
53
+ return providerCache[provider];
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Build the full presets registry from YAML files
59
+ * Format: "provider/preset-name" -> preset config
60
+ */
61
+ function buildPresetsRegistry() {
62
+ const registry = {};
63
+ const providers = ["github", "linear"];
64
+
65
+ for (const provider of providers) {
66
+ const { presets } = loadProviderData(provider);
67
+ for (const [name, config] of Object.entries(presets)) {
68
+ registry[`${provider}/${name}`] = config;
69
+ }
70
+ }
71
+
72
+ return registry;
73
+ }
74
+
75
+ // Load presets once at module initialization
76
+ const PRESETS = buildPresetsRegistry();
77
+
78
+ /**
79
+ * Get a preset by name
80
+ * @param {string} presetName - Preset identifier (e.g., "github/my-issues")
81
+ * @returns {object|null} Preset configuration or null if not found
82
+ */
83
+ export function getPreset(presetName) {
84
+ return PRESETS[presetName] || null;
85
+ }
86
+
87
+ /**
88
+ * Get provider config (response_key, mappings) for a provider
89
+ * @param {string} provider - Provider name (e.g., "github", "linear")
90
+ * @returns {object|null} Provider config or null if not found
91
+ */
92
+ export function getProviderConfig(provider) {
93
+ const { providerConfig } = loadProviderData(provider);
94
+ return providerConfig;
95
+ }
96
+
97
+ /**
98
+ * List all available preset names
99
+ * @returns {string[]} Array of preset names
100
+ */
101
+ export function listPresets() {
102
+ return Object.keys(PRESETS);
103
+ }
104
+
105
+ /**
106
+ * Expand a preset into a full source configuration
107
+ * User config is merged on top of preset defaults
108
+ * @param {string} presetName - Preset identifier
109
+ * @param {object} userConfig - User's source config (overrides preset)
110
+ * @returns {object} Merged source configuration
111
+ * @throws {Error} If preset is unknown
112
+ */
113
+ export function expandPreset(presetName, userConfig) {
114
+ const preset = getPreset(presetName);
115
+ if (!preset) {
116
+ throw new Error(`Unknown preset: ${presetName}`);
117
+ }
118
+
119
+ // Deep merge: preset as base, user config on top
120
+ return {
121
+ ...preset,
122
+ ...userConfig,
123
+ // Merge nested objects specially
124
+ tool: userConfig.tool || preset.tool,
125
+ args: {
126
+ ...preset.args,
127
+ ...(userConfig.args || {}),
128
+ },
129
+ item: userConfig.item || preset.item,
130
+ // Remove the preset key from final output
131
+ preset: undefined,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Expand GitHub shorthand syntax into full source config
137
+ * @param {string} query - GitHub search query
138
+ * @param {object} userConfig - Rest of user's source config
139
+ * @returns {object} Full source configuration
140
+ */
141
+ export function expandGitHubShorthand(query, userConfig) {
142
+ return {
143
+ ...userConfig,
144
+ tool: {
145
+ mcp: "github",
146
+ name: "search_issues",
147
+ },
148
+ args: {
149
+ q: query,
150
+ },
151
+ item: {
152
+ id: "{html_url}",
153
+ },
154
+ // Remove the github key from final output
155
+ github: undefined,
156
+ };
157
+ }
@@ -0,0 +1,19 @@
1
+ # Linear source presets
2
+
3
+ # Provider-level config (applies to all Linear presets)
4
+ _provider:
5
+ response_key: nodes
6
+ mappings:
7
+ body: title
8
+ number: "url:/([A-Z0-9]+-[0-9]+)/"
9
+
10
+ # Presets
11
+ my-issues:
12
+ name: my-issues
13
+ tool:
14
+ mcp: linear
15
+ name: list_issues
16
+ args: {}
17
+ # teamId and assigneeId are required - user must provide
18
+ item:
19
+ id: "linear:{id}"
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Manages configuration stored in ~/.config/opencode-pilot/config.yaml
5
5
  * Supports:
6
+ * - defaults: default values applied to all sources
6
7
  * - repos: per-repository settings (use YAML anchors for sharing)
7
- * - sources: polling sources with generic tool references
8
+ * - sources: polling sources with generic tool references, presets, or shorthand
8
9
  * - tools: field mappings for normalizing MCP responses
9
10
  * - templates: prompt templates stored as markdown files
10
11
  */
@@ -14,6 +15,7 @@ import path from "path";
14
15
  import os from "os";
15
16
  import YAML from "yaml";
16
17
  import { getNestedValue } from "./utils.js";
18
+ import { expandPreset, expandGitHubShorthand, getProviderConfig } from "./presets/index.js";
17
19
 
18
20
  // Default config path
19
21
  const DEFAULT_CONFIG_PATH = path.join(
@@ -100,13 +102,53 @@ export function getRepoConfig(repoKey) {
100
102
  return repoConfig;
101
103
  }
102
104
 
105
+ /**
106
+ * Normalize a single source config
107
+ * Expands presets, shorthand syntax, and applies defaults
108
+ * @param {object} source - Raw source config
109
+ * @param {object} defaults - Default values to apply
110
+ * @returns {object} Normalized source config
111
+ */
112
+ function normalizeSource(source, defaults) {
113
+ let normalized = { ...source };
114
+
115
+ // Expand preset if present
116
+ if (source.preset) {
117
+ normalized = expandPreset(source.preset, source);
118
+ }
119
+
120
+ // Expand GitHub shorthand if present
121
+ if (source.github) {
122
+ normalized = expandGitHubShorthand(source.github, source);
123
+ }
124
+
125
+ // Apply defaults (source values take precedence)
126
+ return {
127
+ ...defaults,
128
+ ...normalized,
129
+ };
130
+ }
131
+
103
132
  /**
104
133
  * Get all top-level sources (for polling)
105
- * @returns {Array} Array of source configurations
134
+ * Expands presets and shorthand syntax, applies defaults
135
+ * @returns {Array} Array of normalized source configurations
106
136
  */
107
137
  export function getSources() {
108
138
  const config = getRawConfig();
109
- return config.sources || [];
139
+ const rawSources = config.sources || [];
140
+ const defaults = config.defaults || {};
141
+
142
+ return rawSources.map((source) => normalizeSource(source, defaults));
143
+ }
144
+
145
+ /**
146
+ * Get defaults section from config
147
+ * @returns {object} Defaults configuration or empty object
148
+ */
149
+ export function getDefaults() {
150
+ const config = getRawConfig();
151
+ return config.defaults || {};
110
152
  }
111
153
 
112
154
  /**
@@ -136,19 +178,36 @@ export function getToolMappings(provider) {
136
178
 
137
179
  /**
138
180
  * Get full tool provider configuration (response_key, mappings, etc.)
181
+ * Checks user config first, then falls back to preset provider defaults
139
182
  * @param {string} provider - Tool provider name (e.g., "github", "linear", "apple-reminders")
140
183
  * @returns {object|null} Tool config including response_key and mappings, or null if not configured
141
184
  */
142
185
  export function getToolProviderConfig(provider) {
143
186
  const config = getRawConfig();
144
187
  const tools = config.tools || {};
145
- const toolConfig = tools[provider];
146
-
147
- if (!toolConfig) {
148
- return null;
188
+ const userToolConfig = tools[provider];
189
+
190
+ // Get preset provider config as fallback
191
+ const presetProviderConfig = getProviderConfig(provider);
192
+
193
+ // If user has config, merge with preset defaults (user takes precedence)
194
+ if (userToolConfig) {
195
+ if (presetProviderConfig) {
196
+ return {
197
+ ...presetProviderConfig,
198
+ ...userToolConfig,
199
+ // Deep merge mappings
200
+ mappings: {
201
+ ...(presetProviderConfig.mappings || {}),
202
+ ...(userToolConfig.mappings || {}),
203
+ },
204
+ };
205
+ }
206
+ return userToolConfig;
149
207
  }
150
208
 
151
- return toolConfig;
209
+ // Fall back to preset provider config
210
+ return presetProviderConfig;
152
211
  }
153
212
 
154
213
  /**
@@ -350,9 +350,48 @@ sources: []
350
350
 
351
351
  const toolConfig = getToolProviderConfig('github');
352
352
 
353
- assert.strictEqual(toolConfig.response_key, undefined);
353
+ // GitHub preset has response_key: items, user config doesn't override it
354
+ assert.strictEqual(toolConfig.response_key, 'items');
354
355
  assert.deepStrictEqual(toolConfig.mappings, { url: 'html_url' });
355
356
  });
357
+
358
+ test('getToolProviderConfig falls back to preset provider config', async () => {
359
+ writeFileSync(configPath, `
360
+ sources: []
361
+ `);
362
+
363
+ const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js');
364
+ loadRepoConfig(configPath);
365
+
366
+ // Linear preset has provider config with response_key and mappings
367
+ const toolConfig = getToolProviderConfig('linear');
368
+
369
+ assert.strictEqual(toolConfig.response_key, 'nodes');
370
+ assert.strictEqual(toolConfig.mappings.body, 'title');
371
+ assert.strictEqual(toolConfig.mappings.number, 'url:/([A-Z0-9]+-[0-9]+)/');
372
+ });
373
+
374
+ test('getToolProviderConfig merges user config with preset defaults', async () => {
375
+ writeFileSync(configPath, `
376
+ tools:
377
+ linear:
378
+ mappings:
379
+ custom_field: some_source
380
+
381
+ sources: []
382
+ `);
383
+
384
+ const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js');
385
+ loadRepoConfig(configPath);
386
+
387
+ const toolConfig = getToolProviderConfig('linear');
388
+
389
+ // Should have preset response_key
390
+ assert.strictEqual(toolConfig.response_key, 'nodes');
391
+ // Should have preset mappings plus user mappings
392
+ assert.strictEqual(toolConfig.mappings.body, 'title');
393
+ assert.strictEqual(toolConfig.mappings.custom_field, 'some_source');
394
+ });
356
395
  });
357
396
 
358
397
  describe('repo resolution for sources', () => {
@@ -477,4 +516,217 @@ repos:
477
516
  assert.strictEqual(repoKey, null);
478
517
  });
479
518
  });
519
+
520
+ describe('presets', () => {
521
+ test('expands github/my-issues preset', async () => {
522
+ writeFileSync(configPath, `
523
+ sources:
524
+ - preset: github/my-issues
525
+ prompt: worktree
526
+ `);
527
+
528
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
529
+ loadRepoConfig(configPath);
530
+ const sources = getSources();
531
+
532
+ assert.strictEqual(sources.length, 1);
533
+ assert.strictEqual(sources[0].name, 'my-issues');
534
+ assert.deepStrictEqual(sources[0].tool, { mcp: 'github', name: 'search_issues' });
535
+ assert.strictEqual(sources[0].args.q, 'is:issue assignee:@me state:open');
536
+ assert.strictEqual(sources[0].item.id, '{html_url}');
537
+ assert.strictEqual(sources[0].prompt, 'worktree');
538
+ });
539
+
540
+ test('expands github/review-requests preset', async () => {
541
+ writeFileSync(configPath, `
542
+ sources:
543
+ - preset: github/review-requests
544
+ `);
545
+
546
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
547
+ loadRepoConfig(configPath);
548
+ const sources = getSources();
549
+
550
+ assert.strictEqual(sources[0].name, 'review-requests');
551
+ assert.strictEqual(sources[0].args.q, 'is:pr review-requested:@me state:open');
552
+ });
553
+
554
+ test('expands github/my-prs-feedback preset', async () => {
555
+ writeFileSync(configPath, `
556
+ sources:
557
+ - preset: github/my-prs-feedback
558
+ `);
559
+
560
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
561
+ loadRepoConfig(configPath);
562
+ const sources = getSources();
563
+
564
+ assert.strictEqual(sources[0].name, 'my-prs-feedback');
565
+ assert.strictEqual(sources[0].args.q, 'is:pr author:@me state:open review:changes_requested');
566
+ });
567
+
568
+ test('expands linear/my-issues preset with required args', async () => {
569
+ writeFileSync(configPath, `
570
+ sources:
571
+ - preset: linear/my-issues
572
+ args:
573
+ teamId: "team-uuid-123"
574
+ assigneeId: "user-uuid-456"
575
+ `);
576
+
577
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
578
+ loadRepoConfig(configPath);
579
+ const sources = getSources();
580
+
581
+ assert.strictEqual(sources[0].name, 'my-issues');
582
+ assert.deepStrictEqual(sources[0].tool, { mcp: 'linear', name: 'list_issues' });
583
+ assert.strictEqual(sources[0].args.teamId, 'team-uuid-123');
584
+ assert.strictEqual(sources[0].args.assigneeId, 'user-uuid-456');
585
+ });
586
+
587
+ test('user config overrides preset values', async () => {
588
+ writeFileSync(configPath, `
589
+ sources:
590
+ - preset: github/my-issues
591
+ name: custom-name
592
+ args:
593
+ q: "is:issue assignee:@me state:open label:urgent"
594
+ agent: plan
595
+ `);
596
+
597
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
598
+ loadRepoConfig(configPath);
599
+ const sources = getSources();
600
+
601
+ assert.strictEqual(sources[0].name, 'custom-name');
602
+ assert.strictEqual(sources[0].args.q, 'is:issue assignee:@me state:open label:urgent');
603
+ assert.strictEqual(sources[0].agent, 'plan');
604
+ });
605
+
606
+ test('throws error for unknown preset', async () => {
607
+ writeFileSync(configPath, `
608
+ sources:
609
+ - preset: unknown/preset
610
+ `);
611
+
612
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
613
+ loadRepoConfig(configPath);
614
+
615
+ assert.throws(() => getSources(), /Unknown preset: unknown\/preset/);
616
+ });
617
+ });
618
+
619
+ describe('shorthand syntax', () => {
620
+ test('expands github shorthand to full source', async () => {
621
+ writeFileSync(configPath, `
622
+ sources:
623
+ - name: my-issues
624
+ github: "is:issue assignee:@me state:open"
625
+ prompt: worktree
626
+ `);
627
+
628
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
629
+ loadRepoConfig(configPath);
630
+ const sources = getSources();
631
+
632
+ assert.strictEqual(sources.length, 1);
633
+ assert.strictEqual(sources[0].name, 'my-issues');
634
+ assert.deepStrictEqual(sources[0].tool, { mcp: 'github', name: 'search_issues' });
635
+ assert.strictEqual(sources[0].args.q, 'is:issue assignee:@me state:open');
636
+ assert.strictEqual(sources[0].item.id, '{html_url}');
637
+ assert.strictEqual(sources[0].prompt, 'worktree');
638
+ });
639
+
640
+ test('shorthand works with other source fields', async () => {
641
+ writeFileSync(configPath, `
642
+ sources:
643
+ - name: urgent-issues
644
+ github: "is:issue assignee:@me label:urgent"
645
+ agent: plan
646
+ working_dir: ~/code/myproject
647
+ `);
648
+
649
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
650
+ loadRepoConfig(configPath);
651
+ const sources = getSources();
652
+
653
+ assert.strictEqual(sources[0].agent, 'plan');
654
+ assert.strictEqual(sources[0].working_dir, '~/code/myproject');
655
+ });
656
+ });
657
+
658
+ describe('defaults section', () => {
659
+ test('applies defaults to sources without those fields', async () => {
660
+ writeFileSync(configPath, `
661
+ defaults:
662
+ agent: plan
663
+ prompt: default
664
+
665
+ sources:
666
+ - preset: github/my-issues
667
+ - preset: github/review-requests
668
+ prompt: review
669
+ `);
670
+
671
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
672
+ loadRepoConfig(configPath);
673
+ const sources = getSources();
674
+
675
+ // First source gets both defaults
676
+ assert.strictEqual(sources[0].agent, 'plan');
677
+ assert.strictEqual(sources[0].prompt, 'default');
678
+
679
+ // Second source overrides prompt but gets agent default
680
+ assert.strictEqual(sources[1].agent, 'plan');
681
+ assert.strictEqual(sources[1].prompt, 'review');
682
+ });
683
+
684
+ test('source values override defaults', async () => {
685
+ writeFileSync(configPath, `
686
+ defaults:
687
+ agent: plan
688
+ model: claude-3-sonnet
689
+
690
+ sources:
691
+ - preset: github/my-issues
692
+ agent: architect
693
+ `);
694
+
695
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
696
+ loadRepoConfig(configPath);
697
+ const sources = getSources();
698
+
699
+ assert.strictEqual(sources[0].agent, 'architect');
700
+ assert.strictEqual(sources[0].model, 'claude-3-sonnet');
701
+ });
702
+
703
+ test('getDefaults returns defaults section', async () => {
704
+ writeFileSync(configPath, `
705
+ defaults:
706
+ agent: plan
707
+ prompt: default
708
+ working_dir: ~/code
709
+ `);
710
+
711
+ const { loadRepoConfig, getDefaults } = await import('../../service/repo-config.js');
712
+ loadRepoConfig(configPath);
713
+ const defaults = getDefaults();
714
+
715
+ assert.strictEqual(defaults.agent, 'plan');
716
+ assert.strictEqual(defaults.prompt, 'default');
717
+ assert.strictEqual(defaults.working_dir, '~/code');
718
+ });
719
+
720
+ test('getDefaults returns empty object when no defaults', async () => {
721
+ writeFileSync(configPath, `
722
+ sources: []
723
+ `);
724
+
725
+ const { loadRepoConfig, getDefaults } = await import('../../service/repo-config.js');
726
+ loadRepoConfig(configPath);
727
+ const defaults = getDefaults();
728
+
729
+ assert.deepStrictEqual(defaults, {});
730
+ });
731
+ });
480
732
  });