Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 32 additions & 1 deletion examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion service/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 45 additions & 5 deletions service/poller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -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 },
});
}

Expand Down Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion service/presets/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
43 changes: 43 additions & 0 deletions service/presets/jira.yaml
Original file line number Diff line number Diff line change
@@ -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}"
26 changes: 19 additions & 7 deletions service/repo-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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
Expand Down
Loading