diff --git a/README.md b/README.md index 6b1285e..b5cf767 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,71 @@ Three ways to configure sources, from simplest to most flexible: - `github/review-requests` - PRs needing my review - `github/my-prs-attention` - My PRs needing attention (conflicts OR human feedback) - `linear/my-issues` - Linear tickets (requires `teamId`, `assigneeId`) +- `jira/my-issues` - Jira Cloud issues (requires personal API token) Session names for `my-prs-attention` indicate the condition: "Conflicts: {title}", "Feedback: {title}", or "Conflicts+Feedback: {title}". +### Jira Cloud Setup + +The `jira/my-issues` preset polls Jira Cloud for issues assigned to you. It uses the `mcp-atlassian` MCP server with the `jira_search` tool. + +#### API Token Authentication + +Jira Cloud requires a personal API token for authentication. Passwords are no longer supported. + +1. Generate an API token at [Atlassian Account Security](https://id.atlassian.com/manage/api-tokens) +2. Copy the token (you won't see it again) +3. Configure the `mcp-atlassian` MCP server with your email and token + +The MCP server handles authentication using your email address as the username and the API token as the password. This approach is more secure than using your account password. + +> **Note**: Using OAuth 2.0 (3LO) and Jira Server/Data Center are not possible currently. + +#### Configuration + +Using the preset in your config: + +```yaml +sources: + - preset: jira/my-issues +``` + +The preset uses the default MCP contract: +- **MCP server**: `mcp-atlassian` +- **Tool**: `jira_search` +- **Default JQL**: `assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC` + +This query finds issues assigned to you, filters out completed work, and prioritizes recently updated items. + +#### Custom JQL Queries + +Customize the JQL query by using the full syntax instead of the preset: + +```yaml +sources: + - tool: + mcp: mcp-atlassian + name: jira_search + args: + jql: "project = MYPROJECT AND priority in (High, Critical) AND assignee = currentUser()" +``` + +Use JQL to match your workflow, filter by project, priority, labels, or any other Jira field. + +#### Alternative MCP Servers + +If your Jira MCP server uses a different name, override the default contract: + +```yaml +sources: + - preset: jira/my-issues + tool: + mcp: your-jira-mcp-server + name: your_jira_search_tool +``` + +This ensures pilot connects to your MCP server using the correct server and tool names. + ### Prompt Templates Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc. diff --git a/examples/config.yaml b/examples/config.yaml index 42125bd..7626eee 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -57,6 +57,15 @@ sources: working_dir: ~/code/myproject prompt: worktree + # Jira (requires mcp-atlassian MCP server) + - preset: jira/my-issues + args: + # Optional: custom JQL query (default: assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC) + jql: "assignee = currentUser() AND status = \"In Progress\" ORDER BY priority DESC, updated DESC" + # Jira issues are repo-agnostic - specify working_dir to set a default project location + working_dir: ~/code/myproject + prompt: worktree + # Example with worktree support - run each issue in a fresh git worktree # - preset: github/my-issues # worktree: "new" # Create a new worktree per session @@ -83,6 +92,19 @@ sources: # id: "reminder:{id}" # session: # name: "{title}" # Optional: custom session name (presets have semantic defaults) + + # Multiple Jira sources with different filters + # - preset: jira/my-issues + # name: urgent-jira + # args: + # jql: "priority = High AND statusCategory != Done" + # repos: + # - myorg/backend # Restrict to specific repos (optional) + # - preset: jira/my-issues + # name: team-jira + # args: + # jql: "component = 'Backend' AND assignee = currentUser() AND statusCategory != Done" + # working_dir: ~/code/backend # Tool config for custom MCP servers (GitHub/Linear have built-in config) # tools: @@ -91,6 +113,15 @@ sources: # mappings: # title: name # body: notes +# jira: +# response_key: issues +# mappings: +# summary: fields.summary +# key: key +# status: fields.status.name +# updated: fields.updated +# # Override default JQL if your MCP server uses a different tool name +# # tool_name: jira_search # Explicit repo mappings (overrides repos_dir auto-discovery) # Only needed if a repo isn't in repos_dir or needs custom settings @@ -103,4 +134,4 @@ sources: # ttl_days: 30 # Available presets: github/my-issues, github/review-requests, -# github/my-prs-attention, linear/my-issues +# github/my-prs-attention, linear/my-issues, jira/my-issues diff --git a/package-lock.json b/package-lock.json index 6837828..47c8dcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -213,6 +213,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -2120,6 +2121,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3215,6 +3217,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -5467,6 +5470,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6273,6 +6277,7 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -6327,6 +6332,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -7243,6 +7249,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7670,6 +7677,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/service/actions.js b/service/actions.js index be3ce36..e43db74 100644 --- a/service/actions.js +++ b/service/actions.js @@ -33,7 +33,7 @@ import os from "os"; // The /command endpoint can take 30-45s to return headers because it does // work before responding. The /message endpoint returns headers in ~1ms. // These are generous upper bounds — if exceeded, the server is genuinely stuck. -export const HEADER_TIMEOUT_MS = 60_000; +export const HEADER_TIMEOUT_MS = 180_000; /** * Parse a slash command from the beginning of a prompt diff --git a/service/poller.js b/service/poller.js index 08ba94f..6a22c18 100644 --- a/service/poller.js +++ b/service/poller.js @@ -172,6 +172,15 @@ async function createTransport(mcpConfig) { } } + // Infer type from config structure if not explicitly set + if (!mcpConfig.type) { + if (mcpConfig.command) { + mcpConfig.type = "local"; + } else if (mcpConfig.url) { + mcpConfig.type = "remote"; + } + } + if (mcpConfig.type === "remote") { const url = new URL(mcpConfig.url); if (mcpConfig.url.includes("linear.app/sse")) { @@ -181,14 +190,45 @@ async function createTransport(mcpConfig) { } } else if (mcpConfig.type === "local") { const command = mcpConfig.command; + const extraArgs = Array.isArray(mcpConfig.args) ? mcpConfig.args : []; if (!command || command.length === 0) { throw new Error("Local MCP config missing command"); } - const [cmd, ...args] = command; + + let cmd; + let args; + + if (Array.isArray(command)) { + [cmd, ...args] = command; + args = [...args, ...extraArgs]; + } else { + // If args are provided separately (OpenCode/Claude-style MCP config), + // use command as-is and pass args separately. + if (extraArgs.length > 0) { + cmd = command; + args = extraArgs; + } else { + // Backward-compatible fallback for legacy single-string commands. + [cmd, ...args] = command.trim().split(/\s+/); + } + } + + if (!cmd) { + throw new Error("Local MCP config has empty command"); + } + + const expandedEnv = {}; + const localEnv = mcpConfig.env || mcpConfig.environment; + if (localEnv) { + for (const [key, value] of Object.entries(localEnv)) { + expandedEnv[key] = typeof value === "string" ? expandEnvVars(value) : value; + } + } + return new StdioClientTransport({ command: cmd, args, - env: { ...process.env }, + env: { ...process.env, ...expandedEnv }, }); } @@ -1230,9 +1270,9 @@ export function createPoller(options = {}) { const storedState = meta.itemState; const currentState = item[field]; - if (storedState && currentState) { - const stored = storedState.toLowerCase(); - const current = currentState.toLowerCase(); + if (storedState != null && currentState != null) { + const stored = String(storedState).toLowerCase(); + const current = String(currentState).toLowerCase(); // Reopened: was closed/merged/done, now open/in-progress if ((stored === 'closed' || stored === 'merged' || stored === 'done') diff --git a/service/presets/index.js b/service/presets/index.js index 04efbe3..66da580 100644 --- a/service/presets/index.js +++ b/service/presets/index.js @@ -60,7 +60,7 @@ function loadProviderData(provider) { */ function buildPresetsRegistry() { const registry = {}; - const providers = ["github", "linear"]; + const providers = ["github", "linear", "jira"]; for (const provider of providers) { const { presets } = loadProviderData(provider); diff --git a/service/presets/jira.yaml b/service/presets/jira.yaml new file mode 100644 index 0000000..5640b6d --- /dev/null +++ b/service/presets/jira.yaml @@ -0,0 +1,43 @@ +# Jira source presets + +# Provider-level config (applies to all Jira presets) +_provider: + response_key: issues + mappings: + title: summary + number: key + state: status + updated_at: updated + body: description + # Reprocess items when state changes (e.g., reopened, status transitions) + # Note: updated_at is NOT included because our own changes would trigger reprocessing + reprocess_on: + - state # Detect status changes (Done -> In Progress, etc.) + +# Presets +my-issues: + name: jira-my-issues + tool: + mcp: mcp-atlassian + name: jira_search + args: + jql: "assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC" + item: + id: "jira:{key}" + worktree_name: "{key}" + session: + name: "{summary}" + + +research: + name: jira-research + tool: + mcp: mcp-atlassian + name: jira_search + args: + jql: "assignee = EMPTY and status = \"Triage\" and labels in (ai-research) ORDER BY priority DESC, updated DESC" + item: + id: "jira:{key}" + worktree_name: "{key}" + session: + name: "{summary}" diff --git a/service/repo-config.js b/service/repo-config.js index fcc9717..6735e59 100644 --- a/service/repo-config.js +++ b/service/repo-config.js @@ -275,12 +275,18 @@ export function getToolMappings(provider) { const config = getRawConfig(); const tools = config.tools || {}; const toolConfig = tools[provider]; + const providerAliases = { + "mcp-atlassian": "jira", + }; + const aliasedProvider = providerAliases[provider]; + const aliasedToolConfig = aliasedProvider ? tools[aliasedProvider] : null; + const effectiveToolConfig = toolConfig || aliasedToolConfig; - if (!toolConfig || !toolConfig.mappings) { + if (!effectiveToolConfig || !effectiveToolConfig.mappings) { return null; } - return toolConfig.mappings; + return effectiveToolConfig.mappings; } /** @@ -293,24 +299,30 @@ export function getToolProviderConfig(provider) { const config = getRawConfig(); const tools = config.tools || {}; const userToolConfig = tools[provider]; + const providerAliases = { + "mcp-atlassian": "jira", + }; + const aliasedProvider = providerAliases[provider]; + const aliasedUserToolConfig = aliasedProvider ? tools[aliasedProvider] : null; + const effectiveUserToolConfig = userToolConfig || aliasedUserToolConfig; // Get preset provider config as fallback - const presetProviderConfig = getProviderConfig(provider); + const presetProviderConfig = getProviderConfig(provider) || (aliasedProvider ? getProviderConfig(aliasedProvider) : null); // If user has config, merge with preset defaults (user takes precedence) - if (userToolConfig) { + if (effectiveUserToolConfig) { if (presetProviderConfig) { return { ...presetProviderConfig, - ...userToolConfig, + ...effectiveUserToolConfig, // Deep merge mappings mappings: { ...(presetProviderConfig.mappings || {}), - ...(userToolConfig.mappings || {}), + ...(effectiveUserToolConfig.mappings || {}), }, }; } - return userToolConfig; + return effectiveUserToolConfig; } // Fall back to preset provider config diff --git a/test/unit/poll-service.test.js b/test/unit/poll-service.test.js index 287785b..ab4c409 100644 --- a/test/unit/poll-service.test.js +++ b/test/unit/poll-service.test.js @@ -86,6 +86,26 @@ sources: const missingName = { name: 'test', tool: { mcp: 'github' } }; assert.strictEqual(hasToolConfig(missingName), false); }); + + test('hasToolConfig accepts Jira MCP tool config', async () => { + const { hasToolConfig } = await import('../../service/poll-service.js'); + + // Valid Jira MCP config using mcp-atlassian + const jiraMcp = { + name: 'jira-issues', + tool: { mcp: 'mcp-atlassian', name: 'jira_search' }, + args: { jql: 'assignee = currentUser() AND statusCategory != Done' } + }; + assert.strictEqual(hasToolConfig(jiraMcp), true); + + // Jira MCP with different server name (override default) + const customJiraMcp = { + name: 'custom-jira', + tool: { mcp: 'my-jira-server', name: 'custom_jira_search' }, + args: {} + }; + assert.strictEqual(hasToolConfig(customJiraMcp), true); + }); }); describe('buildActionConfigFromSource', () => { @@ -378,5 +398,41 @@ sources: // Should use source working_dir since repo not in config assert.strictEqual(actionConfig.working_dir, '~/default/path'); }); + + test('builds action config for repo-agnostic Jira source', async () => { + const config = ` +sources: + - preset: jira/my-issues + working_dir: ~/projects/jira-work +`; + writeFileSync(configPath, config); + + const { loadRepoConfig } = await import('../../service/repo-config.js'); + const { buildActionConfigForItem } = await import('../../service/poll-service.js'); + loadRepoConfig(configPath); + + // Jira source - repo-agnostic (no repo template in item) + const source = { + name: 'jira-issues', + tool: { mcp: 'mcp-atlassian', name: 'jira_search' }, + working_dir: '~/projects/jira-work' + }; + // Jira item format: jira:{key}, no repository context + const item = { + id: 'jira:PROJ-123', + key: 'PROJ-123', + fields: { + summary: 'Fix authentication bug', + status: { name: 'In Progress' } + } + }; + + const actionConfig = buildActionConfigForItem(source, item); + + // Should use source working_dir since Jira is repo-agnostic + assert.strictEqual(actionConfig.working_dir, '~/projects/jira-work'); + // repo_path is set from working_dir (normalized by buildActionConfigFromSource) + assert.strictEqual(actionConfig.repo_path, '~/projects/jira-work'); + }); }); }); diff --git a/test/unit/poller.test.js b/test/unit/poller.test.js index f28c976..c60c957 100644 --- a/test/unit/poller.test.js +++ b/test/unit/poller.test.js @@ -623,6 +623,17 @@ describe('poller.js', () => { assert.strictEqual(poller.shouldReprocess(item), true); }); + test('shouldReprocess handles non-string state values without throwing', async () => { + const { createPoller } = await import('../../service/poller.js'); + + const poller = createPoller({ stateFile }); + poller.markProcessed('issue-obj', { source: 'test', itemState: { name: 'closed' } }); + + const item = { id: 'issue-obj', state: { name: 'open' } }; + assert.doesNotThrow(() => poller.shouldReprocess(item)); + assert.strictEqual(poller.shouldReprocess(item), false); + }); + test('shouldReprocess does NOT check updated_at by default (avoids self-triggering)', async () => { const { createPoller } = await import('../../service/poller.js'); @@ -1150,6 +1161,75 @@ describe('poller.js', () => { test('extracts value using regex syntax', async () => { const { applyMappings } = await import('../../service/poller.js'); + test('generates stable ID from Jira key', async () => { + const { transformItems } = await import('../../service/poller.js'); + + const items = [ + { key: 'PROJ-100', fields: { summary: 'Issue A' } }, + { key: 'PROJ-200', fields: { summary: 'Issue B' } } + ]; + const idTemplate = 'jira:{key}'; + + const transformed = transformItems(items, idTemplate); + + assert.strictEqual(transformed[0].id, 'jira:PROJ-100'); + assert.strictEqual(transformed[1].id, 'jira:PROJ-200'); + // Original fields preserved + assert.strictEqual(transformed[0].fields.summary, 'Issue A'); + }); + + test('combines Jira mappings with ID generation', async () => { + const { transformItems, applyMappings } = await import('../../service/poller.js'); + + const items = [ + { + key: 'PROJ-1', + fields: { + summary: 'Authentication issue', + status: { name: 'To Do' }, + updated: '2026-02-20T09:00:00Z' + } + }, + { + key: 'PROJ-2', + fields: { + summary: 'Performance improvement', + status: { name: 'In Review' }, + updated: '2026-02-21T16:30:00Z' + } + } + ]; + // Jira provider mappings + const mappings = { + title: 'fields.summary', + number: 'key', + state: 'fields.status.name', + updated_at: 'fields.updated' + }; + const idTemplate = 'jira:{key}'; + + // Apply mappings, then transform + const mappedItems = items.map(item => applyMappings(item, mappings)); + const transformed = transformItems(mappedItems, idTemplate); + + // Check first item + assert.strictEqual(transformed[0].id, 'jira:PROJ-1'); + assert.strictEqual(transformed[0].number, 'PROJ-1'); + assert.strictEqual(transformed[0].title, 'Authentication issue'); + assert.strictEqual(transformed[0].state, 'To Do'); + assert.strictEqual(transformed[0].updated_at, '2026-02-20T09:00:00Z'); + + // Check second item + assert.strictEqual(transformed[1].id, 'jira:PROJ-2'); + assert.strictEqual(transformed[1].number, 'PROJ-2'); + assert.strictEqual(transformed[1].title, 'Performance improvement'); + assert.strictEqual(transformed[1].state, 'In Review'); + assert.strictEqual(transformed[1].updated_at, '2026-02-21T16:30:00Z'); + + // Original fields preserved + assert.strictEqual(transformed[0].key, 'PROJ-1'); + assert.strictEqual(transformed[0].fields.summary, 'Authentication issue'); + }); const item = { title: 'Fix the bug', url: 'https://linear.app/0din/issue/0DIN-683/attack-technique-detection' @@ -1243,6 +1323,60 @@ describe('poller.js', () => { test('extracts array using response_key', async () => { const { parseJsonArray } = await import('../../service/poller.js'); + test('maps Jira fields to normalized fields', async () => { + const { applyMappings } = await import('../../service/poller.js'); + + const item = { + key: 'PROJ-456', + fields: { + summary: 'Implement new feature', + status: { name: 'In Progress' }, + updated: '2026-02-23T14:22:00Z' + } + }; + // Jira provider mappings from preset + const mappings = { + title: 'fields.summary', + number: 'key', + state: 'fields.status.name', + updated_at: 'fields.updated' + }; + + const mapped = applyMappings(item, mappings); + + assert.strictEqual(mapped.number, 'PROJ-456'); + assert.strictEqual(mapped.title, 'Implement new feature'); + assert.strictEqual(mapped.state, 'In Progress'); + assert.strictEqual(mapped.updated_at, '2026-02-23T14:22:00Z'); + // Original fields preserved + assert.strictEqual(mapped.key, 'PROJ-456'); + assert.strictEqual(mapped.fields.summary, 'Implement new feature'); + }); + + test('handles missing Jira fields gracefully', async () => { + const { applyMappings } = await import('../../service/poller.js'); + + const item = { + key: 'PROJ-789', + fields: { + summary: 'Fix bug' + // status and updated missing + } + }; + const mappings = { + title: 'fields.summary', + number: 'key', + state: 'fields.status.name', + updated_at: 'fields.updated' + }; + + const mapped = applyMappings(item, mappings); + + assert.strictEqual(mapped.number, 'PROJ-789'); + assert.strictEqual(mapped.title, 'Fix bug'); + assert.strictEqual(mapped.state, undefined); + assert.strictEqual(mapped.updated_at, undefined); + }); const text = JSON.stringify({ reminders: [ { id: 'reminder-1', name: 'Task 1', completed: false }, @@ -1284,6 +1418,45 @@ describe('poller.js', () => { const text = JSON.stringify({ items: [{ id: '1' }] }); const result = parseJsonArray(text, 'test', 'reminders'); + assert.strictEqual(result.length, 0); + }); + test('extracts Jira issues array from mcp-atlassian response', async () => { + const { parseJsonArray } = await import('../../service/poller.js'); + + // Jira mcp-atlassian jira_search returns array at root (no wrapper) + const text = JSON.stringify([ + { + key: 'PROJ-123', + fields: { + summary: 'Fix authentication bug', + status: { name: 'In Progress' }, + updated: '2026-02-23T10:30:00Z' + } + }, + { + key: 'PROJ-124', + fields: { + summary: 'Add user preferences', + status: { name: 'To Do' }, + updated: '2026-02-22T15:45:00Z' + } + } + ]); + const result = parseJsonArray(text, 'jira/my-issues'); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].key, 'PROJ-123'); + assert.strictEqual(result[0].fields.summary, 'Fix authentication bug'); + assert.strictEqual(result[1].key, 'PROJ-124'); + assert.strictEqual(result[1].fields.summary, 'Add user preferences'); + }); + + test('handles empty Jira search results', async () => { + const { parseJsonArray } = await import('../../service/poller.js'); + + const text = JSON.stringify([]); + const result = parseJsonArray(text, 'jira/my-issues'); + assert.strictEqual(result.length, 0); }); }); diff --git a/test/unit/repo-config.test.js b/test/unit/repo-config.test.js index 9fb5c11..48a2a9a 100644 --- a/test/unit/repo-config.test.js +++ b/test/unit/repo-config.test.js @@ -495,6 +495,25 @@ sources: [] assert.strictEqual(toolConfig.mappings.body, 'title'); assert.strictEqual(toolConfig.mappings.number, 'url:/([A-Z0-9]+-[0-9]+)/'); }); + test('getToolProviderConfig falls back to jira preset provider config', async () => { + writeFileSync(configPath, ` +sources: [] +`); + + const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js'); + loadRepoConfig(configPath); + + // Jira preset has provider config with mappings and reprocess_on + const toolConfig = getToolProviderConfig('jira'); + + assert.strictEqual(toolConfig.response_key, 'issues'); + assert.strictEqual(toolConfig.mappings.title, 'summary'); + assert.strictEqual(toolConfig.mappings.number, 'key'); + assert.strictEqual(toolConfig.mappings.state, 'status'); + assert.strictEqual(toolConfig.mappings.updated_at, 'updated'); + assert.strictEqual(toolConfig.mappings.body, 'description'); + assert.ok(Array.isArray(toolConfig.reprocess_on)); + }); test('getToolProviderConfig merges user config with preset defaults', async () => { writeFileSync(configPath, ` @@ -517,8 +536,34 @@ sources: [] assert.strictEqual(toolConfig.mappings.body, 'title'); assert.strictEqual(toolConfig.mappings.custom_field, 'some_source'); }); + test('getToolProviderConfig merges user jira config with preset defaults', async () => { + writeFileSync(configPath, ` +tools: + jira: + mappings: + priority: fields.priority.name + +sources: [] +`); + + const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js'); + loadRepoConfig(configPath); + + const toolConfig = getToolProviderConfig('jira'); + + // Should have preset mappings plus user mappings + assert.strictEqual(toolConfig.response_key, 'issues'); + assert.strictEqual(toolConfig.mappings.title, 'summary'); + assert.strictEqual(toolConfig.mappings.number, 'key'); + assert.strictEqual(toolConfig.mappings.state, 'status'); + assert.strictEqual(toolConfig.mappings.updated_at, 'updated'); + assert.strictEqual(toolConfig.mappings.body, 'description'); + assert.strictEqual(toolConfig.mappings.priority, 'fields.priority.name'); + // Should have preset reprocess_on + assert.ok(Array.isArray(toolConfig.reprocess_on)); }); + }); describe('repo resolution for sources', () => { test('resolves repo from simple field reference', async () => { writeFileSync(configPath, ` @@ -719,6 +764,23 @@ sources: assert.strictEqual(sources[0].args.teamId, 'team-uuid-123'); assert.strictEqual(sources[0].args.assigneeId, 'user-uuid-456'); }); + test('expands jira/my-issues preset', async () => { + writeFileSync(configPath, ` +sources: + - preset: jira/my-issues +`); + + const { loadRepoConfig, getSources } = await import('../../service/repo-config.js'); + loadRepoConfig(configPath); + const sources = getSources(); + + assert.strictEqual(sources.length, 1); + assert.strictEqual(sources[0].name, 'jira-my-issues'); + assert.deepStrictEqual(sources[0].tool, { mcp: 'mcp-atlassian', name: 'jira_search' }); + assert.strictEqual(sources[0].item.id, 'jira:{key}'); + assert.strictEqual(sources[0].worktree_name, '{key}'); + assert.strictEqual(sources[0].session.name, '{summary}'); + }); test('user config overrides preset values', async () => { writeFileSync(configPath, ` @@ -950,8 +1012,33 @@ sources: assert.strictEqual(sources[1].name, 'github-my-issues'); }); - }); + test('github, linear, and jira my-issues presets have distinct names when used together', async () => { + writeFileSync(configPath, ` +sources: + - preset: jira/my-issues + - preset: linear/my-issues + args: + teamId: "team-uuid" + assigneeId: "user-uuid" + - preset: github/my-issues +`); + + const { loadRepoConfig, getSources } = await import('../../service/repo-config.js'); + loadRepoConfig(configPath); + const sources = getSources(); + + assert.strictEqual(sources.length, 3); + assert.strictEqual(sources[0].name, 'jira-my-issues'); + assert.strictEqual(sources[1].name, 'linear-my-issues'); + assert.strictEqual(sources[2].name, 'github-my-issues'); + + // Verify all three names are different + assert.notStrictEqual(sources[0].name, sources[1].name); + assert.notStrictEqual(sources[1].name, sources[2].name); + assert.notStrictEqual(sources[0].name, sources[2].name); + }); + }); describe('shorthand syntax', () => { test('expands github shorthand to full source', async () => { writeFileSync(configPath, `