From 805dc409bd8573d2cf849e592624ce7f4deb584b Mon Sep 17 00:00:00 2001 From: Brian Hann Date: Mon, 23 Feb 2026 16:12:11 -0600 Subject: [PATCH 1/2] feat(presets): add jira cloud my-issues source support --- README.md | 62 +++++++++++++ examples/config.yaml | 33 ++++++- service/presets/index.js | 2 +- service/presets/jira.yaml | 27 ++++++ test/unit/poll-service.test.js | 56 ++++++++++++ test/unit/poller.test.js | 162 +++++++++++++++++++++++++++++++++ test/unit/repo-config.test.js | 87 +++++++++++++++++- 7 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 service/presets/jira.yaml diff --git a/README.md b/README.md index 6b1285e..569e7b2 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**: OAuth 2.0 (3LO) and Jira Server/Data Center are out of scope for this v1 feature. + +#### 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/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..6bdc229 --- /dev/null +++ b/service/presets/jira.yaml @@ -0,0 +1,27 @@ +# Jira source presets + +# Provider-level config (applies to all Jira presets) +_provider: + mappings: + title: fields.summary + number: key + state: fields.status.name + updated_at: fields.updated + # 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: "{fields.summary}" 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..ca73131 100644 --- a/test/unit/poller.test.js +++ b/test/unit/poller.test.js @@ -1150,6 +1150,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 +1312,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 +1407,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..1756355 100644 --- a/test/unit/repo-config.test.js +++ b/test/unit/repo-config.test.js @@ -495,6 +495,24 @@ 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.mappings.title, 'fields.summary'); + assert.strictEqual(toolConfig.mappings.number, 'key'); + assert.strictEqual(toolConfig.mappings.state, 'fields.status.name'); + assert.strictEqual(toolConfig.mappings.updated_at, 'fields.updated'); + assert.ok(Array.isArray(toolConfig.reprocess_on)); + assert.ok(toolConfig.reprocess_on.includes('state')); + }); test('getToolProviderConfig merges user config with preset defaults', async () => { writeFileSync(configPath, ` @@ -517,8 +535,33 @@ 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.mappings.title, 'fields.summary'); + assert.strictEqual(toolConfig.mappings.number, 'key'); + assert.strictEqual(toolConfig.mappings.state, 'fields.status.name'); + assert.strictEqual(toolConfig.mappings.updated_at, 'fields.updated'); + assert.strictEqual(toolConfig.mappings.priority, 'fields.priority.name'); + // Should have preset reprocess_on + assert.ok(Array.isArray(toolConfig.reprocess_on)); + assert.ok(toolConfig.reprocess_on.includes('state')); }); + }); describe('repo resolution for sources', () => { test('resolves repo from simple field reference', async () => { writeFileSync(configPath, ` @@ -719,6 +762,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, '{fields.summary}'); + }); test('user config overrides preset values', async () => { writeFileSync(configPath, ` @@ -950,8 +1010,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, ` From 34598f3dc6b61cd46297bc394685c29e3eb5e1b5 Mon Sep 17 00:00:00 2001 From: Brian Hann Date: Wed, 25 Feb 2026 14:49:10 -0600 Subject: [PATCH 2/2] Fix Jira source --- README.md | 2 +- package-lock.json | 8 ++++++ service/actions.js | 2 +- service/poller.js | 50 +++++++++++++++++++++++++++++++---- service/presets/jira.yaml | 24 ++++++++++++++--- service/repo-config.js | 26 +++++++++++++----- test/unit/poller.test.js | 11 ++++++++ test/unit/repo-config.test.js | 20 +++++++------- 8 files changed, 116 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 569e7b2..b5cf767 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Jira Cloud requires a personal API token for authentication. Passwords are no lo 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**: OAuth 2.0 (3LO) and Jira Server/Data Center are out of scope for this v1 feature. +> **Note**: Using OAuth 2.0 (3LO) and Jira Server/Data Center are not possible currently. #### Configuration 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/jira.yaml b/service/presets/jira.yaml index 6bdc229..5640b6d 100644 --- a/service/presets/jira.yaml +++ b/service/presets/jira.yaml @@ -2,11 +2,13 @@ # Provider-level config (applies to all Jira presets) _provider: + response_key: issues mappings: - title: fields.summary + title: summary number: key - state: fields.status.name - updated_at: fields.updated + 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: @@ -24,4 +26,18 @@ my-issues: id: "jira:{key}" worktree_name: "{key}" session: - name: "{fields.summary}" + 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/poller.test.js b/test/unit/poller.test.js index ca73131..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'); diff --git a/test/unit/repo-config.test.js b/test/unit/repo-config.test.js index 1756355..48a2a9a 100644 --- a/test/unit/repo-config.test.js +++ b/test/unit/repo-config.test.js @@ -506,12 +506,13 @@ sources: [] // Jira preset has provider config with mappings and reprocess_on const toolConfig = getToolProviderConfig('jira'); - assert.strictEqual(toolConfig.mappings.title, 'fields.summary'); + assert.strictEqual(toolConfig.response_key, 'issues'); + assert.strictEqual(toolConfig.mappings.title, 'summary'); assert.strictEqual(toolConfig.mappings.number, 'key'); - assert.strictEqual(toolConfig.mappings.state, 'fields.status.name'); - assert.strictEqual(toolConfig.mappings.updated_at, 'fields.updated'); + 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)); - assert.ok(toolConfig.reprocess_on.includes('state')); }); test('getToolProviderConfig merges user config with preset defaults', async () => { @@ -551,14 +552,15 @@ sources: [] const toolConfig = getToolProviderConfig('jira'); // Should have preset mappings plus user mappings - assert.strictEqual(toolConfig.mappings.title, 'fields.summary'); + assert.strictEqual(toolConfig.response_key, 'issues'); + assert.strictEqual(toolConfig.mappings.title, 'summary'); assert.strictEqual(toolConfig.mappings.number, 'key'); - assert.strictEqual(toolConfig.mappings.state, 'fields.status.name'); - assert.strictEqual(toolConfig.mappings.updated_at, 'fields.updated'); + 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)); - assert.ok(toolConfig.reprocess_on.includes('state')); }); }); @@ -777,7 +779,7 @@ sources: 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, '{fields.summary}'); + assert.strictEqual(sources[0].session.name, '{summary}'); }); test('user config overrides preset values', async () => {