Skip to main content

zeroclaw_tools/
jira_tool.rs

1use async_trait::async_trait;
2use reqwest::Client;
3use serde_json::{Value, json};
4use std::collections::{HashMap, HashSet};
5use std::sync::Arc;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::policy::{SecurityPolicy, ToolOperation};
8
9const JIRA_SEARCH_PAGE_SIZE: u32 = 100;
10const MAX_ERROR_BODY_CHARS: usize = 500;
11
12/// Controls how much data is returned by `get_ticket`.
13#[derive(Default)]
14enum LevelOfDetails {
15    Basic,
16    #[default]
17    BasicSearch,
18    Full,
19    Changelog,
20}
21
22/// Tool for interacting with the Jira REST API.
23///
24/// When `email` is provided, uses **API v3** with HTTP Basic auth
25/// (`email:api_token`) — the standard Jira Cloud authentication model.
26///
27/// When `email` is `None`, uses **API v2** with Bearer token auth
28/// (`Authorization: Bearer <api_token>`) — the standard Jira Server /
29/// Data Center (self-hosted) authentication model.
30///
31/// Supports eight actions gated by `[jira].allowed_actions` in config:
32/// - `get_ticket`        — always in the default allowlist; read-only.
33/// - `search_tickets`    — requires explicit opt-in; read-only.
34/// - `comment_ticket`    — requires explicit opt-in; mutating (Act policy).
35/// - `list_projects`     — requires explicit opt-in; read-only.
36/// - `myself`            — requires explicit opt-in; read-only. Verifies credentials.
37/// - `list_transitions`  — requires explicit opt-in; read-only.
38/// - `transition_ticket` — requires explicit opt-in; mutating (Act policy).
39/// - `create_ticket`     — requires explicit opt-in; mutating (Act policy).
40pub struct JiraTool {
41    base_url: String,
42    email: Option<String>,
43    api_token: String,
44    allowed_actions: Vec<String>,
45    http: Client,
46    security: Arc<SecurityPolicy>,
47    timeout_secs: u64,
48}
49
50impl JiraTool {
51    pub fn new(
52        base_url: String,
53        email: Option<String>,
54        api_token: String,
55        allowed_actions: Vec<String>,
56        security: Arc<SecurityPolicy>,
57        timeout_secs: u64,
58    ) -> Self {
59        Self {
60            base_url: base_url.trim_end_matches('/').to_string(),
61            email,
62            api_token,
63            allowed_actions,
64            http: Client::new(),
65            security,
66            timeout_secs,
67        }
68    }
69
70    /// `"3"` for Jira Cloud (email present), `"2"` for Server/DC (no email).
71    fn api_version(&self) -> &str {
72        if self.email.is_some() { "3" } else { "2" }
73    }
74
75    /// Returns an authenticated request builder.
76    /// Cloud: HTTP Basic (`email:token`). Server/DC: Bearer token.
77    fn authenticated(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
78        match &self.email {
79            Some(email) => req.basic_auth(email, Some(&self.api_token)),
80            None => req.bearer_auth(&self.api_token),
81        }
82    }
83
84    /// `true` when connected to Jira Cloud (API v3, email present).
85    fn is_cloud(&self) -> bool {
86        self.email.is_some()
87    }
88
89    fn is_action_allowed(&self, action: &str) -> bool {
90        self.allowed_actions.iter().any(|a| a == action)
91    }
92
93    async fn get_ticket(
94        &self,
95        issue_key: &str,
96        level: LevelOfDetails,
97    ) -> anyhow::Result<ToolResult> {
98        validate_issue_key(issue_key)?;
99        let ver = self.api_version();
100        let url = format!("{}/rest/api/{}/issue/{}", self.base_url, ver, issue_key);
101
102        let query: Vec<(&str, &str)> = match &level {
103            LevelOfDetails::Basic => vec![
104                ("fields", "summary"),
105                ("fields", "priority"),
106                ("fields", "status"),
107                ("fields", "assignee"),
108                ("fields", "description"),
109                ("fields", "created"),
110                ("fields", "updated"),
111                ("fields", "comment"),
112                ("expand", "renderedFields"),
113            ],
114            LevelOfDetails::BasicSearch => vec![
115                ("fields", "summary"),
116                ("fields", "priority"),
117                ("fields", "status"),
118                ("fields", "assignee"),
119                ("fields", "created"),
120                ("fields", "updated"),
121            ],
122            LevelOfDetails::Full => vec![("expand", "renderedFields"), ("expand", "names")],
123            LevelOfDetails::Changelog => vec![("expand", "changelog")],
124        };
125
126        let req = self
127            .http
128            .get(&url)
129            .query(&query)
130            .timeout(std::time::Duration::from_secs(self.timeout_secs));
131        let resp = self.authenticated(req).send().await.map_err(|e| {
132            ::zeroclaw_log::record!(
133                ERROR,
134                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
135                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
136                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
137                "jira: Jira get_ticket request failed"
138            );
139            anyhow::Error::msg(format!("Jira get_ticket request failed: {e}"))
140        })?;
141
142        let status = resp.status();
143        if !status.is_success() {
144            let text = resp.text().await.unwrap_or_default();
145            anyhow::bail!(
146                "Jira get_ticket failed ({status}): {}",
147                crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
148            );
149        }
150
151        let raw: Value = resp.json().await.map_err(|e| {
152            ::zeroclaw_log::record!(
153                ERROR,
154                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
155                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
156                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
157                "jira: Failed to parse Jira get_ticket response"
158            );
159            anyhow::Error::msg(format!("Failed to parse Jira get_ticket response: {e}"))
160        })?;
161
162        let shaped = match level {
163            LevelOfDetails::Basic => shape_basic(&raw),
164            LevelOfDetails::BasicSearch => shape_basic_search(&raw),
165            LevelOfDetails::Full => shape_full(&raw),
166            LevelOfDetails::Changelog => shape_changelog(&raw),
167        };
168
169        Ok(ToolResult {
170            success: true,
171            output: serde_json::to_string_pretty(&shaped).unwrap_or_else(|_| shaped.to_string()),
172            error: None,
173        })
174    }
175
176    #[allow(clippy::cast_possible_truncation)]
177    async fn search_tickets(
178        &self,
179        jql: &str,
180        max_results: Option<u32>,
181    ) -> anyhow::Result<ToolResult> {
182        let max_results = max_results.unwrap_or(25).clamp(1, 999);
183
184        let issues = if self.is_cloud() {
185            self.search_tickets_v3(jql, max_results).await?
186        } else {
187            self.search_tickets_v2(jql, max_results).await?
188        };
189
190        let output = json!(issues);
191        Ok(ToolResult {
192            success: true,
193            output: serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()),
194            error: None,
195        })
196    }
197
198    /// Cloud (v3): `POST /rest/api/3/search/jql` with `nextPageToken` pagination.
199    #[allow(clippy::cast_possible_truncation)]
200    async fn search_tickets_v3(&self, jql: &str, max_results: u32) -> anyhow::Result<Vec<Value>> {
201        let url = format!("{}/rest/api/3/search/jql", self.base_url);
202        let mut issues: Vec<Value> = Vec::new();
203        let mut next_page_token: Option<String> = None;
204
205        loop {
206            let remaining = max_results.saturating_sub(issues.len() as u32);
207            let page_size = remaining.min(JIRA_SEARCH_PAGE_SIZE);
208
209            let mut body = json!({
210                "jql": jql,
211                "maxResults": page_size,
212                "fields": ["summary", "priority", "status", "assignee", "created", "updated"]
213            });
214
215            if let Some(token) = &next_page_token {
216                body["nextPageToken"] = json!(token);
217            }
218
219            let req = self
220                .http
221                .post(&url)
222                .json(&body)
223                .timeout(std::time::Duration::from_secs(self.timeout_secs));
224            let resp = self.authenticated(req).send().await.map_err(|e| {
225                ::zeroclaw_log::record!(
226                    ERROR,
227                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
228                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
229                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
230                    "jira: Jira search_tickets request failed"
231                );
232                anyhow::Error::msg(format!("Jira search_tickets request failed: {e}"))
233            })?;
234
235            let status = resp.status();
236            if !status.is_success() {
237                let text = resp.text().await.unwrap_or_default();
238                anyhow::bail!(
239                    "Jira search_tickets failed ({status}): {}",
240                    crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
241                );
242            }
243
244            let raw: Value = resp.json().await.map_err(|e| {
245                ::zeroclaw_log::record!(
246                    ERROR,
247                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
248                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
249                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
250                    "jira: Failed to parse Jira search response"
251                );
252                anyhow::Error::msg(format!("Failed to parse Jira search response: {e}"))
253            })?;
254
255            if let Some(page) = raw["issues"].as_array() {
256                issues.extend(page.iter().map(shape_basic_search));
257            }
258
259            let is_last = raw["isLast"].as_bool().unwrap_or(true);
260            if is_last || issues.len() as u32 >= max_results {
261                break;
262            }
263
264            next_page_token = raw["nextPageToken"].as_str().map(String::from);
265            if next_page_token.is_none() {
266                break;
267            }
268        }
269
270        Ok(issues)
271    }
272
273    /// Server/DC (v2): `POST /rest/api/2/search` with `startAt` offset pagination.
274    #[allow(clippy::cast_possible_truncation)]
275    async fn search_tickets_v2(&self, jql: &str, max_results: u32) -> anyhow::Result<Vec<Value>> {
276        let url = format!("{}/rest/api/2/search", self.base_url);
277        let mut issues: Vec<Value> = Vec::new();
278        let mut start_at: u32 = 0;
279
280        loop {
281            let remaining = max_results.saturating_sub(issues.len() as u32);
282            let page_size = remaining.min(JIRA_SEARCH_PAGE_SIZE);
283
284            let body = json!({
285                "jql": jql,
286                "startAt": start_at,
287                "maxResults": page_size,
288                "fields": ["summary", "priority", "status", "assignee", "created", "updated"]
289            });
290
291            let req = self
292                .http
293                .post(&url)
294                .json(&body)
295                .timeout(std::time::Duration::from_secs(self.timeout_secs));
296            let resp = self.authenticated(req).send().await.map_err(|e| {
297                ::zeroclaw_log::record!(
298                    ERROR,
299                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
300                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
301                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
302                    "jira: Jira search_tickets request failed"
303                );
304                anyhow::Error::msg(format!("Jira search_tickets request failed: {e}"))
305            })?;
306
307            let status = resp.status();
308            if !status.is_success() {
309                let text = resp.text().await.unwrap_or_default();
310                anyhow::bail!(
311                    "Jira search_tickets failed ({status}): {}",
312                    crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
313                );
314            }
315
316            let raw: Value = resp.json().await.map_err(|e| {
317                ::zeroclaw_log::record!(
318                    ERROR,
319                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
320                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
321                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
322                    "jira: Failed to parse Jira search response"
323                );
324                anyhow::Error::msg(format!("Failed to parse Jira search response: {e}"))
325            })?;
326
327            let page = raw["issues"].as_array();
328            let page_len = page.map_or(0, |p| p.len());
329            if let Some(page) = page {
330                issues.extend(page.iter().map(shape_basic_search));
331            }
332
333            let total = raw["total"].as_u64().unwrap_or(0) as u32;
334            start_at += page_len as u32;
335            if page_len == 0 || start_at >= total || issues.len() as u32 >= max_results {
336                break;
337            }
338        }
339
340        Ok(issues)
341    }
342
343    async fn comment_ticket(
344        &self,
345        issue_key: &str,
346        comment_text: &str,
347    ) -> anyhow::Result<ToolResult> {
348        validate_issue_key(issue_key)?;
349
350        let ver = self.api_version();
351        let url = format!(
352            "{}/rest/api/{}/issue/{}/comment",
353            self.base_url, ver, issue_key
354        );
355
356        let body = if self.is_cloud() {
357            let emails = extract_emails(comment_text);
358            let mut mentions: HashMap<String, (String, String)> = HashMap::new();
359            for email in emails {
360                if let Some(info) = self.resolve_email(&email).await {
361                    mentions.insert(email, info);
362                }
363            }
364            let adf = build_adf(comment_text, &mentions);
365            json!({ "body": adf })
366        } else {
367            json!({ "body": comment_text })
368        };
369
370        let req = self
371            .http
372            .post(&url)
373            .json(&body)
374            .timeout(std::time::Duration::from_secs(self.timeout_secs));
375        let resp = self.authenticated(req).send().await.map_err(|e| {
376            ::zeroclaw_log::record!(
377                ERROR,
378                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
379                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
380                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
381                "jira: Jira comment_ticket request failed"
382            );
383            anyhow::Error::msg(format!("Jira comment_ticket request failed: {e}"))
384        })?;
385
386        let status = resp.status();
387        if !status.is_success() {
388            let text = resp.text().await.unwrap_or_default();
389            anyhow::bail!(
390                "Jira comment_ticket failed ({status}): {}",
391                crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
392            );
393        }
394
395        let response: Value = resp.json().await.map_err(|e| {
396            ::zeroclaw_log::record!(
397                ERROR,
398                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
399                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
400                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
401                "jira: Failed to parse Jira comment response"
402            );
403            anyhow::Error::msg(format!("Failed to parse Jira comment response: {e}"))
404        })?;
405
406        let shaped = shape_comment_response(&response);
407        Ok(ToolResult {
408            success: true,
409            output: serde_json::to_string_pretty(&shaped).unwrap_or_else(|_| shaped.to_string()),
410            error: None,
411        })
412    }
413
414    async fn list_projects(&self) -> anyhow::Result<ToolResult> {
415        let ver = self.api_version();
416        let url = format!("{}/rest/api/{}/project", self.base_url, ver);
417
418        let req = self
419            .http
420            .get(&url)
421            .timeout(std::time::Duration::from_secs(self.timeout_secs));
422        let resp = self.authenticated(req).send().await.map_err(|e| {
423            ::zeroclaw_log::record!(
424                ERROR,
425                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
426                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
427                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
428                "jira: Jira list_projects request failed"
429            );
430            anyhow::Error::msg(format!("Jira list_projects request failed: {e}"))
431        })?;
432
433        let status = resp.status();
434        if !status.is_success() {
435            let text = resp.text().await.unwrap_or_default();
436            anyhow::bail!(
437                "Jira list_projects failed ({status}): {}",
438                crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
439            );
440        }
441
442        let projects: Vec<Value> = resp.json().await.map_err(|e| {
443            ::zeroclaw_log::record!(
444                ERROR,
445                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
446                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
447                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
448                "jira: Failed to parse Jira list_projects response"
449            );
450            anyhow::Error::msg(format!("Failed to parse Jira list_projects response: {e}"))
451        })?;
452
453        let keys: Vec<String> = projects
454            .iter()
455            .filter_map(|p| p["key"].as_str().map(String::from))
456            .collect();
457
458        const STATUS_CONCURRENCY: usize = 5;
459
460        let users_url = format!(
461            "{}/rest/api/{}/user/assignable/multiProjectSearch",
462            self.base_url, ver
463        );
464
465        let users_req = self
466            .http
467            .get(&users_url)
468            .query(&[
469                ("projectKeys", keys.join(",").as_str()),
470                ("maxResults", "50"),
471            ])
472            .timeout(std::time::Duration::from_secs(self.timeout_secs));
473        let users_resp = self.authenticated(users_req).send().await.map_err(|e| {
474            ::zeroclaw_log::record!(
475                ERROR,
476                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
477                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
478                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
479                "jira: Jira list_projects users request failed"
480            );
481            anyhow::Error::msg(format!("Jira list_projects users request failed: {e}"))
482        })?;
483
484        let users: Vec<Value> = if users_resp.status().is_success() {
485            users_resp.json().await.map_err(|e| {
486                ::zeroclaw_log::record!(
487                    ERROR,
488                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
489                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
490                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
491                    "jira: Failed to parse Jira list_projects users response"
492                );
493                anyhow::Error::msg(format!(
494                    "Failed to parse Jira list_projects users response: {e}"
495                ))
496            })?
497        } else {
498            let status = users_resp.status();
499            let text = users_resp.text().await.unwrap_or_default();
500            anyhow::bail!(
501                "Jira list_projects users failed ({status}): {}",
502                crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
503            );
504        };
505
506        let mut set: tokio::task::JoinSet<(usize, anyhow::Result<Value>)> =
507            tokio::task::JoinSet::new();
508        let mut statuses_results = vec![json!([]); keys.len()];
509
510        for (i, key) in keys.iter().enumerate() {
511            if set.len() >= STATUS_CONCURRENCY {
512                let Some(Ok((idx, result))) = set.join_next().await else {
513                    continue;
514                };
515                statuses_results[idx] = result.map_err(|e| {
516                    ::zeroclaw_log::record!(
517                        ERROR,
518                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
519                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
520                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
521                        "jira: Jira statuses failed"
522                    );
523                    anyhow::Error::msg(format!("Jira statuses failed: {e}"))
524                })?;
525            }
526
527            let client = self.http.clone();
528            let request_url = format!("{url}/{key}/statuses");
529            let email = self.email.clone();
530            let token = self.api_token.clone();
531            let timeout = self.timeout_secs;
532
533            set.spawn(async move {
534                let result = async {
535                    let req = client
536                        .get(&request_url)
537                        .timeout(std::time::Duration::from_secs(timeout));
538                    let req = match &email {
539                        Some(e) => req.basic_auth(e, Some(&token)),
540                        None => req.bearer_auth(&token),
541                    };
542                    let resp = req.send().await.map_err(|e| {
543                        ::zeroclaw_log::record!(
544                            ERROR,
545                            ::zeroclaw_log::Event::new(
546                                module_path!(),
547                                ::zeroclaw_log::Action::Fail
548                            )
549                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
550                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
551                            "jira: statuses request failed"
552                        );
553                        anyhow::Error::msg(format!("statuses request failed: {e}"))
554                    })?;
555
556                    if !resp.status().is_success() {
557                        anyhow::bail!("statuses request returned {}", resp.status());
558                    }
559
560                    resp.json::<Value>().await.map_err(|e| {
561                        ::zeroclaw_log::record!(
562                            ERROR,
563                            ::zeroclaw_log::Event::new(
564                                module_path!(),
565                                ::zeroclaw_log::Action::Fail
566                            )
567                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
568                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
569                            "jira: failed to parse statuses response"
570                        );
571                        anyhow::Error::msg(format!("failed to parse statuses response: {e}"))
572                    })
573                }
574                .await;
575                (i, result)
576            });
577        }
578
579        while let Some(Ok((idx, result))) = set.join_next().await {
580            statuses_results[idx] = result.map_err(|e| {
581                ::zeroclaw_log::record!(
582                    ERROR,
583                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
584                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
585                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
586                    "jira: Jira statuses failed"
587                );
588                anyhow::Error::msg(format!("Jira statuses failed: {e}"))
589            })?;
590        }
591
592        let shaped_projects = shape_projects(&projects, &statuses_results);
593        let shaped_users: Vec<Value> = users
594            .iter()
595            .filter_map(|u| {
596                let display = u["displayName"].as_str()?;
597                let email = u["emailAddress"].as_str()?;
598                Some(json!({ "displayName": display, "emailAddress": email }))
599            })
600            .collect();
601
602        let output = json!({ "projects": shaped_projects, "users": shaped_users });
603        Ok(ToolResult {
604            success: true,
605            output: serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()),
606            error: None,
607        })
608    }
609
610    async fn get_myself(&self) -> anyhow::Result<ToolResult> {
611        let ver = self.api_version();
612        let url = format!("{}/rest/api/{}/myself", self.base_url, ver);
613
614        let req = self
615            .http
616            .get(&url)
617            .timeout(std::time::Duration::from_secs(self.timeout_secs));
618        let resp = self.authenticated(req).send().await.map_err(|e| {
619            ::zeroclaw_log::record!(
620                ERROR,
621                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
622                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
623                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
624                "jira: Jira myself request failed"
625            );
626            anyhow::Error::msg(format!("Jira myself request failed: {e}"))
627        })?;
628
629        let status = resp.status();
630        if !status.is_success() {
631            let text = resp.text().await.unwrap_or_default();
632            anyhow::bail!(
633                "Jira myself failed ({status}): {}",
634                crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
635            );
636        }
637
638        let raw: Value = resp.json().await.map_err(|e| {
639            ::zeroclaw_log::record!(
640                ERROR,
641                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
642                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
643                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
644                "jira: Failed to parse Jira myself response"
645            );
646            anyhow::Error::msg(format!("Failed to parse Jira myself response: {e}"))
647        })?;
648
649        let shaped = json!({
650            "accountId":    raw["accountId"],
651            "displayName":  raw["displayName"],
652            "emailAddress": raw["emailAddress"],
653            "active":       raw["active"],
654        });
655
656        Ok(ToolResult {
657            success: true,
658            output: serde_json::to_string_pretty(&shaped).unwrap_or_else(|_| shaped.to_string()),
659            error: None,
660        })
661    }
662
663    async fn resolve_email(&self, email: &str) -> Option<(String, String)> {
664        let ver = self.api_version();
665        let url = format!("{}/rest/api/{}/user/search", self.base_url, ver);
666        let req = self
667            .http
668            .get(&url)
669            .query(&[("query", email)])
670            .timeout(std::time::Duration::from_secs(self.timeout_secs));
671        let result = self
672            .authenticated(req)
673            .send()
674            .await
675            .ok()?
676            .json::<Value>()
677            .await
678            .ok()?;
679
680        result.as_array()?.iter().find_map(|u| {
681            let account_email = u["emailAddress"].as_str()?;
682            if account_email.eq_ignore_ascii_case(email) {
683                Some((
684                    u["accountId"].as_str()?.to_string(),
685                    u["displayName"].as_str()?.to_string(),
686                ))
687            } else {
688                None
689            }
690        })
691    }
692
693    /// Fetches the available transitions for an issue and returns a minimal
694    /// shape `{ transitions: [{ id, name, to_status }] }`.
695    async fn fetch_transitions(&self, issue_key: &str) -> anyhow::Result<Vec<Value>> {
696        validate_issue_key(issue_key)?;
697        let ver = self.api_version();
698        let url = format!(
699            "{}/rest/api/{}/issue/{}/transitions",
700            self.base_url, ver, issue_key
701        );
702
703        let req = self
704            .http
705            .get(&url)
706            .timeout(std::time::Duration::from_secs(self.timeout_secs));
707        let resp = self.authenticated(req).send().await.map_err(|e| {
708            ::zeroclaw_log::record!(
709                ERROR,
710                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
711                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
712                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
713                "jira: Jira list_transitions request failed"
714            );
715            anyhow::Error::msg(format!("Jira list_transitions request failed: {e}"))
716        })?;
717
718        let status = resp.status();
719        if !status.is_success() {
720            let text = resp.text().await.unwrap_or_default();
721            anyhow::bail!(
722                "Jira list_transitions failed ({status}): {}",
723                crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
724            );
725        }
726
727        let raw: Value = resp.json().await.map_err(|e| {
728            ::zeroclaw_log::record!(
729                ERROR,
730                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
731                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
732                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
733                "jira: Failed to parse Jira transitions response"
734            );
735            anyhow::Error::msg(format!("Failed to parse Jira transitions response: {e}"))
736        })?;
737
738        Ok(shape_transitions(&raw))
739    }
740
741    async fn list_transitions(&self, issue_key: &str) -> anyhow::Result<ToolResult> {
742        let transitions = self.fetch_transitions(issue_key).await?;
743        let output = json!({ "transitions": transitions });
744        Ok(ToolResult {
745            success: true,
746            output: serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()),
747            error: None,
748        })
749    }
750
751    async fn transition_ticket(
752        &self,
753        issue_key: &str,
754        transition_id: Option<&str>,
755        transition_name: Option<&str>,
756    ) -> anyhow::Result<ToolResult> {
757        validate_issue_key(issue_key)?;
758
759        // Resolve transition_name → id if needed.
760        let resolved_id: String = match (transition_id, transition_name) {
761            (Some(id), _) if !id.trim().is_empty() => id.to_string(),
762            (_, Some(name)) if !name.trim().is_empty() => {
763                let transitions = self.fetch_transitions(issue_key).await?;
764                let needle = name.trim().to_ascii_lowercase();
765                let found = transitions.iter().find_map(|t| {
766                    let n = t["name"].as_str()?;
767                    if n.eq_ignore_ascii_case(&needle) || n.to_ascii_lowercase() == needle {
768                        t["id"].as_str().map(String::from)
769                    } else {
770                        None
771                    }
772                });
773                match found {
774                    Some(id) => id,
775                    None => {
776                        let available: Vec<&str> = transitions
777                            .iter()
778                            .filter_map(|t| t["name"].as_str())
779                            .collect();
780                        anyhow::bail!(
781                            "Transition '{name}' not found for {issue_key}. Available: {}",
782                            available.join(", ")
783                        );
784                    }
785                }
786            }
787            _ => {
788                anyhow::bail!(
789                    "transition_ticket requires exactly one of transition_id or transition_name"
790                );
791            }
792        };
793
794        let ver = self.api_version();
795        let url = format!(
796            "{}/rest/api/{}/issue/{}/transitions",
797            self.base_url, ver, issue_key
798        );
799        let body = json!({ "transition": { "id": resolved_id } });
800
801        let req = self
802            .http
803            .post(&url)
804            .json(&body)
805            .timeout(std::time::Duration::from_secs(self.timeout_secs));
806        let resp = self.authenticated(req).send().await.map_err(|e| {
807            ::zeroclaw_log::record!(
808                ERROR,
809                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
810                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
811                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
812                "jira: Jira transition_ticket request failed"
813            );
814            anyhow::Error::msg(format!("Jira transition_ticket request failed: {e}"))
815        })?;
816
817        let status = resp.status();
818        if !status.is_success() {
819            let text = resp.text().await.unwrap_or_default();
820            anyhow::bail!(
821                "Jira transition_ticket failed ({status}): {}",
822                crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
823            );
824        }
825
826        // Jira returns 204 No Content on a successful transition.
827        let output = json!({
828            "ok": true,
829            "issue_key": issue_key,
830            "transition_id": resolved_id,
831        });
832        Ok(ToolResult {
833            success: true,
834            output: serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()),
835            error: None,
836        })
837    }
838
839    #[allow(clippy::too_many_arguments)]
840    async fn create_ticket(
841        &self,
842        project_key: &str,
843        issue_type: &str,
844        summary: &str,
845        description: Option<&str>,
846        assignee: Option<&str>,
847        labels: Option<&[String]>,
848        parent_key: Option<&str>,
849    ) -> anyhow::Result<ToolResult> {
850        validate_project_key(project_key)?;
851        if summary.trim().is_empty() {
852            anyhow::bail!("create_ticket requires a non-empty summary");
853        }
854        if issue_type.trim().is_empty() {
855            anyhow::bail!("create_ticket requires a non-empty issue_type");
856        }
857        if let Some(parent) = parent_key {
858            validate_issue_key(parent)?;
859        }
860
861        let mut fields = serde_json::Map::new();
862        fields.insert("project".into(), json!({ "key": project_key }));
863        fields.insert("issuetype".into(), json!({ "name": issue_type }));
864        fields.insert("summary".into(), json!(summary));
865
866        if let Some(desc) = description {
867            let value = if self.is_cloud() {
868                build_adf(desc, &HashMap::new())
869            } else {
870                json!(desc)
871            };
872            fields.insert("description".into(), value);
873        }
874
875        if let Some(a) = assignee {
876            let value = if self.is_cloud() {
877                json!({ "accountId": a })
878            } else {
879                json!({ "name": a })
880            };
881            fields.insert("assignee".into(), value);
882        }
883
884        if let Some(ls) = labels {
885            fields.insert("labels".into(), json!(ls));
886        }
887
888        if let Some(parent) = parent_key {
889            fields.insert("parent".into(), json!({ "key": parent }));
890        }
891
892        let body = json!({ "fields": Value::Object(fields) });
893
894        let ver = self.api_version();
895        let url = format!("{}/rest/api/{}/issue", self.base_url, ver);
896
897        let req = self
898            .http
899            .post(&url)
900            .json(&body)
901            .timeout(std::time::Duration::from_secs(self.timeout_secs));
902        let resp = self.authenticated(req).send().await.map_err(|e| {
903            ::zeroclaw_log::record!(
904                ERROR,
905                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
906                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
907                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
908                "jira: Jira create_ticket request failed"
909            );
910            anyhow::Error::msg(format!("Jira create_ticket request failed: {e}"))
911        })?;
912
913        let status = resp.status();
914        if !status.is_success() {
915            let text = resp.text().await.unwrap_or_default();
916            anyhow::bail!(
917                "Jira create_ticket failed ({status}): {}",
918                crate::util_helpers::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
919            );
920        }
921
922        let raw: Value = resp.json().await.map_err(|e| {
923            ::zeroclaw_log::record!(
924                ERROR,
925                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
926                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
927                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
928                "jira: Failed to parse Jira create_ticket response"
929            );
930            anyhow::Error::msg(format!("Failed to parse Jira create_ticket response: {e}"))
931        })?;
932
933        let key = raw["key"].as_str().unwrap_or("");
934        let output = json!({
935            "id":         raw["id"],
936            "key":        key,
937            "self_url":   raw["self"],
938            "browse_url": format!("{}/browse/{}", self.base_url, key),
939        });
940        Ok(ToolResult {
941            success: true,
942            output: serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()),
943            error: None,
944        })
945    }
946}
947
948#[async_trait]
949impl Tool for JiraTool {
950    fn name(&self) -> &str {
951        "jira"
952    }
953
954    fn description(&self) -> &str {
955        "Interact with Jira: read tickets, search with JQL, add comments, list projects and per-issue transitions, transition an issue through its workflow, and create new issues."
956    }
957
958    fn parameters_schema(&self) -> serde_json::Value {
959        json!({
960            "type": "object",
961            "properties": {
962                "action": {
963                    "type": "string",
964                    "enum": [
965                        "get_ticket",
966                        "search_tickets",
967                        "comment_ticket",
968                        "list_projects",
969                        "myself",
970                        "list_transitions",
971                        "transition_ticket",
972                        "create_ticket"
973                    ],
974                    "description": "The Jira action to perform. Enabled actions are configured in [jira].allowed_actions. Use 'myself' to verify that credentials are valid and the Jira connection is working."
975                },
976                "issue_key": {
977                    "type": "string",
978                    "description": "Jira issue key, e.g. 'PROJ-123'. Required for get_ticket, comment_ticket, list_transitions, and transition_ticket."
979                },
980                "level_of_details": {
981                    "type": "string",
982                    "enum": ["basic", "basic_search", "full", "changelog"],
983                    "description": "How much data to return for get_ticket. Omit to use the default ('basic'). Options: 'basic' — summary, status, priority, assignee, rendered description, and rendered comments (best for reading a ticket in full); 'basic_search' — lightweight fields only, no description or comments (best when you only need to identify the ticket); 'full' — all Jira fields plus rendered HTML (verbose, use sparingly); 'changelog' — issue key and full change history only."
984                },
985                "jql": {
986                    "type": "string",
987                    "description": "JQL query string for search_tickets. Example: 'project = PROJ AND status = \"In Progress\" ORDER BY updated DESC'."
988                },
989                "max_results": {
990                    "type": "integer",
991                    "description": "Maximum number of issues to return for search_tickets. Defaults to 25, capped at 999.",
992                    "default": 25
993                },
994                "comment": {
995                    "type": "string",
996                    "description": "Comment body for comment_ticket. In Jira Cloud mode, supports a limited markdown-like syntax converted to Atlassian Document Format (ADF): mention a user with @user@domain.com (the leading @ is required; a bare email without @ prefix is treated as plain text), bold with **text**, bullet list items with a leading '- ', and newlines as line breaks. In Jira Server/Data Center mode, comments are posted as plain text with no ADF conversion or mention resolution. Example: 'Hi @john@company.com, this is **important**.\n- Check the logs\n- Rerun the pipeline'"
997                },
998                "transition_id": {
999                    "type": "string",
1000                    "description": "Transition ID to apply for transition_ticket. Provide either transition_id or transition_name (not both). Use list_transitions to discover the IDs valid for an issue's current state."
1001                },
1002                "transition_name": {
1003                    "type": "string",
1004                    "description": "Transition name (case-insensitive) to apply for transition_ticket, e.g. 'In Progress' or 'Done'. Provide either transition_id or transition_name (not both). The tool resolves the name against the issue's available transitions and returns an error listing valid names if not found."
1005                },
1006                "project_key": {
1007                    "type": "string",
1008                    "description": "Jira project key, e.g. 'PROJ'. Required for create_ticket. Use list_projects to discover keys."
1009                },
1010                "issue_type": {
1011                    "type": "string",
1012                    "description": "Issue type name, e.g. 'Task', 'Bug', 'Story'. Required for create_ticket. Valid values per project are returned by list_projects."
1013                },
1014                "summary": {
1015                    "type": "string",
1016                    "description": "Ticket title. Required for create_ticket. Must be non-empty."
1017                },
1018                "description": {
1019                    "type": "string",
1020                    "description": "Ticket description for create_ticket. Optional. In Jira Cloud mode, the same limited markdown-like syntax as 'comment' is supported and rendered to ADF (no mention resolution). In Server/Data Center mode, sent as plain text."
1021                },
1022                "assignee": {
1023                    "type": "string",
1024                    "description": "Assignee for create_ticket. Optional. In Jira Cloud, pass an accountId; in Server/Data Center, pass a username."
1025                },
1026                "labels": {
1027                    "type": "array",
1028                    "items": {"type": "string"},
1029                    "description": "Labels to attach to the new issue for create_ticket. Optional."
1030                },
1031                "parent_key": {
1032                    "type": "string",
1033                    "description": "Parent issue key for create_ticket. Optional. Used for sub-tasks or to set the parent epic (e.g. 'PROJ-100')."
1034                }
1035            },
1036            "required": ["action"]
1037        })
1038    }
1039
1040    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1041        let action = match args.get("action").and_then(|v| v.as_str()) {
1042            Some(a) => a,
1043            None => {
1044                return Ok(ToolResult {
1045                    success: false,
1046                    output: String::new(),
1047                    error: Some("Missing required parameter: action".into()),
1048                });
1049            }
1050        };
1051
1052        // Reject unknown actions before the allowlist check so typos produce a
1053        // clear "unknown action" error rather than a misleading "not enabled" one.
1054        if !matches!(
1055            action,
1056            "get_ticket"
1057                | "search_tickets"
1058                | "comment_ticket"
1059                | "list_projects"
1060                | "myself"
1061                | "list_transitions"
1062                | "transition_ticket"
1063                | "create_ticket"
1064        ) {
1065            return Ok(ToolResult {
1066                success: false,
1067                output: String::new(),
1068                error: Some(format!(
1069                    "Unknown action: '{action}'. Valid actions: get_ticket, search_tickets, comment_ticket, list_projects, myself, list_transitions, transition_ticket, create_ticket"
1070                )),
1071            });
1072        }
1073
1074        if !self.is_action_allowed(action) {
1075            return Ok(ToolResult {
1076                success: false,
1077                output: String::new(),
1078                error: Some(format!(
1079                    "Action '{action}' is not enabled. Add it to jira.allowed_actions in config.toml. \
1080                     Currently allowed: {}",
1081                    self.allowed_actions.join(", ")
1082                )),
1083            });
1084        }
1085
1086        let operation = match action {
1087            "get_ticket" | "search_tickets" | "list_projects" | "myself" | "list_transitions" => {
1088                ToolOperation::Read
1089            }
1090            "comment_ticket" | "transition_ticket" | "create_ticket" => ToolOperation::Act,
1091            _ => unreachable!(),
1092        };
1093
1094        if let Err(error) = self.security.enforce_tool_operation(operation, "jira") {
1095            return Ok(ToolResult {
1096                success: false,
1097                output: String::new(),
1098                error: Some(error),
1099            });
1100        }
1101
1102        let result = match action {
1103            "get_ticket" => {
1104                let issue_key = match args.get("issue_key").and_then(|v| v.as_str()) {
1105                    Some(k) => k,
1106                    None => {
1107                        return Ok(ToolResult {
1108                            success: false,
1109                            output: String::new(),
1110                            error: Some("get_ticket requires issue_key parameter".into()),
1111                        });
1112                    }
1113                };
1114                let level = match args.get("level_of_details").and_then(|v| v.as_str()) {
1115                    Some("basic_search") => LevelOfDetails::BasicSearch,
1116                    Some("full") => LevelOfDetails::Full,
1117                    Some("changelog") => LevelOfDetails::Changelog,
1118                    _ => LevelOfDetails::Basic,
1119                };
1120                self.get_ticket(issue_key, level).await
1121            }
1122            "search_tickets" => {
1123                let jql = match args.get("jql").and_then(|v| v.as_str()) {
1124                    Some(j) => j,
1125                    None => {
1126                        return Ok(ToolResult {
1127                            success: false,
1128                            output: String::new(),
1129                            error: Some("search_tickets requires jql parameter".into()),
1130                        });
1131                    }
1132                };
1133                let max_results = args
1134                    .get("max_results")
1135                    .and_then(|v| v.as_u64())
1136                    .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
1137                self.search_tickets(jql, max_results).await
1138            }
1139            "myself" => self.get_myself().await,
1140            "list_projects" => self.list_projects().await,
1141            "comment_ticket" => {
1142                let issue_key = match args.get("issue_key").and_then(|v| v.as_str()) {
1143                    Some(k) => k,
1144                    None => {
1145                        return Ok(ToolResult {
1146                            success: false,
1147                            output: String::new(),
1148                            error: Some("comment_ticket requires issue_key parameter".into()),
1149                        });
1150                    }
1151                };
1152                let comment = match args.get("comment").and_then(|v| v.as_str()) {
1153                    Some(c) if !c.trim().is_empty() => c,
1154                    _ => {
1155                        return Ok(ToolResult {
1156                            success: false,
1157                            output: String::new(),
1158                            error: Some(
1159                                "comment_ticket requires a non-empty comment parameter".into(),
1160                            ),
1161                        });
1162                    }
1163                };
1164                self.comment_ticket(issue_key, comment).await
1165            }
1166            "list_transitions" => {
1167                let issue_key = match args.get("issue_key").and_then(|v| v.as_str()) {
1168                    Some(k) => k,
1169                    None => {
1170                        return Ok(ToolResult {
1171                            success: false,
1172                            output: String::new(),
1173                            error: Some("list_transitions requires issue_key parameter".into()),
1174                        });
1175                    }
1176                };
1177                self.list_transitions(issue_key).await
1178            }
1179            "transition_ticket" => {
1180                let issue_key = match args.get("issue_key").and_then(|v| v.as_str()) {
1181                    Some(k) => k,
1182                    None => {
1183                        return Ok(ToolResult {
1184                            success: false,
1185                            output: String::new(),
1186                            error: Some("transition_ticket requires issue_key parameter".into()),
1187                        });
1188                    }
1189                };
1190                let transition_id = args
1191                    .get("transition_id")
1192                    .and_then(|v| v.as_str())
1193                    .filter(|s| !s.trim().is_empty());
1194                let transition_name = args
1195                    .get("transition_name")
1196                    .and_then(|v| v.as_str())
1197                    .filter(|s| !s.trim().is_empty());
1198                if transition_id.is_none() && transition_name.is_none() {
1199                    return Ok(ToolResult {
1200                        success: false,
1201                        output: String::new(),
1202                        error: Some(
1203                            "transition_ticket requires either transition_id or transition_name"
1204                                .into(),
1205                        ),
1206                    });
1207                }
1208                if transition_id.is_some() && transition_name.is_some() {
1209                    return Ok(ToolResult {
1210                        success: false,
1211                        output: String::new(),
1212                        error: Some(
1213                            "transition_ticket accepts only one of transition_id or transition_name, not both".into(),
1214                        ),
1215                    });
1216                }
1217                self.transition_ticket(issue_key, transition_id, transition_name)
1218                    .await
1219            }
1220            "create_ticket" => {
1221                let project_key = match args.get("project_key").and_then(|v| v.as_str()) {
1222                    Some(k) if !k.trim().is_empty() => k,
1223                    _ => {
1224                        return Ok(ToolResult {
1225                            success: false,
1226                            output: String::new(),
1227                            error: Some(
1228                                "create_ticket requires a non-empty project_key parameter".into(),
1229                            ),
1230                        });
1231                    }
1232                };
1233                let issue_type = match args.get("issue_type").and_then(|v| v.as_str()) {
1234                    Some(t) if !t.trim().is_empty() => t,
1235                    _ => {
1236                        return Ok(ToolResult {
1237                            success: false,
1238                            output: String::new(),
1239                            error: Some(
1240                                "create_ticket requires a non-empty issue_type parameter".into(),
1241                            ),
1242                        });
1243                    }
1244                };
1245                let summary = match args.get("summary").and_then(|v| v.as_str()) {
1246                    Some(s) if !s.trim().is_empty() => s,
1247                    _ => {
1248                        return Ok(ToolResult {
1249                            success: false,
1250                            output: String::new(),
1251                            error: Some(
1252                                "create_ticket requires a non-empty summary parameter".into(),
1253                            ),
1254                        });
1255                    }
1256                };
1257                let description = args
1258                    .get("description")
1259                    .and_then(|v| v.as_str())
1260                    .filter(|s| !s.is_empty());
1261                let assignee = args
1262                    .get("assignee")
1263                    .and_then(|v| v.as_str())
1264                    .filter(|s| !s.trim().is_empty());
1265                let labels: Option<Vec<String>> = args.get("labels").and_then(|v| {
1266                    v.as_array().map(|arr| {
1267                        arr.iter()
1268                            .filter_map(|x| x.as_str().map(String::from))
1269                            .collect()
1270                    })
1271                });
1272                let parent_key = args
1273                    .get("parent_key")
1274                    .and_then(|v| v.as_str())
1275                    .filter(|s| !s.trim().is_empty());
1276                self.create_ticket(
1277                    project_key,
1278                    issue_type,
1279                    summary,
1280                    description,
1281                    assignee,
1282                    labels.as_deref(),
1283                    parent_key,
1284                )
1285                .await
1286            }
1287            _ => unreachable!(),
1288        };
1289
1290        match result {
1291            Ok(tool_result) => Ok(tool_result),
1292            Err(e) => Ok(ToolResult {
1293                success: false,
1294                output: String::new(),
1295                error: Some(e.to_string()),
1296            }),
1297        }
1298    }
1299}
1300
1301// ── Input validation ──────────────────────────────────────────────────────────
1302
1303/// Validates that `issue_key` matches the Jira key format `PROJ-123` or `proj-123`.
1304/// Prevents path traversal if a crafted key like `../../other` were interpolated
1305/// directly into the URL.
1306fn validate_issue_key(key: &str) -> anyhow::Result<()> {
1307    let valid = key.split_once('-').is_some_and(|(project, number)| {
1308        !project.is_empty()
1309            && project.chars().all(|c| c.is_ascii_alphanumeric())
1310            && !number.is_empty()
1311            && number.chars().all(|c| c.is_ascii_digit())
1312    });
1313    if valid {
1314        Ok(())
1315    } else {
1316        anyhow::bail!(
1317            "Invalid issue key '{key}'. Expected format: PROJECT-123 (e.g. PROJ-42, proj-42)"
1318        )
1319    }
1320}
1321
1322/// Validates that `key` matches the Jira project key format. Same character
1323/// class as the project portion of `validate_issue_key` so the two stay in
1324/// step.
1325fn validate_project_key(key: &str) -> anyhow::Result<()> {
1326    let valid = !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric());
1327    if valid {
1328        Ok(())
1329    } else {
1330        anyhow::bail!("Invalid project key '{key}'. Expected ASCII alphanumeric, e.g. PROJ")
1331    }
1332}
1333
1334// ── Response shaping ──────────────────────────────────────────────────────────
1335
1336/// Safely extracts the first 10 characters (date prefix) from a string.
1337/// Returns the full string if it is shorter than 10 characters instead of
1338/// panicking on out-of-bounds slice indexing.
1339fn date_prefix(s: &str) -> &str {
1340    s.get(..10).unwrap_or(s)
1341}
1342
1343fn shape_basic(raw: &Value) -> Value {
1344    let f = &raw["fields"];
1345    let rf = &raw["renderedFields"];
1346
1347    // Build a lookup map from comment ID → rendered body for O(1) access
1348    // instead of scanning the rendered array for each comment (O(n²)).
1349    let rendered_by_id: HashMap<&str, &str> = rf["comment"]["comments"]
1350        .as_array()
1351        .map(|arr| {
1352            arr.iter()
1353                .filter_map(|rc| Some((rc["id"].as_str()?, rc["body"].as_str()?)))
1354                .collect()
1355        })
1356        .unwrap_or_default();
1357
1358    let comments: Vec<Value> = f["comment"]["comments"]
1359        .as_array()
1360        .map(|arr| {
1361            arr.iter()
1362                .map(|c| {
1363                    let id = c["id"].as_str().unwrap_or("");
1364                    json!({
1365                        "author": c["author"]["displayName"],
1366                        "created": date_prefix(c["created"].as_str().unwrap_or("")),
1367                        "body": rendered_by_id.get(id).copied().unwrap_or("")
1368                    })
1369                })
1370                .collect()
1371        })
1372        .unwrap_or_default();
1373
1374    json!({
1375        "key":         raw["key"],
1376        "summary":     f["summary"],
1377        "status":      f["status"]["name"],
1378        "priority":    f["priority"]["name"],
1379        "assignee":    f["assignee"]["displayName"],
1380        "created":     date_prefix(f["created"].as_str().unwrap_or("")),
1381        "updated":     date_prefix(f["updated"].as_str().unwrap_or("")),
1382        "description": rf["description"].as_str().unwrap_or(""),
1383        "comments":    comments,
1384    })
1385}
1386
1387fn shape_basic_search(raw: &Value) -> Value {
1388    let f = &raw["fields"];
1389    json!({
1390        "key":      raw["key"],
1391        "summary":  f["summary"],
1392        "status":   f["status"]["name"],
1393        "priority": f["priority"]["name"],
1394        "assignee": f["assignee"]["displayName"],
1395        "created":  date_prefix(f["created"].as_str().unwrap_or("")),
1396        "updated":  date_prefix(f["updated"].as_str().unwrap_or("")),
1397    })
1398}
1399
1400fn shape_full(raw: &Value) -> Value {
1401    let mut result = raw.clone();
1402    let rf = &raw["renderedFields"];
1403
1404    if let Some(desc) = rf["description"].as_str() {
1405        result["fields"]["description"] = json!(desc);
1406    }
1407
1408    if let (Some(comments), Some(rendered_comments)) = (
1409        result["fields"]["comment"]["comments"].as_array_mut(),
1410        rf["comment"]["comments"].as_array(),
1411    ) {
1412        for (c, rc) in comments.iter_mut().zip(rendered_comments.iter()) {
1413            if let Some(body) = rc["body"].as_str() {
1414                c["body"] = json!(body);
1415            }
1416        }
1417    }
1418
1419    result.as_object_mut().unwrap().remove("renderedFields");
1420    result
1421}
1422
1423fn shape_changelog(raw: &Value) -> Value {
1424    json!({
1425        "key":       raw["key"],
1426        "changelog": raw["changelog"],
1427    })
1428}
1429
1430/// Returns only the comment ID, author, and creation date — avoids
1431/// exposing internal Jira metadata back to the AI.
1432fn shape_comment_response(raw: &Value) -> Value {
1433    json!({
1434        "id":      raw["id"],
1435        "author":  raw["author"]["displayName"],
1436        "created": date_prefix(raw["created"].as_str().unwrap_or("")),
1437    })
1438}
1439
1440/// Trims Jira's transitions response to `[{ id, name, to_status }]`, dropping
1441/// icons, conditions, and other workflow-engine internals.
1442fn shape_transitions(raw: &Value) -> Vec<Value> {
1443    raw["transitions"]
1444        .as_array()
1445        .map(|arr| {
1446            arr.iter()
1447                .map(|t| {
1448                    json!({
1449                        "id":        t["id"],
1450                        "name":      t["name"],
1451                        "to_status": t["to"]["name"],
1452                    })
1453                })
1454                .collect()
1455        })
1456        .unwrap_or_default()
1457}
1458
1459fn shape_projects(projects: &[Value], statuses_per_project: &[Value]) -> Vec<Value> {
1460    projects
1461        .iter()
1462        .zip(statuses_per_project.iter())
1463        .map(|(p, statuses)| {
1464            let mut issue_types: Vec<String> = Vec::new();
1465            let mut all_statuses: HashSet<String> = HashSet::new();
1466
1467            if let Some(arr) = statuses.as_array() {
1468                for it in arr {
1469                    if let Some(name) = it["name"].as_str() {
1470                        issue_types.push(name.to_string());
1471                    }
1472                    if let Some(ss) = it["statuses"].as_array() {
1473                        for s in ss {
1474                            if let Some(sn) = s["name"].as_str() {
1475                                all_statuses.insert(sn.to_string());
1476                            }
1477                        }
1478                    }
1479                }
1480            }
1481
1482            let mut ordered: Vec<String> = all_statuses.into_iter().collect();
1483            ordered.sort();
1484
1485            json!({
1486                "key":         p["key"],
1487                "name":        p["name"],
1488                "projectType": p["projectTypeKey"],
1489                "style":       p["style"],
1490                "issueTypes":  issue_types,
1491                "statuses":    ordered,
1492            })
1493        })
1494        .collect()
1495}
1496
1497// ── Comment / ADF builder ─────────────────────────────────────────────────────
1498
1499/// Strips trailing punctuation that commonly appears after an email address
1500/// (e.g. `@john@co.com,` or `@john@co.com)`). Also strips leading bracket-like
1501/// punctuation so `@(john@co.com)` resolves correctly.
1502fn clean_email(s: &str) -> &str {
1503    s.trim_start_matches(['(', '['])
1504        .trim_end_matches([',', '!', '?', ':', ';', ')', ']'])
1505}
1506
1507fn extract_emails(text: &str) -> Vec<String> {
1508    let mut emails = Vec::new();
1509    for word in text.split_whitespace() {
1510        if let Some(rest) = word.strip_prefix('@') {
1511            let email = clean_email(rest);
1512            if email.contains('@') {
1513                emails.push(email.to_string());
1514            }
1515        }
1516    }
1517    let mut seen = std::collections::HashSet::new();
1518    emails.retain(|e| seen.insert(e.clone()));
1519    emails
1520}
1521
1522fn parse_inline(text: &str, mentions: &HashMap<String, (String, String)>) -> Vec<Value> {
1523    let mut nodes: Vec<Value> = Vec::new();
1524    let mut chars = text.chars().peekable();
1525    let mut current = String::new();
1526
1527    while let Some(ch) = chars.next() {
1528        if ch == '*' && chars.peek() == Some(&'*') {
1529            chars.next(); // consume second *
1530            if !current.is_empty() {
1531                nodes.push(json!({ "type": "text", "text": current.clone() }));
1532                current.clear();
1533            }
1534            let mut bold = String::new();
1535            let mut closed = false;
1536            loop {
1537                match chars.next() {
1538                    Some('*') if chars.peek() == Some(&'*') => {
1539                        chars.next(); // consume second *
1540                        closed = true;
1541                        break;
1542                    }
1543                    Some(c) => bold.push(c),
1544                    None => break,
1545                }
1546            }
1547            if closed && !bold.is_empty() {
1548                nodes.push(json!({
1549                    "type": "text",
1550                    "text": bold,
1551                    "marks": [{ "type": "strong" }]
1552                }));
1553            } else if !bold.is_empty() {
1554                // Unmatched ** — emit as literal text
1555                current.push_str("**");
1556                current.push_str(&bold);
1557            }
1558        } else if ch == '@' {
1559            let mut raw = String::new();
1560            while let Some(&next) = chars.peek() {
1561                if next.is_whitespace() {
1562                    break;
1563                }
1564                raw.push(chars.next().unwrap());
1565            }
1566            let email = clean_email(&raw);
1567            // Compute the end position of `email` within `raw` via pointer
1568            // arithmetic so the suffix is correct even when leading chars were
1569            // stripped by clean_email.
1570            let email_end = (email.as_ptr() as usize - raw.as_ptr() as usize) + email.len();
1571            let suffix = &raw[email_end..];
1572            if email.contains('@') {
1573                if let Some((account_id, display_name)) = mentions.get(email) {
1574                    if !current.is_empty() {
1575                        nodes.push(json!({ "type": "text", "text": current.clone() }));
1576                        current.clear();
1577                    }
1578                    nodes.push(json!({
1579                        "type": "mention",
1580                        "attrs": {
1581                            "id": account_id,
1582                            "text": format!("@{}", display_name)
1583                        }
1584                    }));
1585                    if !suffix.is_empty() {
1586                        current.push_str(suffix);
1587                    }
1588                } else {
1589                    current.push('@');
1590                    current.push_str(&raw);
1591                }
1592            } else {
1593                current.push('@');
1594                current.push_str(email);
1595            }
1596        } else {
1597            current.push(ch);
1598        }
1599    }
1600
1601    if !current.is_empty() {
1602        nodes.push(json!({ "type": "text", "text": current }));
1603    }
1604
1605    nodes
1606}
1607
1608fn build_adf(text: &str, mentions: &HashMap<String, (String, String)>) -> Value {
1609    let mut content: Vec<Value> = Vec::new();
1610    let mut paragraph: Vec<Value> = Vec::new();
1611    let mut list_items: Vec<Value> = Vec::new();
1612
1613    let flush_paragraph = |paragraph: &mut Vec<Value>, content: &mut Vec<Value>| {
1614        if !paragraph.is_empty() {
1615            content.push(json!({ "type": "paragraph", "content": paragraph.clone() }));
1616            paragraph.clear();
1617        }
1618    };
1619
1620    let flush_list = |list_items: &mut Vec<Value>, content: &mut Vec<Value>| {
1621        if !list_items.is_empty() {
1622            content.push(json!({ "type": "bulletList", "content": list_items.clone() }));
1623            list_items.clear();
1624        }
1625    };
1626
1627    for line in text.lines() {
1628        if line.trim().is_empty() {
1629            flush_paragraph(&mut paragraph, &mut content);
1630            flush_list(&mut list_items, &mut content);
1631        } else if let Some(item) = line.strip_prefix("- ") {
1632            flush_paragraph(&mut paragraph, &mut content);
1633            let inline = parse_inline(item, mentions);
1634            list_items.push(json!({
1635                "type": "listItem",
1636                "content": [{ "type": "paragraph", "content": inline }]
1637            }));
1638        } else {
1639            flush_list(&mut list_items, &mut content);
1640            if !paragraph.is_empty() {
1641                paragraph.push(json!({ "type": "hardBreak" }));
1642            }
1643            paragraph.extend(parse_inline(line, mentions));
1644        }
1645    }
1646
1647    flush_paragraph(&mut paragraph, &mut content);
1648    flush_list(&mut list_items, &mut content);
1649
1650    json!({ "type": "doc", "version": 1, "content": content })
1651}
1652
1653// ── Tests ─────────────────────────────────────────────────────────────────────
1654
1655#[cfg(test)]
1656mod tests {
1657    use super::*;
1658    use zeroclaw_config::autonomy::AutonomyLevel;
1659    use zeroclaw_config::policy::SecurityPolicy;
1660
1661    fn test_security() -> Arc<SecurityPolicy> {
1662        Arc::new(SecurityPolicy {
1663            autonomy: AutonomyLevel::Supervised,
1664            ..SecurityPolicy::default()
1665        })
1666    }
1667
1668    fn test_tool_with_base_url(
1669        base_url: String,
1670        email: Option<String>,
1671        api_token: &str,
1672        allowed_actions: Vec<&str>,
1673    ) -> JiraTool {
1674        JiraTool::new(
1675            base_url,
1676            email,
1677            api_token.into(),
1678            allowed_actions.into_iter().map(String::from).collect(),
1679            test_security(),
1680            30,
1681        )
1682    }
1683
1684    /// Cloud mode helper (email present → API v3 + Basic auth).
1685    fn test_tool(allowed_actions: Vec<&str>) -> JiraTool {
1686        test_tool_with_base_url(
1687            "https://test.atlassian.net".into(),
1688            Some("test@example.com".into()),
1689            "test-token",
1690            allowed_actions,
1691        )
1692    }
1693
1694    /// Server/DC mode helper (no email → API v2 + Bearer auth).
1695    fn test_tool_server(allowed_actions: Vec<&str>) -> JiraTool {
1696        test_tool_with_base_url(
1697            "https://internal-jira.company.com".into(),
1698            None,
1699            "pat-token-abc",
1700            allowed_actions,
1701        )
1702    }
1703
1704    fn basic_auth_header(email: &str, token: &str) -> String {
1705        use base64::Engine as _;
1706
1707        let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{email}:{token}"));
1708        format!("Basic {encoded}")
1709    }
1710
1711    fn basic_search_issue(key: &str) -> Value {
1712        json!({
1713            "key": key,
1714            "fields": {
1715                "summary": "Fix bug",
1716                "status": { "name": "In Progress" },
1717                "priority": { "name": "High" },
1718                "assignee": { "displayName": "Jane" },
1719                "created": "2024-01-15T10:00:00.000Z",
1720                "updated": "2024-03-01T12:00:00.000Z"
1721            }
1722        })
1723    }
1724
1725    // ── API version / auth mode tests ───────────────────────────────────────
1726
1727    #[test]
1728    fn cloud_tool_uses_api_v3() {
1729        let tool = test_tool(vec!["get_ticket"]);
1730        assert_eq!(tool.api_version(), "3");
1731        assert!(tool.is_cloud());
1732    }
1733
1734    #[test]
1735    fn server_tool_uses_api_v2() {
1736        let tool = test_tool_server(vec!["get_ticket"]);
1737        assert_eq!(tool.api_version(), "2");
1738        assert!(!tool.is_cloud());
1739    }
1740
1741    #[test]
1742    fn tool_name_is_jira() {
1743        assert_eq!(test_tool(vec!["get_ticket"]).name(), "jira");
1744    }
1745
1746    // ── Request shape tests ─────────────────────────────────────────────────
1747
1748    #[tokio::test]
1749    async fn cloud_search_uses_basic_auth_v3_endpoint_and_next_page_token() {
1750        use wiremock::matchers::{body_json, header, method, path};
1751        use wiremock::{Mock, MockServer, ResponseTemplate};
1752
1753        let server = MockServer::start().await;
1754        let auth = basic_auth_header("test@example.com", "test-token");
1755        let fields = json!([
1756            "summary", "priority", "status", "assignee", "created", "updated"
1757        ]);
1758
1759        let first_body = json!({
1760            "jql": "project = PROJ",
1761            "maxResults": 2,
1762            "fields": fields
1763        });
1764        Mock::given(method("POST"))
1765            .and(path("/rest/api/3/search/jql"))
1766            .and(header("authorization", auth.as_str()))
1767            .and(body_json(&first_body))
1768            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1769                "issues": [basic_search_issue("PROJ-1")],
1770                "isLast": false,
1771                "nextPageToken": "page-2"
1772            })))
1773            .expect(1)
1774            .mount(&server)
1775            .await;
1776
1777        let second_body = json!({
1778            "jql": "project = PROJ",
1779            "maxResults": 1,
1780            "fields": fields,
1781            "nextPageToken": "page-2"
1782        });
1783        Mock::given(method("POST"))
1784            .and(path("/rest/api/3/search/jql"))
1785            .and(header("authorization", auth.as_str()))
1786            .and(body_json(&second_body))
1787            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1788                "issues": [basic_search_issue("PROJ-2")],
1789                "isLast": true
1790            })))
1791            .expect(1)
1792            .mount(&server)
1793            .await;
1794
1795        let tool = test_tool_with_base_url(
1796            server.uri(),
1797            Some("test@example.com".into()),
1798            "test-token",
1799            vec!["search_tickets"],
1800        );
1801        let result = tool
1802            .execute(json!({
1803                "action": "search_tickets",
1804                "jql": "project = PROJ",
1805                "max_results": 2
1806            }))
1807            .await
1808            .unwrap();
1809
1810        assert!(result.success, "unexpected error: {:?}", result.error);
1811        let output: Value = serde_json::from_str(&result.output).unwrap();
1812        assert_eq!(output.as_array().unwrap().len(), 2);
1813        server.verify().await;
1814    }
1815
1816    #[tokio::test]
1817    async fn server_search_uses_bearer_auth_v2_endpoint_and_start_at() {
1818        use wiremock::matchers::{body_json, header, method, path};
1819        use wiremock::{Mock, MockServer, ResponseTemplate};
1820
1821        let server = MockServer::start().await;
1822        let fields = json!([
1823            "summary", "priority", "status", "assignee", "created", "updated"
1824        ]);
1825
1826        let first_body = json!({
1827            "jql": "project = PROJ",
1828            "startAt": 0,
1829            "maxResults": 2,
1830            "fields": fields
1831        });
1832        Mock::given(method("POST"))
1833            .and(path("/rest/api/2/search"))
1834            .and(header("authorization", "Bearer pat-token-abc"))
1835            .and(body_json(&first_body))
1836            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1837                "issues": [basic_search_issue("PROJ-1")],
1838                "total": 2
1839            })))
1840            .expect(1)
1841            .mount(&server)
1842            .await;
1843
1844        let second_body = json!({
1845            "jql": "project = PROJ",
1846            "startAt": 1,
1847            "maxResults": 1,
1848            "fields": fields
1849        });
1850        Mock::given(method("POST"))
1851            .and(path("/rest/api/2/search"))
1852            .and(header("authorization", "Bearer pat-token-abc"))
1853            .and(body_json(&second_body))
1854            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1855                "issues": [basic_search_issue("PROJ-2")],
1856                "total": 2
1857            })))
1858            .expect(1)
1859            .mount(&server)
1860            .await;
1861
1862        let tool =
1863            test_tool_with_base_url(server.uri(), None, "pat-token-abc", vec!["search_tickets"]);
1864        let result = tool
1865            .execute(json!({
1866                "action": "search_tickets",
1867                "jql": "project = PROJ",
1868                "max_results": 2
1869            }))
1870            .await
1871            .unwrap();
1872
1873        assert!(result.success, "unexpected error: {:?}", result.error);
1874        let output: Value = serde_json::from_str(&result.output).unwrap();
1875        assert_eq!(output.as_array().unwrap().len(), 2);
1876        server.verify().await;
1877    }
1878
1879    #[tokio::test]
1880    async fn cloud_comment_posts_adf_body_to_v3_endpoint() {
1881        use wiremock::matchers::{body_json, header, method, path};
1882        use wiremock::{Mock, MockServer, ResponseTemplate};
1883
1884        let server = MockServer::start().await;
1885        let comment = "This is **important**.\n- Check the logs";
1886        let expected_body = json!({ "body": build_adf(comment, &HashMap::new()) });
1887        let auth = basic_auth_header("test@example.com", "test-token");
1888
1889        Mock::given(method("POST"))
1890            .and(path("/rest/api/3/issue/PROJ-1/comment"))
1891            .and(header("authorization", auth.as_str()))
1892            .and(body_json(&expected_body))
1893            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1894                "id": "10000",
1895                "author": { "displayName": "Jane" },
1896                "created": "2024-01-15T10:00:00.000Z"
1897            })))
1898            .expect(1)
1899            .mount(&server)
1900            .await;
1901
1902        let tool = test_tool_with_base_url(
1903            server.uri(),
1904            Some("test@example.com".into()),
1905            "test-token",
1906            vec!["comment_ticket"],
1907        );
1908        let result = tool
1909            .execute(json!({
1910                "action": "comment_ticket",
1911                "issue_key": "PROJ-1",
1912                "comment": comment
1913            }))
1914            .await
1915            .unwrap();
1916
1917        assert!(result.success, "unexpected error: {:?}", result.error);
1918        server.verify().await;
1919    }
1920
1921    #[tokio::test]
1922    async fn server_comment_posts_plain_text_body_to_v2_endpoint() {
1923        use wiremock::matchers::{body_json, header, method, path};
1924        use wiremock::{Mock, MockServer, ResponseTemplate};
1925
1926        let server = MockServer::start().await;
1927        let comment = "Hi @john@company.com, this is **important**.\n- Check the logs";
1928        let expected_body = json!({ "body": comment });
1929
1930        Mock::given(method("POST"))
1931            .and(path("/rest/api/2/issue/PROJ-1/comment"))
1932            .and(header("authorization", "Bearer pat-token-abc"))
1933            .and(body_json(&expected_body))
1934            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1935                "id": "10001",
1936                "author": { "displayName": "Jane" },
1937                "created": "2024-01-15T10:00:00.000Z"
1938            })))
1939            .expect(1)
1940            .mount(&server)
1941            .await;
1942
1943        let tool =
1944            test_tool_with_base_url(server.uri(), None, "pat-token-abc", vec!["comment_ticket"]);
1945        let result = tool
1946            .execute(json!({
1947                "action": "comment_ticket",
1948                "issue_key": "PROJ-1",
1949                "comment": comment
1950            }))
1951            .await
1952            .unwrap();
1953
1954        assert!(result.success, "unexpected error: {:?}", result.error);
1955        server.verify().await;
1956    }
1957
1958    #[test]
1959    fn parameters_schema_has_required_action() {
1960        let schema = test_tool(vec!["get_ticket"]).parameters_schema();
1961        let required = schema["required"].as_array().unwrap();
1962        assert!(required.iter().any(|v| v.as_str() == Some("action")));
1963    }
1964
1965    #[test]
1966    fn parameters_schema_defines_all_actions() {
1967        let schema = test_tool(vec!["get_ticket"]).parameters_schema();
1968        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
1969        let action_strs: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
1970        assert!(action_strs.contains(&"get_ticket"));
1971        assert!(action_strs.contains(&"search_tickets"));
1972        assert!(action_strs.contains(&"comment_ticket"));
1973    }
1974
1975    #[test]
1976    fn parameters_schema_describes_cloud_and_server_comment_modes() {
1977        let schema = test_tool(vec!["comment_ticket"]).parameters_schema();
1978        let description = schema["properties"]["comment"]["description"]
1979            .as_str()
1980            .unwrap();
1981
1982        assert!(description.contains("Jira Cloud mode"));
1983        assert!(description.contains("Atlassian Document Format"));
1984        assert!(description.contains("Jira Server/Data Center mode"));
1985        assert!(description.contains("plain text"));
1986    }
1987
1988    #[tokio::test]
1989    async fn execute_missing_action_returns_error() {
1990        let result = test_tool(vec!["get_ticket"])
1991            .execute(json!({}))
1992            .await
1993            .unwrap();
1994        assert!(!result.success);
1995        assert!(result.error.as_deref().unwrap().contains("action"));
1996    }
1997
1998    #[tokio::test]
1999    async fn execute_unknown_action_returns_error() {
2000        let result = test_tool(vec!["get_ticket"])
2001            .execute(json!({"action": "delete_ticket"}))
2002            .await
2003            .unwrap();
2004        assert!(!result.success);
2005        assert!(result.error.as_deref().unwrap().contains("Unknown action"));
2006    }
2007
2008    #[tokio::test]
2009    async fn execute_disallowed_action_returns_error() {
2010        let result = test_tool(vec!["get_ticket"])
2011            .execute(json!({"action": "comment_ticket"}))
2012            .await
2013            .unwrap();
2014        assert!(!result.success);
2015        let err = result.error.unwrap();
2016        assert!(err.contains("not enabled"));
2017        assert!(err.contains("allowed_actions"));
2018    }
2019
2020    #[tokio::test]
2021    async fn execute_get_ticket_missing_key_returns_error() {
2022        let result = test_tool(vec!["get_ticket"])
2023            .execute(json!({"action": "get_ticket"}))
2024            .await
2025            .unwrap();
2026        assert!(!result.success);
2027        assert!(result.error.as_deref().unwrap().contains("issue_key"));
2028    }
2029
2030    #[tokio::test]
2031    async fn execute_search_tickets_missing_jql_returns_error() {
2032        let result = test_tool(vec!["get_ticket", "search_tickets"])
2033            .execute(json!({"action": "search_tickets"}))
2034            .await
2035            .unwrap();
2036        assert!(!result.success);
2037        assert!(result.error.as_deref().unwrap().contains("jql"));
2038    }
2039
2040    #[tokio::test]
2041    async fn execute_comment_ticket_missing_key_returns_error() {
2042        let result = test_tool(vec!["get_ticket", "comment_ticket"])
2043            .execute(json!({"action": "comment_ticket", "comment": "hello"}))
2044            .await
2045            .unwrap();
2046        assert!(!result.success);
2047        assert!(result.error.as_deref().unwrap().contains("issue_key"));
2048    }
2049
2050    #[tokio::test]
2051    async fn execute_comment_ticket_missing_comment_returns_error() {
2052        let result = test_tool(vec!["get_ticket", "comment_ticket"])
2053            .execute(json!({"action": "comment_ticket", "issue_key": "PROJ-1"}))
2054            .await
2055            .unwrap();
2056        assert!(!result.success);
2057        assert!(result.error.as_deref().unwrap().contains("comment"));
2058    }
2059
2060    #[tokio::test]
2061    async fn execute_comment_ticket_empty_comment_returns_error() {
2062        let result = test_tool(vec!["get_ticket", "comment_ticket"])
2063            .execute(json!({"action": "comment_ticket", "issue_key": "PROJ-1", "comment": "   "}))
2064            .await
2065            .unwrap();
2066        assert!(!result.success);
2067        assert!(result.error.as_deref().unwrap().contains("comment"));
2068    }
2069
2070    #[tokio::test]
2071    async fn execute_comment_blocked_in_readonly_mode() {
2072        let security = Arc::new(SecurityPolicy {
2073            autonomy: AutonomyLevel::ReadOnly,
2074            ..SecurityPolicy::default()
2075        });
2076        let tool = JiraTool::new(
2077            "https://test.atlassian.net".into(),
2078            Some("test@example.com".into()),
2079            "token".into(),
2080            vec!["get_ticket".into(), "comment_ticket".into()],
2081            security,
2082            30,
2083        );
2084        let result = tool
2085            .execute(json!({
2086                "action": "comment_ticket",
2087                "issue_key": "PROJ-1",
2088                "comment": "hello"
2089            }))
2090            .await
2091            .unwrap();
2092        assert!(!result.success);
2093        assert!(result.error.as_deref().unwrap().contains("read-only"));
2094    }
2095
2096    // ── myself action ────────────────────────────────────────────────────────
2097
2098    #[test]
2099    fn parameters_schema_includes_myself_action() {
2100        let schema = test_tool(vec!["myself"]).parameters_schema();
2101        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
2102        let action_strs: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
2103        assert!(action_strs.contains(&"myself"));
2104    }
2105
2106    #[tokio::test]
2107    async fn execute_myself_disallowed_returns_error() {
2108        let result = test_tool(vec!["get_ticket"])
2109            .execute(json!({"action": "myself"}))
2110            .await
2111            .unwrap();
2112        assert!(!result.success);
2113        let err = result.error.unwrap();
2114        assert!(err.contains("not enabled"));
2115        assert!(err.contains("allowed_actions"));
2116    }
2117
2118    #[tokio::test]
2119    async fn execute_myself_not_blocked_in_readonly_mode() {
2120        // myself is a Read operation — the security policy should not block it.
2121        // The call will fail at the HTTP level (no real server), not at the
2122        // policy level, so the error must NOT contain "read-only".
2123        let security = Arc::new(SecurityPolicy {
2124            autonomy: AutonomyLevel::ReadOnly,
2125            ..SecurityPolicy::default()
2126        });
2127        let tool = JiraTool::new(
2128            "https://test.atlassian.net".into(),
2129            Some("test@example.com".into()),
2130            "token".into(),
2131            vec!["myself".into()],
2132            security,
2133            30,
2134        );
2135        let result = tool.execute(json!({"action": "myself"})).await.unwrap();
2136        assert!(!result.success);
2137        assert!(!result.error.as_deref().unwrap_or("").contains("read-only"));
2138    }
2139
2140    // ── Issue key validation ──────────────────────────────────────────────────
2141
2142    #[test]
2143    fn validate_issue_key_accepts_valid_keys() {
2144        assert!(validate_issue_key("PROJ-1").is_ok());
2145        assert!(validate_issue_key("PROJ-123").is_ok());
2146        assert!(validate_issue_key("AB-99").is_ok());
2147        assert!(validate_issue_key("MYPROJECT-1000").is_ok());
2148        assert!(validate_issue_key("proj-1").is_ok());
2149        assert!(validate_issue_key("proj-123").is_ok());
2150    }
2151
2152    #[test]
2153    fn validate_issue_key_rejects_path_traversal() {
2154        assert!(validate_issue_key("../../etc/passwd").is_err());
2155        assert!(validate_issue_key("../other").is_err());
2156    }
2157
2158    #[test]
2159    fn validate_issue_key_rejects_malformed() {
2160        assert!(validate_issue_key("PROJ").is_err()); // no number
2161        assert!(validate_issue_key("PROJ-").is_err()); // empty number
2162        assert!(validate_issue_key("-123").is_err()); // no project
2163        assert!(validate_issue_key("PROJ-12x").is_err()); // non-digit in number
2164    }
2165
2166    // ── ADF builder unit tests ────────────────────────────────────────────────
2167
2168    #[test]
2169    fn build_adf_plain_text() {
2170        let adf = build_adf("Hello world", &HashMap::new());
2171        assert_eq!(adf["type"], "doc");
2172        assert_eq!(adf["version"], 1);
2173        let para = &adf["content"][0];
2174        assert_eq!(para["type"], "paragraph");
2175        assert_eq!(para["content"][0]["text"], "Hello world");
2176    }
2177
2178    #[test]
2179    fn build_adf_bold() {
2180        let adf = build_adf("**bold**", &HashMap::new());
2181        let text_node = &adf["content"][0]["content"][0];
2182        assert_eq!(text_node["text"], "bold");
2183        assert_eq!(text_node["marks"][0]["type"], "strong");
2184    }
2185
2186    #[test]
2187    fn build_adf_unmatched_bold_is_literal() {
2188        let adf = build_adf("**no closing", &HashMap::new());
2189        let text = &adf["content"][0]["content"][0]["text"];
2190        assert!(text.as_str().unwrap().contains("**no closing"));
2191    }
2192
2193    #[test]
2194    fn build_adf_bullet_list() {
2195        let adf = build_adf("- first\n- second", &HashMap::new());
2196        let list = &adf["content"][0];
2197        assert_eq!(list["type"], "bulletList");
2198        assert_eq!(list["content"].as_array().unwrap().len(), 2);
2199        assert_eq!(list["content"][0]["type"], "listItem");
2200    }
2201
2202    #[test]
2203    fn build_adf_mention_resolved() {
2204        let mut mentions = HashMap::new();
2205        mentions.insert(
2206            "john@company.com".to_string(),
2207            ("acc-123".to_string(), "John Doe".to_string()),
2208        );
2209        let adf = build_adf("Hi @john@company.com done", &mentions);
2210        let content = &adf["content"][0]["content"];
2211        let mention = content
2212            .as_array()
2213            .unwrap()
2214            .iter()
2215            .find(|n| n["type"] == "mention")
2216            .unwrap();
2217        assert_eq!(mention["attrs"]["id"], "acc-123");
2218        assert_eq!(mention["attrs"]["text"], "@John Doe");
2219    }
2220
2221    #[test]
2222    fn build_adf_unresolved_mention_rendered_as_plain_text() {
2223        let adf = build_adf("Hi @unknown@example.com", &HashMap::new());
2224        let text = &adf["content"][0]["content"][0]["text"];
2225        assert!(text.as_str().unwrap().contains("@unknown@example.com"));
2226    }
2227
2228    #[test]
2229    fn extract_emails_finds_at_prefixed_emails() {
2230        let emails = extract_emails("Hello @john@company.com and @jane@corp.io done");
2231        assert_eq!(emails, vec!["john@company.com", "jane@corp.io"]);
2232    }
2233
2234    #[test]
2235    fn extract_emails_deduplicates() {
2236        let emails = extract_emails("@a@b.com @a@b.com");
2237        assert_eq!(emails.len(), 1);
2238    }
2239
2240    #[test]
2241    fn extract_emails_deduplicates_non_adjacent() {
2242        let emails = extract_emails("@a@b.com @c@d.com @a@b.com");
2243        assert_eq!(emails, vec!["a@b.com", "c@d.com"]);
2244    }
2245
2246    #[test]
2247    fn extract_emails_strips_trailing_punctuation() {
2248        let emails = extract_emails("@john@company.com,");
2249        assert_eq!(emails, vec!["john@company.com"]);
2250    }
2251
2252    #[test]
2253    fn extract_emails_strips_leading_punctuation() {
2254        let emails = extract_emails("@(john@company.com)");
2255        assert_eq!(emails, vec!["john@company.com"]);
2256    }
2257
2258    #[test]
2259    fn shape_basic_search_extracts_expected_fields() {
2260        let raw = json!({
2261            "key": "PROJ-1",
2262            "fields": {
2263                "summary": "Fix bug",
2264                "status": { "name": "In Progress" },
2265                "priority": { "name": "High" },
2266                "assignee": { "displayName": "Jane" },
2267                "created": "2024-01-15T10:00:00.000Z",
2268                "updated": "2024-03-01T12:00:00.000Z"
2269            }
2270        });
2271        let shaped = shape_basic_search(&raw);
2272        assert_eq!(shaped["key"], "PROJ-1");
2273        assert_eq!(shaped["summary"], "Fix bug");
2274        assert_eq!(shaped["status"], "In Progress");
2275        assert_eq!(shaped["priority"], "High");
2276        assert_eq!(shaped["assignee"], "Jane");
2277        assert_eq!(shaped["created"], "2024-01-15");
2278        assert_eq!(shaped["updated"], "2024-03-01");
2279    }
2280
2281    #[test]
2282    fn shape_changelog_extracts_key_and_changelog() {
2283        let raw = json!({
2284            "key": "PROJ-42",
2285            "changelog": { "histories": [] },
2286            "fields": {}
2287        });
2288        let shaped = shape_changelog(&raw);
2289        assert_eq!(shaped["key"], "PROJ-42");
2290        assert!(shaped.get("changelog").is_some());
2291        assert!(shaped.get("fields").is_none());
2292    }
2293
2294    #[test]
2295    fn shape_comment_response_extracts_id_author_created() {
2296        let raw = json!({
2297            "id": "12345",
2298            "author": { "displayName": "Alice", "accountId": "abc" },
2299            "created": "2024-06-01T09:00:00.000Z",
2300            "body": { "type": "doc" },
2301            "self": "https://internal.url"
2302        });
2303        let shaped = shape_comment_response(&raw);
2304        assert_eq!(shaped["id"], "12345");
2305        assert_eq!(shaped["author"], "Alice");
2306        assert_eq!(shaped["created"], "2024-06-01");
2307        assert!(shaped.get("body").is_none());
2308        assert!(shaped.get("self").is_none());
2309    }
2310
2311    // ── date_prefix helper ─────────────────────────────────────────────────
2312
2313    #[test]
2314    fn date_prefix_normal_date_string() {
2315        assert_eq!(date_prefix("2024-01-15T10:00:00.000Z"), "2024-01-15");
2316    }
2317
2318    #[test]
2319    fn date_prefix_empty_string() {
2320        assert_eq!(date_prefix(""), "");
2321    }
2322
2323    #[test]
2324    fn date_prefix_short_string() {
2325        assert_eq!(date_prefix("2024"), "2024");
2326    }
2327
2328    #[test]
2329    fn date_prefix_exactly_ten_chars() {
2330        assert_eq!(date_prefix("2024-01-15"), "2024-01-15");
2331    }
2332
2333    #[test]
2334    fn shape_basic_uses_o1_comment_lookup() {
2335        // Verify that comments are matched by ID, not by position.
2336        let raw = json!({
2337            "key": "PROJ-1",
2338            "fields": {
2339                "summary": "s", "priority": {"name":"P"}, "status": {"name":"S"},
2340                "assignee": {"displayName":"A"},
2341                "created": "2024-01-01T00:00:00.000Z",
2342                "updated": "2024-01-01T00:00:00.000Z",
2343                "comment": {
2344                    "comments": [
2345                        { "id": "2", "author": {"displayName":"Bob"}, "created": "2024-01-02T00:00:00.000Z" },
2346                        { "id": "1", "author": {"displayName":"Alice"}, "created": "2024-01-01T00:00:00.000Z" }
2347                    ]
2348                }
2349            },
2350            "renderedFields": {
2351                "description": "",
2352                "comment": {
2353                    "comments": [
2354                        { "id": "1", "body": "Alice's body" },
2355                        { "id": "2", "body": "Bob's body" }
2356                    ]
2357                }
2358            }
2359        });
2360        let shaped = shape_basic(&raw);
2361        // Comment with id "2" (Bob) should get Bob's rendered body, not Alice's
2362        assert_eq!(shaped["comments"][0]["author"], "Bob");
2363        assert_eq!(shaped["comments"][0]["body"], "Bob's body");
2364        assert_eq!(shaped["comments"][1]["author"], "Alice");
2365        assert_eq!(shaped["comments"][1]["body"], "Alice's body");
2366    }
2367
2368    // ── list_projects action ────────────────────────────────────────────────
2369
2370    #[test]
2371    fn parameters_schema_includes_list_projects_action() {
2372        let schema = test_tool(vec!["list_projects"]).parameters_schema();
2373        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
2374        let action_strs: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
2375        assert!(action_strs.contains(&"list_projects"));
2376    }
2377
2378    #[tokio::test]
2379    async fn execute_list_projects_disallowed_returns_error() {
2380        let result = test_tool(vec!["get_ticket"])
2381            .execute(json!({"action": "list_projects"}))
2382            .await
2383            .unwrap();
2384        assert!(!result.success);
2385        let err = result.error.unwrap();
2386        assert!(err.contains("not enabled"));
2387        assert!(err.contains("allowed_actions"));
2388    }
2389
2390    #[tokio::test]
2391    async fn execute_list_projects_not_blocked_in_readonly_mode() {
2392        let security = Arc::new(SecurityPolicy {
2393            autonomy: AutonomyLevel::ReadOnly,
2394            ..SecurityPolicy::default()
2395        });
2396        let tool = JiraTool::new(
2397            "https://127.0.0.1:1".into(),
2398            Some("test@example.com".into()),
2399            "token".into(),
2400            vec!["list_projects".into()],
2401            security,
2402            30,
2403        );
2404        let result = tool
2405            .execute(json!({"action": "list_projects"}))
2406            .await
2407            .unwrap();
2408        assert!(!result.success);
2409        assert!(
2410            !result.error.as_deref().unwrap_or("").contains("read-only"),
2411            "error should not mention read-only policy: {:?}",
2412            result.error
2413        );
2414    }
2415
2416    #[test]
2417    fn shape_projects_extracts_expected_fields() {
2418        let projects = json!([
2419            { "key": "AT", "name": "ALL TASKS", "projectTypeKey": "business", "style": "next-gen" },
2420            { "key": "GP", "name": "G-PROJECT", "projectTypeKey": "software", "style": "next-gen" }
2421        ]);
2422        let statuses: Vec<Value> = vec![
2423            json!([
2424                { "name": "Task", "statuses": [
2425                    { "name": "To Do" }, { "name": "In Progress" }, { "name": "Collecting Intel" }, { "name": "Done" }
2426                ]},
2427                { "name": "Sub-task", "statuses": [
2428                    { "name": "To Do" }, { "name": "Verification" }
2429                ]}
2430            ]),
2431            json!([
2432                { "name": "Task", "statuses": [
2433                    { "name": "To Do" }, { "name": "Design" }, { "name": "Done" }
2434                ]},
2435                { "name": "Epic", "statuses": [
2436                    { "name": "To Do" }, { "name": "Done" }
2437                ]}
2438            ]),
2439        ];
2440        let shaped = shape_projects(projects.as_array().unwrap(), &statuses);
2441        let arr = &shaped;
2442
2443        assert_eq!(arr.len(), 2);
2444
2445        assert_eq!(arr[0]["key"], "AT");
2446        assert_eq!(arr[0]["name"], "ALL TASKS");
2447        assert_eq!(arr[0]["projectType"], "business");
2448        let at_statuses: Vec<&str> = arr[0]["statuses"]
2449            .as_array()
2450            .unwrap()
2451            .iter()
2452            .filter_map(|v| v.as_str())
2453            .collect();
2454        assert_eq!(
2455            at_statuses,
2456            vec![
2457                "Collecting Intel",
2458                "Done",
2459                "In Progress",
2460                "To Do",
2461                "Verification",
2462            ]
2463        );
2464        let at_types: Vec<&str> = arr[0]["issueTypes"]
2465            .as_array()
2466            .unwrap()
2467            .iter()
2468            .filter_map(|v| v.as_str())
2469            .collect();
2470        assert!(at_types.contains(&"Task"));
2471        assert!(at_types.contains(&"Sub-task"));
2472
2473        assert_eq!(arr[1]["key"], "GP");
2474        assert_eq!(arr[1]["projectType"], "software");
2475        let gp_statuses: Vec<&str> = arr[1]["statuses"]
2476            .as_array()
2477            .unwrap()
2478            .iter()
2479            .filter_map(|v| v.as_str())
2480            .collect();
2481        assert_eq!(gp_statuses, vec!["Design", "Done", "To Do"]);
2482
2483        assert!(
2484            arr[0].get("users").is_none(),
2485            "users should not be in per-project data"
2486        );
2487    }
2488
2489    #[test]
2490    fn shape_projects_sorts_statuses_alphabetically() {
2491        let projects = json!([
2492            { "key": "P", "name": "P", "projectTypeKey": "software", "style": "next-gen" }
2493        ]);
2494        let statuses: Vec<Value> = vec![json!([
2495            { "name": "Task", "statuses": [
2496                { "name": "Done" }, { "name": "Custom" }, { "name": "To Do" }, { "name": "Alpha" }
2497            ]}
2498        ])];
2499        let shaped = shape_projects(projects.as_array().unwrap(), &statuses);
2500        let ordered: Vec<&str> = shaped[0]["statuses"]
2501            .as_array()
2502            .unwrap()
2503            .iter()
2504            .filter_map(|v| v.as_str())
2505            .collect();
2506        assert_eq!(ordered, vec!["Alpha", "Custom", "Done", "To Do"]);
2507    }
2508
2509    #[test]
2510    fn shape_projects_empty_inputs() {
2511        let shaped = shape_projects(&[], &[]);
2512        assert_eq!(shaped.len(), 0);
2513    }
2514
2515    // ── list_transitions / transition_ticket / create_ticket ─────────────────
2516
2517    #[test]
2518    fn parameters_schema_includes_new_actions() {
2519        let schema = test_tool(vec!["get_ticket"]).parameters_schema();
2520        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
2521        let action_strs: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
2522        assert!(action_strs.contains(&"list_transitions"));
2523        assert!(action_strs.contains(&"transition_ticket"));
2524        assert!(action_strs.contains(&"create_ticket"));
2525    }
2526
2527    #[test]
2528    fn parameters_schema_describes_transition_params() {
2529        let schema = test_tool(vec!["transition_ticket"]).parameters_schema();
2530        let props = &schema["properties"];
2531        assert!(props["transition_id"].is_object());
2532        assert!(props["transition_name"].is_object());
2533    }
2534
2535    #[test]
2536    fn parameters_schema_describes_create_params() {
2537        let schema = test_tool(vec!["create_ticket"]).parameters_schema();
2538        let props = &schema["properties"];
2539        for key in [
2540            "project_key",
2541            "issue_type",
2542            "summary",
2543            "description",
2544            "assignee",
2545            "labels",
2546            "parent_key",
2547        ] {
2548            assert!(props[key].is_object(), "missing schema property: {key}");
2549        }
2550    }
2551
2552    #[tokio::test]
2553    async fn execute_list_transitions_disallowed_returns_error() {
2554        let result = test_tool(vec!["get_ticket"])
2555            .execute(json!({"action": "list_transitions", "issue_key": "PROJ-1"}))
2556            .await
2557            .unwrap();
2558        assert!(!result.success);
2559        assert!(result.error.as_deref().unwrap().contains("not enabled"));
2560    }
2561
2562    #[tokio::test]
2563    async fn execute_transition_ticket_blocked_in_readonly_mode() {
2564        let security = Arc::new(SecurityPolicy {
2565            autonomy: AutonomyLevel::ReadOnly,
2566            ..SecurityPolicy::default()
2567        });
2568        let tool = JiraTool::new(
2569            "https://test.atlassian.net".into(),
2570            Some("test@example.com".into()),
2571            "token".into(),
2572            vec!["transition_ticket".into()],
2573            security,
2574            30,
2575        );
2576        let result = tool
2577            .execute(json!({
2578                "action": "transition_ticket",
2579                "issue_key": "PROJ-1",
2580                "transition_id": "31"
2581            }))
2582            .await
2583            .unwrap();
2584        assert!(!result.success);
2585        assert!(result.error.as_deref().unwrap().contains("read-only"));
2586    }
2587
2588    #[tokio::test]
2589    async fn execute_create_ticket_blocked_in_readonly_mode() {
2590        let security = Arc::new(SecurityPolicy {
2591            autonomy: AutonomyLevel::ReadOnly,
2592            ..SecurityPolicy::default()
2593        });
2594        let tool = JiraTool::new(
2595            "https://test.atlassian.net".into(),
2596            Some("test@example.com".into()),
2597            "token".into(),
2598            vec!["create_ticket".into()],
2599            security,
2600            30,
2601        );
2602        let result = tool
2603            .execute(json!({
2604                "action": "create_ticket",
2605                "project_key": "PROJ",
2606                "issue_type": "Task",
2607                "summary": "test"
2608            }))
2609            .await
2610            .unwrap();
2611        assert!(!result.success);
2612        assert!(result.error.as_deref().unwrap().contains("read-only"));
2613    }
2614
2615    #[tokio::test]
2616    async fn execute_list_transitions_not_blocked_in_readonly_mode() {
2617        let security = Arc::new(SecurityPolicy {
2618            autonomy: AutonomyLevel::ReadOnly,
2619            ..SecurityPolicy::default()
2620        });
2621        let tool = JiraTool::new(
2622            "https://127.0.0.1:1".into(),
2623            Some("test@example.com".into()),
2624            "token".into(),
2625            vec!["list_transitions".into()],
2626            security,
2627            30,
2628        );
2629        let result = tool
2630            .execute(json!({"action": "list_transitions", "issue_key": "PROJ-1"}))
2631            .await
2632            .unwrap();
2633        assert!(!result.success);
2634        assert!(
2635            !result.error.as_deref().unwrap_or("").contains("read-only"),
2636            "list_transitions should be a Read op, but error mentioned read-only: {:?}",
2637            result.error
2638        );
2639    }
2640
2641    #[tokio::test]
2642    async fn execute_list_transitions_missing_key_returns_error() {
2643        let result = test_tool(vec!["list_transitions"])
2644            .execute(json!({"action": "list_transitions"}))
2645            .await
2646            .unwrap();
2647        assert!(!result.success);
2648        assert!(result.error.as_deref().unwrap().contains("issue_key"));
2649    }
2650
2651    #[tokio::test]
2652    async fn execute_transition_ticket_missing_id_and_name_returns_error() {
2653        let result = test_tool(vec!["transition_ticket"])
2654            .execute(json!({"action": "transition_ticket", "issue_key": "PROJ-1"}))
2655            .await
2656            .unwrap();
2657        assert!(!result.success);
2658        let err = result.error.unwrap();
2659        assert!(err.contains("transition_id") && err.contains("transition_name"));
2660    }
2661
2662    #[tokio::test]
2663    async fn execute_transition_ticket_both_id_and_name_returns_error() {
2664        let result = test_tool(vec!["transition_ticket"])
2665            .execute(json!({
2666                "action": "transition_ticket",
2667                "issue_key": "PROJ-1",
2668                "transition_id": "31",
2669                "transition_name": "In Progress"
2670            }))
2671            .await
2672            .unwrap();
2673        assert!(!result.success);
2674        assert!(result.error.as_deref().unwrap().contains("only one"));
2675    }
2676
2677    #[tokio::test]
2678    async fn execute_create_ticket_missing_required_fields_returns_error() {
2679        let tool = test_tool(vec!["create_ticket"]);
2680        // Missing project_key
2681        let r1 = tool
2682            .execute(json!({
2683                "action": "create_ticket",
2684                "issue_type": "Task",
2685                "summary": "x"
2686            }))
2687            .await
2688            .unwrap();
2689        assert!(!r1.success);
2690        assert!(r1.error.as_deref().unwrap().contains("project_key"));
2691        // Missing issue_type
2692        let r2 = tool
2693            .execute(json!({
2694                "action": "create_ticket",
2695                "project_key": "PROJ",
2696                "summary": "x"
2697            }))
2698            .await
2699            .unwrap();
2700        assert!(!r2.success);
2701        assert!(r2.error.as_deref().unwrap().contains("issue_type"));
2702        // Missing summary
2703        let r3 = tool
2704            .execute(json!({
2705                "action": "create_ticket",
2706                "project_key": "PROJ",
2707                "issue_type": "Task"
2708            }))
2709            .await
2710            .unwrap();
2711        assert!(!r3.success);
2712        assert!(r3.error.as_deref().unwrap().contains("summary"));
2713    }
2714
2715    #[tokio::test]
2716    async fn cloud_list_transitions_returns_shaped_response() {
2717        use wiremock::matchers::{header, method, path};
2718        use wiremock::{Mock, MockServer, ResponseTemplate};
2719
2720        let server = MockServer::start().await;
2721        let auth = basic_auth_header("test@example.com", "test-token");
2722
2723        Mock::given(method("GET"))
2724            .and(path("/rest/api/3/issue/PROJ-1/transitions"))
2725            .and(header("authorization", auth.as_str()))
2726            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2727                "transitions": [
2728                    { "id": "11", "name": "To Do",       "to": { "name": "To Do" }, "isAvailable": true },
2729                    { "id": "21", "name": "In Progress", "to": { "name": "In Progress" } },
2730                    { "id": "31", "name": "Done",        "to": { "name": "Done" } }
2731                ]
2732            })))
2733            .expect(1)
2734            .mount(&server)
2735            .await;
2736
2737        let tool = test_tool_with_base_url(
2738            server.uri(),
2739            Some("test@example.com".into()),
2740            "test-token",
2741            vec!["list_transitions"],
2742        );
2743        let result = tool
2744            .execute(json!({"action": "list_transitions", "issue_key": "PROJ-1"}))
2745            .await
2746            .unwrap();
2747        assert!(result.success, "unexpected error: {:?}", result.error);
2748        let output: Value = serde_json::from_str(&result.output).unwrap();
2749        let arr = output["transitions"].as_array().unwrap();
2750        assert_eq!(arr.len(), 3);
2751        assert_eq!(arr[1]["id"], "21");
2752        assert_eq!(arr[1]["name"], "In Progress");
2753        assert_eq!(arr[1]["to_status"], "In Progress");
2754        // Verbose Jira fields are dropped.
2755        assert!(arr[0].get("isAvailable").is_none());
2756        server.verify().await;
2757    }
2758
2759    #[tokio::test]
2760    async fn cloud_transition_ticket_by_id_posts_expected_body() {
2761        use wiremock::matchers::{body_json, header, method, path};
2762        use wiremock::{Mock, MockServer, ResponseTemplate};
2763
2764        let server = MockServer::start().await;
2765        let auth = basic_auth_header("test@example.com", "test-token");
2766        let body = json!({ "transition": { "id": "31" } });
2767
2768        Mock::given(method("POST"))
2769            .and(path("/rest/api/3/issue/PROJ-1/transitions"))
2770            .and(header("authorization", auth.as_str()))
2771            .and(body_json(&body))
2772            .respond_with(ResponseTemplate::new(204))
2773            .expect(1)
2774            .mount(&server)
2775            .await;
2776
2777        let tool = test_tool_with_base_url(
2778            server.uri(),
2779            Some("test@example.com".into()),
2780            "test-token",
2781            vec!["transition_ticket"],
2782        );
2783        let result = tool
2784            .execute(json!({
2785                "action": "transition_ticket",
2786                "issue_key": "PROJ-1",
2787                "transition_id": "31"
2788            }))
2789            .await
2790            .unwrap();
2791        assert!(result.success, "unexpected error: {:?}", result.error);
2792        let output: Value = serde_json::from_str(&result.output).unwrap();
2793        assert_eq!(output["ok"], true);
2794        assert_eq!(output["transition_id"], "31");
2795        assert_eq!(output["issue_key"], "PROJ-1");
2796        server.verify().await;
2797    }
2798
2799    #[tokio::test]
2800    async fn server_transition_ticket_by_name_resolves_then_posts_to_v2() {
2801        use wiremock::matchers::{body_json, header, method, path};
2802        use wiremock::{Mock, MockServer, ResponseTemplate};
2803
2804        let server = MockServer::start().await;
2805
2806        Mock::given(method("GET"))
2807            .and(path("/rest/api/2/issue/PROJ-7/transitions"))
2808            .and(header("authorization", "Bearer pat-token-abc"))
2809            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2810                "transitions": [
2811                    { "id": "21", "name": "In Progress", "to": { "name": "In Progress" } },
2812                    { "id": "31", "name": "Done", "to": { "name": "Done" } }
2813                ]
2814            })))
2815            .expect(1)
2816            .mount(&server)
2817            .await;
2818
2819        let post_body = json!({ "transition": { "id": "21" } });
2820        Mock::given(method("POST"))
2821            .and(path("/rest/api/2/issue/PROJ-7/transitions"))
2822            .and(header("authorization", "Bearer pat-token-abc"))
2823            .and(body_json(&post_body))
2824            .respond_with(ResponseTemplate::new(204))
2825            .expect(1)
2826            .mount(&server)
2827            .await;
2828
2829        let tool = test_tool_with_base_url(
2830            server.uri(),
2831            None,
2832            "pat-token-abc",
2833            vec!["transition_ticket"],
2834        );
2835        let result = tool
2836            .execute(json!({
2837                "action": "transition_ticket",
2838                "issue_key": "PROJ-7",
2839                "transition_name": "in progress"
2840            }))
2841            .await
2842            .unwrap();
2843        assert!(result.success, "unexpected error: {:?}", result.error);
2844        let output: Value = serde_json::from_str(&result.output).unwrap();
2845        assert_eq!(output["transition_id"], "21");
2846        server.verify().await;
2847    }
2848
2849    #[tokio::test]
2850    async fn transition_ticket_unknown_name_returns_error_with_available() {
2851        use wiremock::matchers::{method, path};
2852        use wiremock::{Mock, MockServer, ResponseTemplate};
2853
2854        let server = MockServer::start().await;
2855
2856        Mock::given(method("GET"))
2857            .and(path("/rest/api/3/issue/PROJ-1/transitions"))
2858            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2859                "transitions": [
2860                    { "id": "21", "name": "In Progress", "to": { "name": "In Progress" } },
2861                    { "id": "31", "name": "Done", "to": { "name": "Done" } }
2862                ]
2863            })))
2864            .expect(1)
2865            .mount(&server)
2866            .await;
2867
2868        // No POST mock — if the tool tried to POST, the test would fail with
2869        // an unmocked request error from wiremock's verify().
2870        let tool = test_tool_with_base_url(
2871            server.uri(),
2872            Some("test@example.com".into()),
2873            "test-token",
2874            vec!["transition_ticket"],
2875        );
2876        let result = tool
2877            .execute(json!({
2878                "action": "transition_ticket",
2879                "issue_key": "PROJ-1",
2880                "transition_name": "Reticulate Splines"
2881            }))
2882            .await
2883            .unwrap();
2884        assert!(!result.success);
2885        let err = result.error.unwrap();
2886        assert!(err.contains("Reticulate Splines"));
2887        assert!(err.contains("In Progress"));
2888        assert!(err.contains("Done"));
2889        server.verify().await;
2890    }
2891
2892    #[tokio::test]
2893    async fn cloud_create_ticket_minimal_posts_expected_body() {
2894        use wiremock::matchers::{body_json, header, method, path};
2895        use wiremock::{Mock, MockServer, ResponseTemplate};
2896
2897        let server = MockServer::start().await;
2898        let auth = basic_auth_header("test@example.com", "test-token");
2899        let expected = json!({
2900            "fields": {
2901                "project":   { "key": "PROJ" },
2902                "issuetype": { "name": "Task" },
2903                "summary":   "My new task"
2904            }
2905        });
2906
2907        Mock::given(method("POST"))
2908            .and(path("/rest/api/3/issue"))
2909            .and(header("authorization", auth.as_str()))
2910            .and(body_json(&expected))
2911            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2912                "id":   "10042",
2913                "key":  "PROJ-99",
2914                "self": "https://test.atlassian.net/rest/api/3/issue/10042"
2915            })))
2916            .expect(1)
2917            .mount(&server)
2918            .await;
2919
2920        let tool = test_tool_with_base_url(
2921            server.uri(),
2922            Some("test@example.com".into()),
2923            "test-token",
2924            vec!["create_ticket"],
2925        );
2926        let result = tool
2927            .execute(json!({
2928                "action": "create_ticket",
2929                "project_key": "PROJ",
2930                "issue_type": "Task",
2931                "summary": "My new task"
2932            }))
2933            .await
2934            .unwrap();
2935        assert!(result.success, "unexpected error: {:?}", result.error);
2936        let output: Value = serde_json::from_str(&result.output).unwrap();
2937        assert_eq!(output["key"], "PROJ-99");
2938        assert_eq!(output["id"], "10042");
2939        assert_eq!(
2940            output["browse_url"].as_str().unwrap(),
2941            format!("{}/browse/PROJ-99", server.uri())
2942        );
2943        server.verify().await;
2944    }
2945
2946    #[tokio::test]
2947    async fn cloud_create_ticket_with_description_uses_adf() {
2948        use wiremock::matchers::{method, path};
2949        use wiremock::{Mock, MockServer, Request, ResponseTemplate};
2950
2951        let server = MockServer::start().await;
2952
2953        Mock::given(method("POST"))
2954            .and(path("/rest/api/3/issue"))
2955            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2956                "id": "1", "key": "PROJ-1", "self": "x"
2957            })))
2958            .expect(1)
2959            .mount(&server)
2960            .await;
2961
2962        let tool = test_tool_with_base_url(
2963            server.uri(),
2964            Some("test@example.com".into()),
2965            "test-token",
2966            vec!["create_ticket"],
2967        );
2968        tool.execute(json!({
2969            "action": "create_ticket",
2970            "project_key": "PROJ",
2971            "issue_type": "Task",
2972            "summary": "s",
2973            "description": "**bold** body"
2974        }))
2975        .await
2976        .unwrap();
2977
2978        let received = &server.received_requests().await.unwrap();
2979        let req: &Request = received.last().unwrap();
2980        let body: Value = serde_json::from_slice(&req.body).unwrap();
2981        let desc = &body["fields"]["description"];
2982        assert_eq!(desc["type"], "doc", "description must be ADF in Cloud mode");
2983        assert_eq!(desc["version"], 1);
2984    }
2985
2986    #[tokio::test]
2987    async fn server_create_ticket_with_description_uses_plain_string() {
2988        use wiremock::matchers::{method, path};
2989        use wiremock::{Mock, MockServer, Request, ResponseTemplate};
2990
2991        let server = MockServer::start().await;
2992
2993        Mock::given(method("POST"))
2994            .and(path("/rest/api/2/issue"))
2995            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2996                "id": "1", "key": "PROJ-1", "self": "x"
2997            })))
2998            .expect(1)
2999            .mount(&server)
3000            .await;
3001
3002        let tool =
3003            test_tool_with_base_url(server.uri(), None, "pat-token-abc", vec!["create_ticket"]);
3004        tool.execute(json!({
3005            "action": "create_ticket",
3006            "project_key": "PROJ",
3007            "issue_type": "Task",
3008            "summary": "s",
3009            "description": "plain text"
3010        }))
3011        .await
3012        .unwrap();
3013
3014        let received = &server.received_requests().await.unwrap();
3015        let req: &Request = received.last().unwrap();
3016        let body: Value = serde_json::from_slice(&req.body).unwrap();
3017        assert_eq!(
3018            body["fields"]["description"], "plain text",
3019            "description must be a plain string in Server mode"
3020        );
3021    }
3022
3023    #[tokio::test]
3024    async fn cloud_create_ticket_with_assignee_uses_account_id() {
3025        use wiremock::matchers::{method, path};
3026        use wiremock::{Mock, MockServer, Request, ResponseTemplate};
3027
3028        let server = MockServer::start().await;
3029        Mock::given(method("POST"))
3030            .and(path("/rest/api/3/issue"))
3031            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
3032                "id": "1", "key": "PROJ-1", "self": "x"
3033            })))
3034            .mount(&server)
3035            .await;
3036
3037        let tool = test_tool_with_base_url(
3038            server.uri(),
3039            Some("test@example.com".into()),
3040            "test-token",
3041            vec!["create_ticket"],
3042        );
3043        tool.execute(json!({
3044            "action": "create_ticket",
3045            "project_key": "PROJ",
3046            "issue_type": "Task",
3047            "summary": "s",
3048            "assignee": "acc-123"
3049        }))
3050        .await
3051        .unwrap();
3052
3053        let req: Request = server
3054            .received_requests()
3055            .await
3056            .unwrap()
3057            .last()
3058            .cloned()
3059            .unwrap();
3060        let body: Value = serde_json::from_slice(&req.body).unwrap();
3061        assert_eq!(body["fields"]["assignee"]["accountId"], "acc-123");
3062        assert!(body["fields"]["assignee"].get("name").is_none());
3063    }
3064
3065    #[tokio::test]
3066    async fn server_create_ticket_with_assignee_uses_username() {
3067        use wiremock::matchers::{method, path};
3068        use wiremock::{Mock, MockServer, Request, ResponseTemplate};
3069
3070        let server = MockServer::start().await;
3071        Mock::given(method("POST"))
3072            .and(path("/rest/api/2/issue"))
3073            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
3074                "id": "1", "key": "PROJ-1", "self": "x"
3075            })))
3076            .mount(&server)
3077            .await;
3078
3079        let tool =
3080            test_tool_with_base_url(server.uri(), None, "pat-token-abc", vec!["create_ticket"]);
3081        tool.execute(json!({
3082            "action": "create_ticket",
3083            "project_key": "PROJ",
3084            "issue_type": "Task",
3085            "summary": "s",
3086            "assignee": "jdoe"
3087        }))
3088        .await
3089        .unwrap();
3090
3091        let req: Request = server
3092            .received_requests()
3093            .await
3094            .unwrap()
3095            .last()
3096            .cloned()
3097            .unwrap();
3098        let body: Value = serde_json::from_slice(&req.body).unwrap();
3099        assert_eq!(body["fields"]["assignee"]["name"], "jdoe");
3100        assert!(body["fields"]["assignee"].get("accountId").is_none());
3101    }
3102
3103    #[tokio::test]
3104    async fn cloud_create_ticket_jira_error_surfaces_body() {
3105        use wiremock::matchers::{method, path};
3106        use wiremock::{Mock, MockServer, ResponseTemplate};
3107
3108        let server = MockServer::start().await;
3109        Mock::given(method("POST"))
3110            .and(path("/rest/api/3/issue"))
3111            .respond_with(
3112                ResponseTemplate::new(400)
3113                    .set_body_string(r#"{"errors":{"customfield_12345":"Field is required"}}"#),
3114            )
3115            .expect(1)
3116            .mount(&server)
3117            .await;
3118
3119        let tool = test_tool_with_base_url(
3120            server.uri(),
3121            Some("test@example.com".into()),
3122            "test-token",
3123            vec!["create_ticket"],
3124        );
3125        let result = tool
3126            .execute(json!({
3127                "action": "create_ticket",
3128                "project_key": "PROJ",
3129                "issue_type": "Task",
3130                "summary": "s"
3131            }))
3132            .await
3133            .unwrap();
3134        assert!(!result.success);
3135        let err = result.error.unwrap();
3136        assert!(err.contains("400"));
3137        assert!(err.contains("customfield_12345"));
3138        server.verify().await;
3139    }
3140
3141    #[test]
3142    fn validate_project_key_accepts_valid_keys() {
3143        assert!(validate_project_key("PROJ").is_ok());
3144        assert!(validate_project_key("ABC123").is_ok());
3145        assert!(validate_project_key("p1").is_ok());
3146    }
3147
3148    #[test]
3149    fn validate_project_key_rejects_invalid_keys() {
3150        assert!(validate_project_key("").is_err());
3151        assert!(validate_project_key("PROJ-1").is_err());
3152        assert!(validate_project_key("../etc").is_err());
3153        assert!(validate_project_key("PROJ ABC").is_err());
3154    }
3155
3156    #[test]
3157    fn shape_transitions_extracts_minimal_fields() {
3158        let raw = json!({
3159            "transitions": [
3160                {
3161                    "id": "11", "name": "To Do",
3162                    "to": { "name": "To Do", "id": "10000", "self": "https://x" },
3163                    "isAvailable": true
3164                },
3165                {
3166                    "id": "21", "name": "In Progress",
3167                    "to": { "name": "In Progress" }
3168                }
3169            ]
3170        });
3171        let shaped = shape_transitions(&raw);
3172        assert_eq!(shaped.len(), 2);
3173        assert_eq!(shaped[0]["id"], "11");
3174        assert_eq!(shaped[0]["name"], "To Do");
3175        assert_eq!(shaped[0]["to_status"], "To Do");
3176        assert!(shaped[0].get("isAvailable").is_none());
3177    }
3178
3179    #[test]
3180    fn shape_transitions_handles_missing_array() {
3181        assert!(shape_transitions(&json!({})).is_empty());
3182    }
3183}