opencode-pilot 0.4.0 → 0.5.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 +18 -1
- package/bin/opencode-pilot +12 -2
- package/examples/config.yaml +69 -56
- package/examples/templates/devcontainer.md +2 -5
- package/package.json +1 -1
- package/service/presets/github.yaml +37 -0
- package/service/presets/index.js +157 -0
- package/service/presets/linear.yaml +19 -0
- package/service/repo-config.js +67 -8
- package/test/unit/repo-config.test.js +253 -1
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
|
-
- **`
|
|
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.
|
package/bin/opencode-pilot
CHANGED
|
@@ -272,10 +272,20 @@ async function configCommand() {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
// Validate sources section
|
|
275
|
+
// Validate sources section (use getSources to expand presets)
|
|
276
276
|
console.log("");
|
|
277
277
|
console.log("Sources:");
|
|
278
|
-
const
|
|
278
|
+
const { loadRepoConfig, getSources } = await import(join(serviceDir, "repo-config.js"));
|
|
279
|
+
loadRepoConfig(PILOT_CONFIG_FILE);
|
|
280
|
+
|
|
281
|
+
let sources;
|
|
282
|
+
try {
|
|
283
|
+
sources = getSources();
|
|
284
|
+
} catch (err) {
|
|
285
|
+
console.log(` ✗ Failed to load sources: ${err.message}`);
|
|
286
|
+
sources = [];
|
|
287
|
+
}
|
|
288
|
+
|
|
279
289
|
if (sources.length === 0) {
|
|
280
290
|
console.log(" (none configured)");
|
|
281
291
|
} else {
|
package/examples/config.yaml
CHANGED
|
@@ -4,75 +4,88 @@
|
|
|
4
4
|
# Also create templates/ directory with prompt template files
|
|
5
5
|
|
|
6
6
|
# =============================================================================
|
|
7
|
-
#
|
|
7
|
+
# DEFAULTS - Applied to all sources (source values override these)
|
|
8
8
|
# =============================================================================
|
|
9
|
-
|
|
9
|
+
defaults:
|
|
10
|
+
agent: plan # Default agent for all sources
|
|
11
|
+
prompt: default # Default prompt template
|
|
10
12
|
|
|
11
13
|
# =============================================================================
|
|
12
|
-
#
|
|
14
|
+
# SOURCES - What to poll
|
|
13
15
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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: "
|
|
74
|
-
item:
|
|
75
|
-
id: "linear:{id}"
|
|
42
|
+
status: "In Progress"
|
|
76
43
|
working_dir: ~/code/myproject
|
|
77
44
|
prompt: worktree
|
|
78
|
-
|
|
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
|
@@ -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}"
|
package/service/repo-config.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|