use anyhow::{Context, Result};
use rmcp::{
handler::server::wrapper::Parameters,
model::{CallToolResult, Content},
schemars, tool, tool_router, ErrorData as McpError,
};
use serde::Deserialize;
use crate::atlassian::adf::AdfDocument;
use crate::atlassian::client::{AtlassianClient, JiraTransition};
use crate::atlassian::convert::markdown_to_adf;
use crate::atlassian::document::{issue_to_jfm_document, JfmDocument};
use crate::cli::atlassian::helpers::create_client;
use super::error::tool_error;
use super::output_file::write_to_file_yaml;
use super::server::OmniDevServer;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct JiraReadParams {
pub key: String,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub output_file: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct JiraSearchParams {
pub jql: String,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub fields: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct JiraCreateParams {
pub project: String,
pub summary: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub issue_type: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct JiraWriteParams {
pub key: String,
pub content: String,
#[serde(default)]
pub format: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct JiraTransitionParams {
pub key: String,
#[serde(default)]
pub transition: Option<String>,
#[serde(default)]
pub comment: Option<String>,
#[serde(default)]
pub list: Option<bool>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct JiraCommentParams {
pub key: String,
pub action: String,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct JiraLinkParams {
pub action: String,
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub link_type: Option<String>,
#[serde(default)]
pub link_id: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct JiraDevParams {
pub key: String,
}
#[derive(Debug)]
enum ReadFormat {
Jfm,
Adf,
}
impl ReadFormat {
fn parse(raw: Option<&str>) -> Result<Self> {
match raw.map(str::to_ascii_lowercase).as_deref() {
None | Some("jfm") => Ok(Self::Jfm),
Some("adf") => Ok(Self::Adf),
Some(other) => anyhow::bail!("unknown format {other:?} (expected 'jfm' or 'adf')"),
}
}
fn label(&self) -> &'static str {
match self {
Self::Jfm => "jfm",
Self::Adf => "adf",
}
}
}
fn yaml_result<T: serde::Serialize>(data: &T) -> Result<String> {
serde_yaml::to_string(data).context("Failed to serialize result as YAML")
}
fn ok_text(text: String) -> Result<CallToolResult, McpError> {
Ok(CallToolResult::success(vec![Content::text(text)]))
}
async fn run_jira_read(
client: &AtlassianClient,
instance_url: &str,
key: &str,
format: ReadFormat,
output_file: Option<&str>,
) -> Result<String> {
let issue = client.get_issue(key).await?;
let rendered = render_jira_issue(&issue, instance_url, &format)?;
match output_file {
Some(path) => write_to_file_yaml(path, &rendered, format.label()),
None => Ok(rendered),
}
}
fn render_jira_issue(
issue: &crate::atlassian::client::JiraIssue,
instance_url: &str,
format: &ReadFormat,
) -> Result<String> {
match format {
ReadFormat::Jfm => issue_to_jfm_document(issue, instance_url)?.render(),
ReadFormat::Adf => {
let adf = issue
.description_adf
.clone()
.unwrap_or(serde_json::Value::Null);
serde_json::to_string_pretty(&adf).context("Failed to serialize ADF JSON")
}
}
}
async fn run_jira_search(client: &AtlassianClient, jql: &str, limit: u32) -> Result<String> {
let result = client.search_issues(jql, limit).await?;
yaml_result(&result)
}
async fn run_jira_create(
client: &AtlassianClient,
project: &str,
summary: &str,
description: Option<&str>,
issue_type: &str,
) -> Result<String> {
let adf = match description {
Some(md) if !md.is_empty() => Some(markdown_to_adf(md)?),
_ => None,
};
let created = client
.create_issue(project, issue_type, summary, adf.as_ref(), &[])
.await?;
yaml_result(&created)
}
async fn run_jira_write(
client: &AtlassianClient,
key: &str,
content: &str,
format: ReadFormat,
) -> Result<String> {
let adf: AdfDocument = match format {
ReadFormat::Jfm => {
if content.starts_with("---\n") {
let doc = JfmDocument::parse(content)?;
markdown_to_adf(&doc.body)?
} else {
markdown_to_adf(content)?
}
}
ReadFormat::Adf => serde_json::from_str(content).context("Failed to parse ADF JSON")?,
};
client.update_issue(key, &adf, None).await?;
Ok(format!("Updated {key}.\n"))
}
async fn run_jira_transition(
client: &AtlassianClient,
key: &str,
transition: Option<&str>,
comment: Option<&str>,
list: bool,
) -> Result<String> {
let transitions = client.get_transitions(key).await?;
if list || transition.is_none() {
return yaml_result(&transitions);
}
let target = transition.unwrap_or_default();
let matched = resolve_transition(target, &transitions)?.clone();
client.do_transition(key, &matched.id).await?;
if let Some(body) = comment.filter(|s| !s.is_empty()) {
let adf = markdown_to_adf(body)?;
client.add_comment(key, &adf).await?;
}
Ok(format!(
"Transitioned {key} to \"{name}\" (id: {id}).\n",
name = matched.name,
id = matched.id
))
}
fn resolve_transition<'a>(
target: &str,
transitions: &'a [JiraTransition],
) -> Result<&'a JiraTransition> {
if let Some(t) = transitions.iter().find(|t| t.id == target) {
return Ok(t);
}
let target_lower = target.to_lowercase();
let matches: Vec<_> = transitions
.iter()
.filter(|t| t.name.to_lowercase() == target_lower)
.collect();
match matches.len() {
0 => {
let names: Vec<_> = transitions
.iter()
.map(|t| format!("\"{}\" (id: {})", t.name, t.id))
.collect();
anyhow::bail!(
"No transition matching \"{target}\" found. Available: {}",
if names.is_empty() {
"none".to_string()
} else {
names.join(", ")
}
);
}
1 => Ok(matches[0]),
_ => {
let dupes: Vec<_> = matches
.iter()
.map(|t| format!("\"{}\" (id: {})", t.name, t.id))
.collect();
anyhow::bail!(
"Ambiguous transition \"{target}\": {}. Use the id instead.",
dupes.join(", ")
);
}
}
}
async fn run_jira_comment(
client: &AtlassianClient,
key: &str,
action: &str,
body: Option<&str>,
limit: u32,
) -> Result<String> {
match action {
"list" => {
let comments = client.get_comments(key, limit).await?;
yaml_result(&comments)
}
"add" => {
let text =
body.ok_or_else(|| anyhow::anyhow!("`body` is required when action is \"add\""))?;
let adf = markdown_to_adf(text)?;
client.add_comment(key, &adf).await?;
Ok(format!("Comment added to {key}.\n"))
}
other => {
anyhow::bail!("unknown comment action {other:?} (expected \"list\" or \"add\")")
}
}
}
async fn run_jira_link(
client: &AtlassianClient,
action: &str,
key: Option<&str>,
target: Option<&str>,
link_type: Option<&str>,
link_id: Option<&str>,
) -> Result<String> {
match action {
"list" => {
let k = key.ok_or_else(|| anyhow::anyhow!("`key` is required for link list"))?;
let links = client.get_issue_links(k).await?;
yaml_result(&links)
}
"types" => {
let types = client.get_link_types().await?;
yaml_result(&types)
}
"create" => {
let inward = key.ok_or_else(|| {
anyhow::anyhow!("`key` (source issue) is required for link create")
})?;
let outward = target.ok_or_else(|| {
anyhow::anyhow!("`target` is required for link create")
})?;
let lt = link_type.ok_or_else(|| {
anyhow::anyhow!("`link_type` is required for link create")
})?;
client.create_issue_link(lt, inward, outward).await?;
Ok(format!("Linked {inward} → {outward} ({lt}).\n"))
}
"remove" => {
let id = link_id.ok_or_else(|| {
anyhow::anyhow!("`link_id` is required for link remove")
})?;
client.remove_issue_link(id).await?;
Ok(format!("Removed link {id}.\n"))
}
other => anyhow::bail!(
"unknown link action {other:?} (expected \"list\", \"types\", \"create\", or \"remove\")"
),
}
}
async fn run_jira_dev(client: &AtlassianClient, key: &str) -> Result<String> {
let status = client.get_dev_status(key, None, None).await?;
yaml_result(&status)
}
#[allow(missing_docs)] #[tool_router(router = jira_core_tool_router, vis = "pub")]
impl OmniDevServer {
#[tool(
description = "Fetch a JIRA issue. Returns JFM markdown (default, AI-friendly) \
or raw ADF JSON when `format = \"adf\"`. When `output_file` is set, \
the content is written to that path and the tool returns a short \
YAML summary (path/bytes/format) — useful for large issues."
)]
pub async fn jira_read(
&self,
Parameters(params): Parameters<JiraReadParams>,
) -> Result<CallToolResult, McpError> {
let format = ReadFormat::parse(params.format.as_deref()).map_err(tool_error)?;
let (client, instance_url) = create_client().map_err(tool_error)?;
let text = run_jira_read(
&client,
&instance_url,
¶ms.key,
format,
params.output_file.as_deref(),
)
.await
.map_err(tool_error)?;
ok_text(text)
}
#[tool(description = "Search JIRA issues using a JQL query. Returns matching issues as YAML.")]
pub async fn jira_search(
&self,
Parameters(params): Parameters<JiraSearchParams>,
) -> Result<CallToolResult, McpError> {
let limit = params.limit.unwrap_or(20);
let _ = params.fields;
let (client, _instance_url) = create_client().map_err(tool_error)?;
let text = run_jira_search(&client, ¶ms.jql, limit)
.await
.map_err(tool_error)?;
ok_text(text)
}
#[tool(
description = "Create a new JIRA issue. Returns the new issue key and self URL as YAML."
)]
pub async fn jira_create(
&self,
Parameters(params): Parameters<JiraCreateParams>,
) -> Result<CallToolResult, McpError> {
let issue_type = params.issue_type.as_deref().unwrap_or("Task");
let (client, _instance_url) = create_client().map_err(tool_error)?;
let text = run_jira_create(
&client,
¶ms.project,
¶ms.summary,
params.description.as_deref(),
issue_type,
)
.await
.map_err(tool_error)?;
ok_text(text)
}
#[tool(
description = "Update the description of a JIRA issue from JFM markdown (default) or raw ADF JSON."
)]
pub async fn jira_write(
&self,
Parameters(params): Parameters<JiraWriteParams>,
) -> Result<CallToolResult, McpError> {
let format = ReadFormat::parse(params.format.as_deref()).map_err(tool_error)?;
let (client, _instance_url) = create_client().map_err(tool_error)?;
let text = run_jira_write(&client, ¶ms.key, ¶ms.content, format)
.await
.map_err(tool_error)?;
ok_text(text)
}
#[tool(
description = "List available transitions (when `list = true` or no `transition` is given), \
or execute a transition by name or id. Optionally posts `comment` afterwards."
)]
pub async fn jira_transition(
&self,
Parameters(params): Parameters<JiraTransitionParams>,
) -> Result<CallToolResult, McpError> {
let list = params.list.unwrap_or(false);
let (client, _instance_url) = create_client().map_err(tool_error)?;
let text = run_jira_transition(
&client,
¶ms.key,
params.transition.as_deref(),
params.comment.as_deref(),
list,
)
.await
.map_err(tool_error)?;
ok_text(text)
}
#[tool(
description = "Manage JIRA issue comments. `action = \"list\"` returns comments as YAML; \
`action = \"add\"` posts the given `body` (JFM markdown)."
)]
pub async fn jira_comment(
&self,
Parameters(params): Parameters<JiraCommentParams>,
) -> Result<CallToolResult, McpError> {
let limit = params.limit.unwrap_or(0);
let (client, _instance_url) = create_client().map_err(tool_error)?;
let text = run_jira_comment(
&client,
¶ms.key,
¶ms.action,
params.body.as_deref(),
limit,
)
.await
.map_err(tool_error)?;
ok_text(text)
}
#[tool(
description = "Manage JIRA issue links. Actions: \"list\" (needs `key`), \"types\", \
\"create\" (needs `key`, `target`, `link_type`), \
\"remove\" (needs `link_id`)."
)]
pub async fn jira_link(
&self,
Parameters(params): Parameters<JiraLinkParams>,
) -> Result<CallToolResult, McpError> {
let (client, _instance_url) = create_client().map_err(tool_error)?;
let text = run_jira_link(
&client,
¶ms.action,
params.key.as_deref(),
params.target.as_deref(),
params.link_type.as_deref(),
params.link_id.as_deref(),
)
.await
.map_err(tool_error)?;
ok_text(text)
}
#[tool(
description = "Fetch development status for a JIRA issue: linked pull requests, \
branches, and repositories as YAML."
)]
pub async fn jira_dev(
&self,
Parameters(params): Parameters<JiraDevParams>,
) -> Result<CallToolResult, McpError> {
let (client, _instance_url) = create_client().map_err(tool_error)?;
let text = run_jira_dev(&client, ¶ms.key)
.await
.map_err(tool_error)?;
ok_text(text)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use wiremock::matchers::{body_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[test]
fn read_format_defaults_to_jfm() {
assert!(matches!(ReadFormat::parse(None).unwrap(), ReadFormat::Jfm));
}
#[test]
fn read_format_parses_case_insensitively() {
assert!(matches!(
ReadFormat::parse(Some("JFM")).unwrap(),
ReadFormat::Jfm
));
assert!(matches!(
ReadFormat::parse(Some("Adf")).unwrap(),
ReadFormat::Adf
));
}
#[test]
fn read_format_rejects_unknown() {
let err = ReadFormat::parse(Some("xml")).unwrap_err();
assert!(err.to_string().contains("unknown format"));
}
async fn mount_issue_response(server: &MockServer, key: &str, body: serde_json::Value) {
Mock::given(method("GET"))
.and(path(format!("/rest/api/3/issue/{key}")))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(server)
.await;
}
fn sample_issue_body() -> serde_json::Value {
serde_json::json!({
"key": "PROJ-1",
"fields": {
"summary": "Sample",
"description": {
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "Hello from JIRA"}]
}]
},
"status": {"name": "Open"},
"issuetype": {"name": "Task"},
"assignee": {"displayName": "Alice"},
"priority": null,
"labels": ["backend"]
}
})
}
#[tokio::test]
async fn run_jira_read_jfm_emits_frontmatter_and_body() {
let server = MockServer::start().await;
mount_issue_response(&server, "PROJ-1", sample_issue_body()).await;
let client = mock_client(&server.uri());
let rendered = run_jira_read(&client, &server.uri(), "PROJ-1", ReadFormat::Jfm, None)
.await
.unwrap();
assert!(rendered.contains("key: PROJ-1"));
assert!(rendered.contains("summary: Sample"));
assert!(rendered.contains("Hello from JIRA"));
}
#[tokio::test]
async fn run_jira_read_adf_returns_raw_json() {
let server = MockServer::start().await;
mount_issue_response(&server, "PROJ-1", sample_issue_body()).await;
let client = mock_client(&server.uri());
let json = run_jira_read(&client, &server.uri(), "PROJ-1", ReadFormat::Adf, None)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "doc");
}
#[tokio::test]
async fn run_jira_read_adf_null_description_emits_null_json() {
let server = MockServer::start().await;
mount_issue_response(
&server,
"PROJ-2",
serde_json::json!({
"key": "PROJ-2",
"fields": {"summary": "No body"}
}),
)
.await;
let client = mock_client(&server.uri());
let json = run_jira_read(&client, &server.uri(), "PROJ-2", ReadFormat::Adf, None)
.await
.unwrap();
assert_eq!(json.trim(), "null");
}
#[tokio::test]
async fn run_jira_read_propagates_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/NOPE-1"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_read(&client, &server.uri(), "NOPE-1", ReadFormat::Jfm, None)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_jira_read_jfm_writes_to_output_file() {
let server = MockServer::start().await;
mount_issue_response(&server, "PROJ-1", sample_issue_body()).await;
let client = mock_client(&server.uri());
let tmp = tempfile::tempdir().unwrap();
let out_path = tmp.path().join("issue.md");
let path_str = out_path.to_str().unwrap();
let summary = run_jira_read(
&client,
&server.uri(),
"PROJ-1",
ReadFormat::Jfm,
Some(path_str),
)
.await
.unwrap();
assert!(summary.contains(&format!("path: {path_str}")));
assert!(summary.contains("format: jfm"));
assert!(summary.contains("bytes:"));
assert!(!summary.contains("Hello from JIRA"));
let written = std::fs::read_to_string(&out_path).unwrap();
assert!(written.contains("key: PROJ-1"));
assert!(written.contains("Hello from JIRA"));
}
#[tokio::test]
async fn run_jira_read_adf_writes_to_output_file() {
let server = MockServer::start().await;
mount_issue_response(&server, "PROJ-1", sample_issue_body()).await;
let client = mock_client(&server.uri());
let tmp = tempfile::tempdir().unwrap();
let out_path = tmp.path().join("issue.json");
let path_str = out_path.to_str().unwrap();
let summary = run_jira_read(
&client,
&server.uri(),
"PROJ-1",
ReadFormat::Adf,
Some(path_str),
)
.await
.unwrap();
assert!(summary.contains("format: adf"));
let written = std::fs::read_to_string(&out_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed["type"], "doc");
}
#[tokio::test]
async fn run_jira_read_output_file_invalid_path_errors() {
let server = MockServer::start().await;
mount_issue_response(&server, "PROJ-1", sample_issue_body()).await;
let client = mock_client(&server.uri());
let err = run_jira_read(
&client,
&server.uri(),
"PROJ-1",
ReadFormat::Jfm,
Some("/nonexistent_dir_zxq/out.md"),
)
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to write"));
}
#[test]
fn read_format_label_matches_expected_strings() {
assert_eq!(ReadFormat::Jfm.label(), "jfm");
assert_eq!(ReadFormat::Adf.label(), "adf");
}
fn issue_with_description(
adf: Option<serde_json::Value>,
) -> crate::atlassian::client::JiraIssue {
crate::atlassian::client::JiraIssue {
key: "PROJ-1".to_string(),
summary: "S".to_string(),
description_adf: adf,
status: None,
issue_type: None,
assignee: None,
priority: None,
labels: vec![],
custom_fields: vec![],
}
}
#[test]
fn render_jira_issue_jfm_propagates_adf_parse_error() {
let issue = issue_with_description(Some(serde_json::Value::String("not adf".into())));
let err = render_jira_issue(&issue, "https://org", &ReadFormat::Jfm).unwrap_err();
assert!(
err.to_string().contains("Failed to parse ADF"),
"got: {err}"
);
}
#[test]
fn render_jira_issue_adf_serialises_null_when_description_absent() {
let issue = issue_with_description(None);
let json = render_jira_issue(&issue, "https://org", &ReadFormat::Adf).unwrap();
assert_eq!(json.trim(), "null");
}
#[tokio::test]
async fn run_jira_read_propagates_render_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"key": "PROJ-1",
"fields": {
"summary": "Bad ADF",
"description": "this is not adf"
}
})))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_read(&client, &server.uri(), "PROJ-1", ReadFormat::Jfm, None)
.await
.unwrap_err();
assert!(
err.to_string().contains("Failed to parse ADF"),
"got: {err}"
);
}
#[tokio::test]
async fn run_jira_search_yaml_output_includes_keys() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issues": [
{"key": "PROJ-1", "fields": {"summary": "First"}},
{"key": "PROJ-2", "fields": {"summary": "Second"}}
],
"total": 2
})))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let yaml = run_jira_search(&client, "project = PROJ", 20)
.await
.unwrap();
assert!(yaml.contains("PROJ-1"));
assert!(yaml.contains("PROJ-2"));
assert!(yaml.contains("total: 2"));
}
#[tokio::test]
async fn run_jira_search_propagates_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.respond_with(ResponseTemplate::new(400).set_body_string("bad jql"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_search(&client, "!bad!", 20).await.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn run_jira_create_returns_new_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "100",
"key": "PROJ-100",
"self": "https://example.atlassian.net/rest/api/3/issue/100"
})))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let yaml = run_jira_create(&client, "PROJ", "A task", Some("Body text"), "Task")
.await
.unwrap();
assert!(yaml.contains("PROJ-100"));
}
#[tokio::test]
async fn run_jira_create_without_description_omits_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "100",
"key": "PROJ-100",
"self": "https://example.atlassian.net/rest/api/3/issue/100"
})))
.mount(&server)
.await;
let client = mock_client(&server.uri());
run_jira_create(&client, "PROJ", "Terse", None, "Task")
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_create_propagates_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue"))
.respond_with(ResponseTemplate::new(400).set_body_string("bad"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_create(&client, "PROJ", "Title", None, "Task")
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn run_jira_write_jfm_from_markdown_succeeds() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = mock_client(&server.uri());
run_jira_write(&client, "PROJ-1", "New body\n", ReadFormat::Jfm)
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_write_jfm_from_frontmatter_strips_it() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let content = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: T\n---\n\nBody\n";
run_jira_write(&client, "PROJ-1", content, ReadFormat::Jfm)
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_write_adf_parses_raw_json() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = mock_client(&server.uri());
run_jira_write(
&client,
"PROJ-1",
r#"{"version":1,"type":"doc","content":[]}"#,
ReadFormat::Adf,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_write_adf_rejects_invalid_json() {
let client = mock_client("http://127.0.0.1:1");
let err = run_jira_write(&client, "PROJ-1", "not json", ReadFormat::Adf)
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to parse ADF JSON"));
}
#[tokio::test]
async fn run_jira_write_propagates_api_error() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(400).set_body_string("Bad"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_write(&client, "PROJ-1", "Body", ReadFormat::Jfm)
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
async fn mount_transitions(server: &MockServer, key: &str, body: serde_json::Value) {
Mock::given(method("GET"))
.and(path(format!("/rest/api/3/issue/{key}/transitions")))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(server)
.await;
}
#[tokio::test]
async fn run_jira_transition_list_returns_yaml() {
let server = MockServer::start().await;
mount_transitions(
&server,
"PROJ-1",
serde_json::json!({
"transitions": [
{"id": "11", "name": "In Progress"},
{"id": "21", "name": "Done"}
]
}),
)
.await;
let client = mock_client(&server.uri());
let yaml = run_jira_transition(&client, "PROJ-1", None, None, true)
.await
.unwrap();
assert!(yaml.contains("In Progress"));
assert!(yaml.contains("Done"));
}
#[tokio::test]
async fn run_jira_transition_missing_transition_lists_available() {
let server = MockServer::start().await;
mount_transitions(
&server,
"PROJ-1",
serde_json::json!({"transitions": [{"id": "11", "name": "In Progress"}]}),
)
.await;
let client = mock_client(&server.uri());
let yaml = run_jira_transition(&client, "PROJ-1", None, None, false)
.await
.unwrap();
assert!(yaml.contains("In Progress"));
}
#[tokio::test]
async fn run_jira_transition_executes_by_name() {
let server = MockServer::start().await;
mount_transitions(
&server,
"PROJ-1",
serde_json::json!({
"transitions": [
{"id": "11", "name": "In Progress"},
{"id": "21", "name": "Done"}
]
}),
)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.and(body_json(serde_json::json!({"transition": {"id": "21"}})))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let result = run_jira_transition(&client, "PROJ-1", Some("Done"), None, false)
.await
.unwrap();
assert!(result.contains("Transitioned"));
assert!(result.contains("Done"));
}
#[tokio::test]
async fn run_jira_transition_posts_comment_when_provided() {
let server = MockServer::start().await;
mount_transitions(
&server,
"PROJ-1",
serde_json::json!({"transitions": [{"id": "21", "name": "Done"}]}),
)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "c1"})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
run_jira_transition(&client, "PROJ-1", Some("Done"), Some("nice"), false)
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_transition_get_transitions_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_transition(&client, "PROJ-1", None, None, true)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_jira_transition_do_transition_api_error() {
let server = MockServer::start().await;
mount_transitions(
&server,
"PROJ-1",
serde_json::json!({"transitions": [{"id": "21", "name": "Done"}]}),
)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(400).set_body_string("Bad"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_transition(&client, "PROJ-1", Some("Done"), None, false)
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn run_jira_transition_add_comment_api_error() {
let server = MockServer::start().await;
mount_transitions(
&server,
"PROJ-1",
serde_json::json!({"transitions": [{"id": "21", "name": "Done"}]}),
)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_transition(&client, "PROJ-1", Some("Done"), Some("nice"), false)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn run_jira_transition_unknown_name_errors() {
let server = MockServer::start().await;
mount_transitions(
&server,
"PROJ-1",
serde_json::json!({"transitions": [{"id": "11", "name": "In Progress"}]}),
)
.await;
let client = mock_client(&server.uri());
let err = run_jira_transition(&client, "PROJ-1", Some("Nope"), None, false)
.await
.unwrap_err();
assert!(err.to_string().contains("No transition matching"));
}
fn t(id: &str, name: &str) -> JiraTransition {
JiraTransition {
id: id.to_string(),
name: name.to_string(),
}
}
#[test]
fn resolve_transition_exact_id_wins() {
let ts = [t("Done", "Anything"), t("99", "Done")];
assert_eq!(resolve_transition("Done", &ts).unwrap().name, "Anything");
}
#[test]
fn resolve_transition_case_insensitive_name() {
let ts = [t("11", "Done")];
assert_eq!(resolve_transition("done", &ts).unwrap().id, "11");
}
#[test]
fn resolve_transition_empty_list() {
let err = resolve_transition("Done", &[]).unwrap_err();
assert!(err.to_string().contains("none"));
}
#[test]
fn resolve_transition_ambiguous_errors() {
let ts = [t("11", "Done"), t("22", "done")];
let err = resolve_transition("Done", &ts).unwrap_err();
assert!(err.to_string().contains("Ambiguous"));
}
#[tokio::test]
async fn run_jira_comment_list_returns_yaml() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0,
"maxResults": 100,
"total": 1,
"comments": [{
"id": "1",
"author": {"displayName": "Alice"},
"created": "2026-04-01T10:00:00.000+0000",
"body": null
}]
})))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let yaml = run_jira_comment(&client, "PROJ-1", "list", None, 0)
.await
.unwrap();
assert!(yaml.contains("Alice"));
}
#[tokio::test]
async fn run_jira_comment_list_forwards_limit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/comment"))
.and(query_param("maxResults", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0, "maxResults": 2, "total": 0, "comments": []
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
run_jira_comment(&client, "PROJ-1", "list", None, 2)
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_comment_add_posts_comment() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "1"})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
run_jira_comment(&client, "PROJ-1", "add", Some("hello"), 0)
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_comment_add_without_body_errors() {
let client = mock_client("http://127.0.0.1:1");
let err = run_jira_comment(&client, "PROJ-1", "add", None, 0)
.await
.unwrap_err();
assert!(err.to_string().contains("`body` is required"));
}
#[tokio::test]
async fn run_jira_comment_unknown_action_errors() {
let client = mock_client("http://127.0.0.1:1");
let err = run_jira_comment(&client, "PROJ-1", "delete", None, 0)
.await
.unwrap_err();
assert!(err.to_string().contains("unknown comment action"));
}
#[tokio::test]
async fn run_jira_comment_list_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/NOPE-1/comment"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_comment(&client, "NOPE-1", "list", None, 0)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_jira_comment_add_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_comment(&client, "PROJ-1", "add", Some("hi"), 0)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn run_jira_link_types_returns_yaml() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issueLinkTypes": [
{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}
]
})))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let yaml = run_jira_link(&client, "types", None, None, None, None)
.await
.unwrap();
assert!(yaml.contains("Blocks"));
}
#[tokio::test]
async fn run_jira_link_list_returns_yaml() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"key": "PROJ-1",
"fields": {
"summary": "s",
"issuelinks": []
}
})))
.mount(&server)
.await;
let client = mock_client(&server.uri());
run_jira_link(&client, "list", Some("PROJ-1"), None, None, None)
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_link_list_requires_key() {
let client = mock_client("http://127.0.0.1:1");
let err = run_jira_link(&client, "list", None, None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("`key` is required"));
}
#[tokio::test]
async fn run_jira_link_create_posts_link() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issueLink"))
.respond_with(ResponseTemplate::new(201))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
run_jira_link(
&client,
"create",
Some("PROJ-1"),
Some("PROJ-2"),
Some("Blocks"),
None,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_link_create_requires_target() {
let client = mock_client("http://127.0.0.1:1");
let err = run_jira_link(
&client,
"create",
Some("PROJ-1"),
None,
Some("Blocks"),
None,
)
.await
.unwrap_err();
assert!(err.to_string().contains("`target` is required"));
}
#[tokio::test]
async fn run_jira_link_create_requires_link_type() {
let client = mock_client("http://127.0.0.1:1");
let err = run_jira_link(
&client,
"create",
Some("PROJ-1"),
Some("PROJ-2"),
None,
None,
)
.await
.unwrap_err();
assert!(err.to_string().contains("`link_type` is required"));
}
#[tokio::test]
async fn run_jira_link_create_requires_key() {
let client = mock_client("http://127.0.0.1:1");
let err = run_jira_link(
&client,
"create",
None,
Some("PROJ-2"),
Some("Blocks"),
None,
)
.await
.unwrap_err();
assert!(err.to_string().contains("`key` (source issue) is required"));
}
#[tokio::test]
async fn run_jira_link_remove_deletes() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/rest/api/3/issueLink/42"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
run_jira_link(&client, "remove", None, None, None, Some("42"))
.await
.unwrap();
}
#[tokio::test]
async fn run_jira_link_remove_requires_link_id() {
let client = mock_client("http://127.0.0.1:1");
let err = run_jira_link(&client, "remove", None, None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("`link_id` is required"));
}
#[tokio::test]
async fn run_jira_link_unknown_action_errors() {
let client = mock_client("http://127.0.0.1:1");
let err = run_jira_link(&client, "frob", None, None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("unknown link action"));
}
#[tokio::test]
async fn run_jira_link_list_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/NOPE-1"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_link(&client, "list", Some("NOPE-1"), None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_jira_link_types_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_link(&client, "types", None, None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn run_jira_link_create_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issueLink"))
.respond_with(ResponseTemplate::new(400).set_body_string("Bad"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_link(
&client,
"create",
Some("PROJ-1"),
Some("PROJ-2"),
Some("Blocks"),
None,
)
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn run_jira_link_remove_api_error() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/rest/api/3/issueLink/99"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_link(&client, "remove", None, None, None, Some("99"))
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_jira_dev_returns_yaml_for_empty_status() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/dev-status/1.0/issue/summary"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"summary": {
"pullrequest": {"overall": {"count": 0}, "byInstanceType": {}},
"branch": {"overall": {"count": 0}, "byInstanceType": {}},
"repository": {"overall": {"count": 0}, "byInstanceType": {}}
}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/dev-status/1.0/issue/detail"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"detail": [{
"pullRequests": [],
"branches": [],
"repositories": []
}]
})))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let yaml = run_jira_dev(&client, "PROJ-1").await.unwrap();
assert_eq!(yaml.trim(), "{}");
}
#[tokio::test]
async fn run_jira_dev_includes_populated_categories() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/dev-status/1.0/issue/summary"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"summary": {
"pullrequest": {"overall": {"count": 0}, "byInstanceType": {}},
"branch": {"overall": {"count": 0}, "byInstanceType": {}},
"repository": {"overall": {"count": 0}, "byInstanceType": {}}
}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/dev-status/1.0/issue/detail"))
.and(query_param("dataType", "pullrequest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"detail": [{
"pullRequests": [{
"id": "#42",
"name": "Fix bug",
"status": "OPEN",
"url": "https://github.com/o/r/pull/42",
"repositoryName": "o/r",
"source": {"branch": "fix"},
"destination": {"branch": "main"}
}],
"branches": [],
"repositories": []
}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/dev-status/1.0/issue/detail"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"detail": [{"pullRequests": [], "branches": [], "repositories": []}]
})))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let yaml = run_jira_dev(&client, "PROJ-1").await.unwrap();
assert!(yaml.contains("pull_requests"));
assert!(yaml.contains("Fix bug"));
}
#[tokio::test]
async fn run_jira_dev_propagates_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/NOPE-1"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_jira_dev(&client, "NOPE-1").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[test]
fn tool_error_wraps_anyhow_chain() {
let err: anyhow::Error = anyhow::anyhow!("root").context("middle").context("top");
let mcp = tool_error(err);
assert!(mcp.message.contains("top"));
assert!(mcp.message.contains("Caused by: middle"));
assert!(mcp.message.contains("Caused by: root"));
}
#[test]
fn yaml_result_serializes_vec() {
let v = vec![1_u32, 2, 3];
let s = yaml_result(&v).unwrap();
assert_eq!(s, "- 1\n- 2\n- 3\n");
}
#[test]
fn ok_text_returns_success_result() {
let result = ok_text("hello".to_string()).unwrap();
assert!(!result.is_error.unwrap_or(false));
assert_eq!(result.content.len(), 1);
}
}