Skip to main content

construct/tools/
jira_tool.rs

1use super::traits::{Tool, ToolResult};
2use crate::security::{SecurityPolicy, policy::ToolOperation};
3use async_trait::async_trait;
4use reqwest::Client;
5use serde_json::{Value, json};
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
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 v3.
23///
24/// Supports five actions gated by `[jira].allowed_actions` in config:
25/// - `get_ticket`     — always in the default allowlist; read-only.
26/// - `search_tickets` — requires explicit opt-in; read-only.
27/// - `comment_ticket` — requires explicit opt-in; mutating (Act policy).
28/// - `list_projects`  — requires explicit opt-in; read-only.
29/// - `myself`         — requires explicit opt-in; read-only. Verifies credentials.
30pub struct JiraTool {
31    base_url: String,
32    email: String,
33    api_token: String,
34    allowed_actions: Vec<String>,
35    http: Client,
36    security: Arc<SecurityPolicy>,
37    timeout_secs: u64,
38}
39
40impl JiraTool {
41    pub fn new(
42        base_url: String,
43        email: String,
44        api_token: String,
45        allowed_actions: Vec<String>,
46        security: Arc<SecurityPolicy>,
47        timeout_secs: u64,
48    ) -> Self {
49        Self {
50            base_url: base_url.trim_end_matches('/').to_string(),
51            email,
52            api_token,
53            allowed_actions,
54            http: Client::new(),
55            security,
56            timeout_secs,
57        }
58    }
59
60    fn is_action_allowed(&self, action: &str) -> bool {
61        self.allowed_actions.iter().any(|a| a == action)
62    }
63
64    async fn get_ticket(
65        &self,
66        issue_key: &str,
67        level: LevelOfDetails,
68    ) -> anyhow::Result<ToolResult> {
69        validate_issue_key(issue_key)?;
70        let url = format!("{}/rest/api/3/issue/{}", self.base_url, issue_key);
71
72        let query: Vec<(&str, &str)> = match &level {
73            LevelOfDetails::Basic => vec![
74                ("fields", "summary"),
75                ("fields", "priority"),
76                ("fields", "status"),
77                ("fields", "assignee"),
78                ("fields", "description"),
79                ("fields", "created"),
80                ("fields", "updated"),
81                ("fields", "comment"),
82                ("expand", "renderedFields"),
83            ],
84            LevelOfDetails::BasicSearch => vec![
85                ("fields", "summary"),
86                ("fields", "priority"),
87                ("fields", "status"),
88                ("fields", "assignee"),
89                ("fields", "created"),
90                ("fields", "updated"),
91            ],
92            LevelOfDetails::Full => vec![("expand", "renderedFields"), ("expand", "names")],
93            LevelOfDetails::Changelog => vec![("expand", "changelog")],
94        };
95
96        let resp = self
97            .http
98            .get(&url)
99            .basic_auth(&self.email, Some(&self.api_token))
100            .query(&query)
101            .timeout(std::time::Duration::from_secs(self.timeout_secs))
102            .send()
103            .await
104            .map_err(|e| anyhow::anyhow!("Jira get_ticket request failed: {e}"))?;
105
106        let status = resp.status();
107        if !status.is_success() {
108            let text = resp.text().await.unwrap_or_default();
109            anyhow::bail!(
110                "Jira get_ticket failed ({status}): {}",
111                crate::util::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
112            );
113        }
114
115        let raw: Value = resp
116            .json()
117            .await
118            .map_err(|e| anyhow::anyhow!("Failed to parse Jira get_ticket response: {e}"))?;
119
120        let shaped = match level {
121            LevelOfDetails::Basic => shape_basic(&raw),
122            LevelOfDetails::BasicSearch => shape_basic_search(&raw),
123            LevelOfDetails::Full => shape_full(&raw),
124            LevelOfDetails::Changelog => shape_changelog(&raw),
125        };
126
127        Ok(ToolResult {
128            success: true,
129            output: serde_json::to_string_pretty(&shaped).unwrap_or_else(|_| shaped.to_string()),
130            error: None,
131        })
132    }
133
134    #[allow(clippy::cast_possible_truncation)]
135    async fn search_tickets(
136        &self,
137        jql: &str,
138        max_results: Option<u32>,
139    ) -> anyhow::Result<ToolResult> {
140        let url = format!("{}/rest/api/3/search/jql", self.base_url);
141        let max_results = max_results.unwrap_or(25).clamp(1, 999);
142
143        let mut issues: Vec<Value> = Vec::new();
144        let mut next_page_token: Option<String> = None;
145
146        loop {
147            let remaining = max_results.saturating_sub(issues.len() as u32);
148
149            let page_size = remaining.min(JIRA_SEARCH_PAGE_SIZE);
150
151            let mut body = json!({
152                "jql": jql,
153                "maxResults": page_size,
154                "fields": ["summary", "priority", "status", "assignee", "created", "updated"]
155            });
156
157            if let Some(token) = &next_page_token {
158                body["nextPageToken"] = json!(token);
159            }
160
161            let resp = self
162                .http
163                .post(&url)
164                .basic_auth(&self.email, Some(&self.api_token))
165                .json(&body)
166                .timeout(std::time::Duration::from_secs(self.timeout_secs))
167                .send()
168                .await
169                .map_err(|e| anyhow::anyhow!("Jira search_tickets request failed: {e}"))?;
170
171            let status = resp.status();
172            if !status.is_success() {
173                let text = resp.text().await.unwrap_or_default();
174                anyhow::bail!(
175                    "Jira search_tickets failed ({status}): {}",
176                    crate::util::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
177                );
178            }
179
180            let raw: Value = resp
181                .json()
182                .await
183                .map_err(|e| anyhow::anyhow!("Failed to parse Jira search response: {e}"))?;
184
185            if let Some(page) = raw["issues"].as_array() {
186                issues.extend(page.iter().map(shape_basic_search));
187            }
188
189            let is_last = raw["isLast"].as_bool().unwrap_or(true);
190            if is_last || issues.len() as u32 >= max_results {
191                break;
192            }
193
194            next_page_token = raw["nextPageToken"].as_str().map(String::from);
195            if next_page_token.is_none() {
196                break;
197            }
198        }
199
200        let output = json!(issues);
201        Ok(ToolResult {
202            success: true,
203            output: serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()),
204            error: None,
205        })
206    }
207
208    async fn comment_ticket(
209        &self,
210        issue_key: &str,
211        comment_text: &str,
212    ) -> anyhow::Result<ToolResult> {
213        validate_issue_key(issue_key)?;
214
215        let emails = extract_emails(comment_text);
216        let mut mentions: HashMap<String, (String, String)> = HashMap::new();
217        for email in emails {
218            if let Some(info) = self.resolve_email(&email).await {
219                mentions.insert(email, info);
220            }
221        }
222
223        let adf = build_adf(comment_text, &mentions);
224
225        let url = format!("{}/rest/api/3/issue/{}/comment", self.base_url, issue_key);
226        let resp = self
227            .http
228            .post(&url)
229            .basic_auth(&self.email, Some(&self.api_token))
230            .json(&json!({ "body": adf }))
231            .timeout(std::time::Duration::from_secs(self.timeout_secs))
232            .send()
233            .await
234            .map_err(|e| anyhow::anyhow!("Jira comment_ticket request failed: {e}"))?;
235
236        let status = resp.status();
237        if !status.is_success() {
238            let text = resp.text().await.unwrap_or_default();
239            anyhow::bail!(
240                "Jira comment_ticket failed ({status}): {}",
241                crate::util::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
242            );
243        }
244
245        let response: Value = resp
246            .json()
247            .await
248            .map_err(|e| anyhow::anyhow!("Failed to parse Jira comment response: {e}"))?;
249
250        let shaped = shape_comment_response(&response);
251        Ok(ToolResult {
252            success: true,
253            output: serde_json::to_string_pretty(&shaped).unwrap_or_else(|_| shaped.to_string()),
254            error: None,
255        })
256    }
257
258    async fn list_projects(&self) -> anyhow::Result<ToolResult> {
259        let url = format!("{}/rest/api/3/project", self.base_url);
260
261        let resp = self
262            .http
263            .get(&url)
264            .basic_auth(&self.email, Some(&self.api_token))
265            .timeout(std::time::Duration::from_secs(self.timeout_secs))
266            .send()
267            .await
268            .map_err(|e| anyhow::anyhow!("Jira list_projects request failed: {e}"))?;
269
270        let status = resp.status();
271        if !status.is_success() {
272            let text = resp.text().await.unwrap_or_default();
273            anyhow::bail!(
274                "Jira list_projects failed ({status}): {}",
275                crate::util::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
276            );
277        }
278
279        let projects: Vec<Value> = resp
280            .json()
281            .await
282            .map_err(|e| anyhow::anyhow!("Failed to parse Jira list_projects response: {e}"))?;
283
284        let keys: Vec<String> = projects
285            .iter()
286            .filter_map(|p| p["key"].as_str().map(String::from))
287            .collect();
288
289        const STATUS_CONCURRENCY: usize = 5;
290
291        let users_url = format!(
292            "{}/rest/api/3/user/assignable/multiProjectSearch",
293            self.base_url
294        );
295
296        let users_resp = self
297            .http
298            .get(&users_url)
299            .basic_auth(&self.email, Some(&self.api_token))
300            .query(&[
301                ("projectKeys", keys.join(",").as_str()),
302                ("maxResults", "50"),
303            ])
304            .timeout(std::time::Duration::from_secs(self.timeout_secs))
305            .send()
306            .await
307            .map_err(|e| anyhow::anyhow!("Jira list_projects users request failed: {e}"))?;
308
309        let users: Vec<Value> = if users_resp.status().is_success() {
310            users_resp.json().await.map_err(|e| {
311                anyhow::anyhow!("Failed to parse Jira list_projects users response: {e}")
312            })?
313        } else {
314            let status = users_resp.status();
315            let text = users_resp.text().await.unwrap_or_default();
316            anyhow::bail!(
317                "Jira list_projects users failed ({status}): {}",
318                crate::util::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
319            );
320        };
321
322        let mut set: tokio::task::JoinSet<(usize, anyhow::Result<Value>)> =
323            tokio::task::JoinSet::new();
324        let mut statuses_results = vec![json!([]); keys.len()];
325
326        for (i, key) in keys.iter().enumerate() {
327            if set.len() >= STATUS_CONCURRENCY {
328                if let Some(Ok((idx, result))) = set.join_next().await {
329                    statuses_results[idx] =
330                        result.map_err(|e| anyhow::anyhow!("Jira statuses failed: {e}"))?;
331                }
332            }
333
334            let client = self.http.clone();
335            let request_url = format!("{url}/{key}/statuses");
336            let email = self.email.clone();
337            let token = self.api_token.clone();
338            let timeout = self.timeout_secs;
339
340            set.spawn(async move {
341                let result = async {
342                    let resp = client
343                        .get(&request_url)
344                        .basic_auth(&email, Some(&token))
345                        .timeout(std::time::Duration::from_secs(timeout))
346                        .send()
347                        .await
348                        .map_err(|e| anyhow::anyhow!("statuses request failed: {e}"))?;
349
350                    if !resp.status().is_success() {
351                        anyhow::bail!("statuses request returned {}", resp.status());
352                    }
353
354                    resp.json::<Value>()
355                        .await
356                        .map_err(|e| anyhow::anyhow!("failed to parse statuses response: {e}"))
357                }
358                .await;
359                (i, result)
360            });
361        }
362
363        while let Some(Ok((idx, result))) = set.join_next().await {
364            statuses_results[idx] =
365                result.map_err(|e| anyhow::anyhow!("Jira statuses failed: {e}"))?;
366        }
367
368        let shaped_projects = shape_projects(&projects, &statuses_results);
369        let shaped_users: Vec<Value> = users
370            .iter()
371            .filter_map(|u| {
372                let display = u["displayName"].as_str()?;
373                let email = u["emailAddress"].as_str()?;
374                Some(json!({ "displayName": display, "emailAddress": email }))
375            })
376            .collect();
377
378        let output = json!({ "projects": shaped_projects, "users": shaped_users });
379        Ok(ToolResult {
380            success: true,
381            output: serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()),
382            error: None,
383        })
384    }
385
386    async fn get_myself(&self) -> anyhow::Result<ToolResult> {
387        let url = format!("{}/rest/api/3/myself", self.base_url);
388
389        let resp = self
390            .http
391            .get(&url)
392            .basic_auth(&self.email, Some(&self.api_token))
393            .timeout(std::time::Duration::from_secs(self.timeout_secs))
394            .send()
395            .await
396            .map_err(|e| anyhow::anyhow!("Jira myself request failed: {e}"))?;
397
398        let status = resp.status();
399        if !status.is_success() {
400            let text = resp.text().await.unwrap_or_default();
401            anyhow::bail!(
402                "Jira myself failed ({status}): {}",
403                crate::util::truncate_with_ellipsis(&text, MAX_ERROR_BODY_CHARS)
404            );
405        }
406
407        let raw: Value = resp
408            .json()
409            .await
410            .map_err(|e| anyhow::anyhow!("Failed to parse Jira myself response: {e}"))?;
411
412        let shaped = json!({
413            "accountId":    raw["accountId"],
414            "displayName":  raw["displayName"],
415            "emailAddress": raw["emailAddress"],
416            "active":       raw["active"],
417        });
418
419        Ok(ToolResult {
420            success: true,
421            output: serde_json::to_string_pretty(&shaped).unwrap_or_else(|_| shaped.to_string()),
422            error: None,
423        })
424    }
425
426    async fn resolve_email(&self, email: &str) -> Option<(String, String)> {
427        let url = format!("{}/rest/api/3/user/search", self.base_url);
428        let result = self
429            .http
430            .get(&url)
431            .basic_auth(&self.email, Some(&self.api_token))
432            .query(&[("query", email)])
433            .timeout(std::time::Duration::from_secs(self.timeout_secs))
434            .send()
435            .await
436            .ok()?
437            .json::<Value>()
438            .await
439            .ok()?;
440
441        result.as_array()?.iter().find_map(|u| {
442            let account_email = u["emailAddress"].as_str()?;
443            if account_email.eq_ignore_ascii_case(email) {
444                Some((
445                    u["accountId"].as_str()?.to_string(),
446                    u["displayName"].as_str()?.to_string(),
447                ))
448            } else {
449                None
450            }
451        })
452    }
453}
454
455#[async_trait]
456impl Tool for JiraTool {
457    fn name(&self) -> &str {
458        "jira"
459    }
460
461    fn description(&self) -> &str {
462        "Interact with Jira: get tickets with configurable detail level, search issues with JQL, add comments with mention and formatting support."
463    }
464
465    fn parameters_schema(&self) -> serde_json::Value {
466        json!({
467            "type": "object",
468            "properties": {
469                "action": {
470                    "type": "string",
471                    "enum": ["get_ticket", "search_tickets", "comment_ticket", "list_projects", "myself"],
472                    "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."
473                },
474                "issue_key": {
475                    "type": "string",
476                    "description": "Jira issue key, e.g. 'PROJ-123'. Required for get_ticket and comment_ticket."
477                },
478                "level_of_details": {
479                    "type": "string",
480                    "enum": ["basic", "basic_search", "full", "changelog"],
481                    "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."
482                },
483                "jql": {
484                    "type": "string",
485                    "description": "JQL query string for search_tickets. Example: 'project = PROJ AND status = \"In Progress\" ORDER BY updated DESC'."
486                },
487                "max_results": {
488                    "type": "integer",
489                    "description": "Maximum number of issues to return for search_tickets. Defaults to 25, capped at 999.",
490                    "default": 25
491                },
492                "comment": {
493                    "type": "string",
494                    "description": "Comment body for comment_ticket. 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 '- '. Newlines become line breaks. Everything else is plain text. Example: 'Hi @john@company.com, this is **important**.\n- Check the logs\n- Rerun the pipeline'"
495                }
496            },
497            "required": ["action"]
498        })
499    }
500
501    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
502        let action = match args.get("action").and_then(|v| v.as_str()) {
503            Some(a) => a,
504            None => {
505                return Ok(ToolResult {
506                    success: false,
507                    output: String::new(),
508                    error: Some("Missing required parameter: action".into()),
509                });
510            }
511        };
512
513        // Reject unknown actions before the allowlist check so typos produce a
514        // clear "unknown action" error rather than a misleading "not enabled" one.
515        if !matches!(
516            action,
517            "get_ticket" | "search_tickets" | "comment_ticket" | "list_projects" | "myself"
518        ) {
519            return Ok(ToolResult {
520                success: false,
521                output: String::new(),
522                error: Some(format!(
523                    "Unknown action: '{action}'. Valid actions: get_ticket, search_tickets, comment_ticket, list_projects, myself"
524                )),
525            });
526        }
527
528        if !self.is_action_allowed(action) {
529            return Ok(ToolResult {
530                success: false,
531                output: String::new(),
532                error: Some(format!(
533                    "Action '{action}' is not enabled. Add it to jira.allowed_actions in config.toml. \
534                     Currently allowed: {}",
535                    self.allowed_actions.join(", ")
536                )),
537            });
538        }
539
540        let operation = match action {
541            "get_ticket" | "search_tickets" | "list_projects" | "myself" => ToolOperation::Read,
542            "comment_ticket" => ToolOperation::Act,
543            _ => unreachable!(),
544        };
545
546        if let Err(error) = self.security.enforce_tool_operation(operation, "jira") {
547            return Ok(ToolResult {
548                success: false,
549                output: String::new(),
550                error: Some(error),
551            });
552        }
553
554        let result = match action {
555            "get_ticket" => {
556                let issue_key = match args.get("issue_key").and_then(|v| v.as_str()) {
557                    Some(k) => k,
558                    None => {
559                        return Ok(ToolResult {
560                            success: false,
561                            output: String::new(),
562                            error: Some("get_ticket requires issue_key parameter".into()),
563                        });
564                    }
565                };
566                let level = match args.get("level_of_details").and_then(|v| v.as_str()) {
567                    Some("basic_search") => LevelOfDetails::BasicSearch,
568                    Some("full") => LevelOfDetails::Full,
569                    Some("changelog") => LevelOfDetails::Changelog,
570                    _ => LevelOfDetails::Basic,
571                };
572                self.get_ticket(issue_key, level).await
573            }
574            "search_tickets" => {
575                let jql = match args.get("jql").and_then(|v| v.as_str()) {
576                    Some(j) => j,
577                    None => {
578                        return Ok(ToolResult {
579                            success: false,
580                            output: String::new(),
581                            error: Some("search_tickets requires jql parameter".into()),
582                        });
583                    }
584                };
585                let max_results = args
586                    .get("max_results")
587                    .and_then(|v| v.as_u64())
588                    .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
589                self.search_tickets(jql, max_results).await
590            }
591            "myself" => self.get_myself().await,
592            "list_projects" => self.list_projects().await,
593            "comment_ticket" => {
594                let issue_key = match args.get("issue_key").and_then(|v| v.as_str()) {
595                    Some(k) => k,
596                    None => {
597                        return Ok(ToolResult {
598                            success: false,
599                            output: String::new(),
600                            error: Some("comment_ticket requires issue_key parameter".into()),
601                        });
602                    }
603                };
604                let comment = match args.get("comment").and_then(|v| v.as_str()) {
605                    Some(c) if !c.trim().is_empty() => c,
606                    _ => {
607                        return Ok(ToolResult {
608                            success: false,
609                            output: String::new(),
610                            error: Some(
611                                "comment_ticket requires a non-empty comment parameter".into(),
612                            ),
613                        });
614                    }
615                };
616                self.comment_ticket(issue_key, comment).await
617            }
618            _ => unreachable!(),
619        };
620
621        match result {
622            Ok(tool_result) => Ok(tool_result),
623            Err(e) => Ok(ToolResult {
624                success: false,
625                output: String::new(),
626                error: Some(e.to_string()),
627            }),
628        }
629    }
630}
631
632// ── Input validation ──────────────────────────────────────────────────────────
633
634/// Validates that `issue_key` matches the Jira key format `PROJ-123` or `proj-123`.
635/// Prevents path traversal if a crafted key like `../../other` were interpolated
636/// directly into the URL.
637fn validate_issue_key(key: &str) -> anyhow::Result<()> {
638    let valid = key.split_once('-').is_some_and(|(project, number)| {
639        !project.is_empty()
640            && project.chars().all(|c| c.is_ascii_alphanumeric())
641            && !number.is_empty()
642            && number.chars().all(|c| c.is_ascii_digit())
643    });
644    if valid {
645        Ok(())
646    } else {
647        anyhow::bail!(
648            "Invalid issue key '{key}'. Expected format: PROJECT-123 (e.g. PROJ-42, proj-42)"
649        )
650    }
651}
652
653// ── Response shaping ──────────────────────────────────────────────────────────
654
655/// Safely extracts the first 10 characters (date prefix) from a string.
656/// Returns the full string if it is shorter than 10 characters instead of
657/// panicking on out-of-bounds slice indexing.
658fn date_prefix(s: &str) -> &str {
659    s.get(..10).unwrap_or(s)
660}
661
662fn shape_basic(raw: &Value) -> Value {
663    let f = &raw["fields"];
664    let rf = &raw["renderedFields"];
665
666    // Build a lookup map from comment ID → rendered body for O(1) access
667    // instead of scanning the rendered array for each comment (O(n²)).
668    let rendered_by_id: HashMap<&str, &str> = rf["comment"]["comments"]
669        .as_array()
670        .map(|arr| {
671            arr.iter()
672                .filter_map(|rc| Some((rc["id"].as_str()?, rc["body"].as_str()?)))
673                .collect()
674        })
675        .unwrap_or_default();
676
677    let comments: Vec<Value> = f["comment"]["comments"]
678        .as_array()
679        .map(|arr| {
680            arr.iter()
681                .map(|c| {
682                    let id = c["id"].as_str().unwrap_or("");
683                    json!({
684                        "author": c["author"]["displayName"],
685                        "created": date_prefix(c["created"].as_str().unwrap_or("")),
686                        "body": rendered_by_id.get(id).copied().unwrap_or("")
687                    })
688                })
689                .collect()
690        })
691        .unwrap_or_default();
692
693    json!({
694        "key":         raw["key"],
695        "summary":     f["summary"],
696        "status":      f["status"]["name"],
697        "priority":    f["priority"]["name"],
698        "assignee":    f["assignee"]["displayName"],
699        "created":     date_prefix(f["created"].as_str().unwrap_or("")),
700        "updated":     date_prefix(f["updated"].as_str().unwrap_or("")),
701        "description": rf["description"].as_str().unwrap_or(""),
702        "comments":    comments,
703    })
704}
705
706fn shape_basic_search(raw: &Value) -> Value {
707    let f = &raw["fields"];
708    json!({
709        "key":      raw["key"],
710        "summary":  f["summary"],
711        "status":   f["status"]["name"],
712        "priority": f["priority"]["name"],
713        "assignee": f["assignee"]["displayName"],
714        "created":  date_prefix(f["created"].as_str().unwrap_or("")),
715        "updated":  date_prefix(f["updated"].as_str().unwrap_or("")),
716    })
717}
718
719fn shape_full(raw: &Value) -> Value {
720    let mut result = raw.clone();
721    let rf = &raw["renderedFields"];
722
723    if let Some(desc) = rf["description"].as_str() {
724        result["fields"]["description"] = json!(desc);
725    }
726
727    if let (Some(comments), Some(rendered_comments)) = (
728        result["fields"]["comment"]["comments"].as_array_mut(),
729        rf["comment"]["comments"].as_array(),
730    ) {
731        for (c, rc) in comments.iter_mut().zip(rendered_comments.iter()) {
732            if let Some(body) = rc["body"].as_str() {
733                c["body"] = json!(body);
734            }
735        }
736    }
737
738    result.as_object_mut().unwrap().remove("renderedFields");
739    result
740}
741
742fn shape_changelog(raw: &Value) -> Value {
743    json!({
744        "key":       raw["key"],
745        "changelog": raw["changelog"],
746    })
747}
748
749/// Returns only the comment ID, author, and creation date — avoids
750/// exposing internal Jira metadata back to the AI.
751fn shape_comment_response(raw: &Value) -> Value {
752    json!({
753        "id":      raw["id"],
754        "author":  raw["author"]["displayName"],
755        "created": date_prefix(raw["created"].as_str().unwrap_or("")),
756    })
757}
758
759fn shape_projects(projects: &[Value], statuses_per_project: &[Value]) -> Vec<Value> {
760    projects
761        .iter()
762        .zip(statuses_per_project.iter())
763        .map(|(p, statuses)| {
764            let mut issue_types: Vec<String> = Vec::new();
765            let mut all_statuses: HashSet<String> = HashSet::new();
766
767            if let Some(arr) = statuses.as_array() {
768                for it in arr {
769                    if let Some(name) = it["name"].as_str() {
770                        issue_types.push(name.to_string());
771                    }
772                    if let Some(ss) = it["statuses"].as_array() {
773                        for s in ss {
774                            if let Some(sn) = s["name"].as_str() {
775                                all_statuses.insert(sn.to_string());
776                            }
777                        }
778                    }
779                }
780            }
781
782            let mut ordered: Vec<String> = all_statuses.into_iter().collect();
783            ordered.sort();
784
785            json!({
786                "key":         p["key"],
787                "name":        p["name"],
788                "projectType": p["projectTypeKey"],
789                "style":       p["style"],
790                "issueTypes":  issue_types,
791                "statuses":    ordered,
792            })
793        })
794        .collect()
795}
796
797// ── Comment / ADF builder ─────────────────────────────────────────────────────
798
799/// Strips trailing punctuation that commonly appears after an email address
800/// (e.g. `@john@co.com,` or `@john@co.com)`). Also strips leading bracket-like
801/// punctuation so `@(john@co.com)` resolves correctly.
802fn clean_email(s: &str) -> &str {
803    s.trim_start_matches(['(', '['])
804        .trim_end_matches([',', '!', '?', ':', ';', ')', ']'])
805}
806
807fn extract_emails(text: &str) -> Vec<String> {
808    let mut emails = Vec::new();
809    for word in text.split_whitespace() {
810        if let Some(rest) = word.strip_prefix('@') {
811            let email = clean_email(rest);
812            if email.contains('@') {
813                emails.push(email.to_string());
814            }
815        }
816    }
817    let mut seen = std::collections::HashSet::new();
818    emails.retain(|e| seen.insert(e.clone()));
819    emails
820}
821
822fn parse_inline(text: &str, mentions: &HashMap<String, (String, String)>) -> Vec<Value> {
823    let mut nodes: Vec<Value> = Vec::new();
824    let mut chars = text.chars().peekable();
825    let mut current = String::new();
826
827    while let Some(ch) = chars.next() {
828        if ch == '*' && chars.peek() == Some(&'*') {
829            chars.next(); // consume second *
830            if !current.is_empty() {
831                nodes.push(json!({ "type": "text", "text": current.clone() }));
832                current.clear();
833            }
834            let mut bold = String::new();
835            let mut closed = false;
836            loop {
837                match chars.next() {
838                    Some('*') if chars.peek() == Some(&'*') => {
839                        chars.next(); // consume second *
840                        closed = true;
841                        break;
842                    }
843                    Some(c) => bold.push(c),
844                    None => break,
845                }
846            }
847            if closed && !bold.is_empty() {
848                nodes.push(json!({
849                    "type": "text",
850                    "text": bold,
851                    "marks": [{ "type": "strong" }]
852                }));
853            } else if !bold.is_empty() {
854                // Unmatched ** — emit as literal text
855                current.push_str("**");
856                current.push_str(&bold);
857            }
858        } else if ch == '@' {
859            let mut raw = String::new();
860            while let Some(&next) = chars.peek() {
861                if next.is_whitespace() {
862                    break;
863                }
864                raw.push(chars.next().unwrap());
865            }
866            let email = clean_email(&raw);
867            // Compute the end position of `email` within `raw` via pointer
868            // arithmetic so the suffix is correct even when leading chars were
869            // stripped by clean_email.
870            let email_end = (email.as_ptr() as usize - raw.as_ptr() as usize) + email.len();
871            let suffix = &raw[email_end..];
872            if email.contains('@') {
873                if let Some((account_id, display_name)) = mentions.get(email) {
874                    if !current.is_empty() {
875                        nodes.push(json!({ "type": "text", "text": current.clone() }));
876                        current.clear();
877                    }
878                    nodes.push(json!({
879                        "type": "mention",
880                        "attrs": {
881                            "id": account_id,
882                            "text": format!("@{}", display_name)
883                        }
884                    }));
885                    if !suffix.is_empty() {
886                        current.push_str(suffix);
887                    }
888                } else {
889                    current.push('@');
890                    current.push_str(&raw);
891                }
892            } else {
893                current.push('@');
894                current.push_str(email);
895            }
896        } else {
897            current.push(ch);
898        }
899    }
900
901    if !current.is_empty() {
902        nodes.push(json!({ "type": "text", "text": current }));
903    }
904
905    nodes
906}
907
908fn build_adf(text: &str, mentions: &HashMap<String, (String, String)>) -> Value {
909    let mut content: Vec<Value> = Vec::new();
910    let mut paragraph: Vec<Value> = Vec::new();
911    let mut list_items: Vec<Value> = Vec::new();
912
913    let flush_paragraph = |paragraph: &mut Vec<Value>, content: &mut Vec<Value>| {
914        if !paragraph.is_empty() {
915            content.push(json!({ "type": "paragraph", "content": paragraph.clone() }));
916            paragraph.clear();
917        }
918    };
919
920    let flush_list = |list_items: &mut Vec<Value>, content: &mut Vec<Value>| {
921        if !list_items.is_empty() {
922            content.push(json!({ "type": "bulletList", "content": list_items.clone() }));
923            list_items.clear();
924        }
925    };
926
927    for line in text.lines() {
928        if line.trim().is_empty() {
929            flush_paragraph(&mut paragraph, &mut content);
930            flush_list(&mut list_items, &mut content);
931        } else if let Some(item) = line.strip_prefix("- ") {
932            flush_paragraph(&mut paragraph, &mut content);
933            let inline = parse_inline(item, mentions);
934            list_items.push(json!({
935                "type": "listItem",
936                "content": [{ "type": "paragraph", "content": inline }]
937            }));
938        } else {
939            flush_list(&mut list_items, &mut content);
940            if !paragraph.is_empty() {
941                paragraph.push(json!({ "type": "hardBreak" }));
942            }
943            paragraph.extend(parse_inline(line, mentions));
944        }
945    }
946
947    flush_paragraph(&mut paragraph, &mut content);
948    flush_list(&mut list_items, &mut content);
949
950    json!({ "type": "doc", "version": 1, "content": content })
951}
952
953// ── Tests ─────────────────────────────────────────────────────────────────────
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958    use crate::security::{AutonomyLevel, SecurityPolicy};
959
960    fn test_tool(allowed_actions: Vec<&str>) -> JiraTool {
961        let security = Arc::new(SecurityPolicy {
962            autonomy: AutonomyLevel::Supervised,
963            ..SecurityPolicy::default()
964        });
965        JiraTool::new(
966            "https://test.atlassian.net".into(),
967            "test@example.com".into(),
968            "test-token".into(),
969            allowed_actions.into_iter().map(String::from).collect(),
970            security,
971            30,
972        )
973    }
974
975    #[test]
976    fn tool_name_is_jira() {
977        assert_eq!(test_tool(vec!["get_ticket"]).name(), "jira");
978    }
979
980    #[test]
981    fn parameters_schema_has_required_action() {
982        let schema = test_tool(vec!["get_ticket"]).parameters_schema();
983        let required = schema["required"].as_array().unwrap();
984        assert!(required.iter().any(|v| v.as_str() == Some("action")));
985    }
986
987    #[test]
988    fn parameters_schema_defines_all_actions() {
989        let schema = test_tool(vec!["get_ticket"]).parameters_schema();
990        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
991        let action_strs: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
992        assert!(action_strs.contains(&"get_ticket"));
993        assert!(action_strs.contains(&"search_tickets"));
994        assert!(action_strs.contains(&"comment_ticket"));
995    }
996
997    #[tokio::test]
998    async fn execute_missing_action_returns_error() {
999        let result = test_tool(vec!["get_ticket"])
1000            .execute(json!({}))
1001            .await
1002            .unwrap();
1003        assert!(!result.success);
1004        assert!(result.error.as_deref().unwrap().contains("action"));
1005    }
1006
1007    #[tokio::test]
1008    async fn execute_unknown_action_returns_error() {
1009        let result = test_tool(vec!["get_ticket"])
1010            .execute(json!({"action": "delete_ticket"}))
1011            .await
1012            .unwrap();
1013        assert!(!result.success);
1014        assert!(result.error.as_deref().unwrap().contains("Unknown action"));
1015    }
1016
1017    #[tokio::test]
1018    async fn execute_disallowed_action_returns_error() {
1019        let result = test_tool(vec!["get_ticket"])
1020            .execute(json!({"action": "comment_ticket"}))
1021            .await
1022            .unwrap();
1023        assert!(!result.success);
1024        let err = result.error.unwrap();
1025        assert!(err.contains("not enabled"));
1026        assert!(err.contains("allowed_actions"));
1027    }
1028
1029    #[tokio::test]
1030    async fn execute_get_ticket_missing_key_returns_error() {
1031        let result = test_tool(vec!["get_ticket"])
1032            .execute(json!({"action": "get_ticket"}))
1033            .await
1034            .unwrap();
1035        assert!(!result.success);
1036        assert!(result.error.as_deref().unwrap().contains("issue_key"));
1037    }
1038
1039    #[tokio::test]
1040    async fn execute_search_tickets_missing_jql_returns_error() {
1041        let result = test_tool(vec!["get_ticket", "search_tickets"])
1042            .execute(json!({"action": "search_tickets"}))
1043            .await
1044            .unwrap();
1045        assert!(!result.success);
1046        assert!(result.error.as_deref().unwrap().contains("jql"));
1047    }
1048
1049    #[tokio::test]
1050    async fn execute_comment_ticket_missing_key_returns_error() {
1051        let result = test_tool(vec!["get_ticket", "comment_ticket"])
1052            .execute(json!({"action": "comment_ticket", "comment": "hello"}))
1053            .await
1054            .unwrap();
1055        assert!(!result.success);
1056        assert!(result.error.as_deref().unwrap().contains("issue_key"));
1057    }
1058
1059    #[tokio::test]
1060    async fn execute_comment_ticket_missing_comment_returns_error() {
1061        let result = test_tool(vec!["get_ticket", "comment_ticket"])
1062            .execute(json!({"action": "comment_ticket", "issue_key": "PROJ-1"}))
1063            .await
1064            .unwrap();
1065        assert!(!result.success);
1066        assert!(result.error.as_deref().unwrap().contains("comment"));
1067    }
1068
1069    #[tokio::test]
1070    async fn execute_comment_ticket_empty_comment_returns_error() {
1071        let result = test_tool(vec!["get_ticket", "comment_ticket"])
1072            .execute(json!({"action": "comment_ticket", "issue_key": "PROJ-1", "comment": "   "}))
1073            .await
1074            .unwrap();
1075        assert!(!result.success);
1076        assert!(result.error.as_deref().unwrap().contains("comment"));
1077    }
1078
1079    #[tokio::test]
1080    async fn execute_comment_blocked_in_readonly_mode() {
1081        let security = Arc::new(SecurityPolicy {
1082            autonomy: AutonomyLevel::ReadOnly,
1083            ..SecurityPolicy::default()
1084        });
1085        let tool = JiraTool::new(
1086            "https://test.atlassian.net".into(),
1087            "test@example.com".into(),
1088            "token".into(),
1089            vec!["get_ticket".into(), "comment_ticket".into()],
1090            security,
1091            30,
1092        );
1093        let result = tool
1094            .execute(json!({
1095                "action": "comment_ticket",
1096                "issue_key": "PROJ-1",
1097                "comment": "hello"
1098            }))
1099            .await
1100            .unwrap();
1101        assert!(!result.success);
1102        assert!(result.error.as_deref().unwrap().contains("read-only"));
1103    }
1104
1105    // ── myself action ────────────────────────────────────────────────────────
1106
1107    #[test]
1108    fn parameters_schema_includes_myself_action() {
1109        let schema = test_tool(vec!["myself"]).parameters_schema();
1110        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
1111        let action_strs: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
1112        assert!(action_strs.contains(&"myself"));
1113    }
1114
1115    #[tokio::test]
1116    async fn execute_myself_disallowed_returns_error() {
1117        let result = test_tool(vec!["get_ticket"])
1118            .execute(json!({"action": "myself"}))
1119            .await
1120            .unwrap();
1121        assert!(!result.success);
1122        let err = result.error.unwrap();
1123        assert!(err.contains("not enabled"));
1124        assert!(err.contains("allowed_actions"));
1125    }
1126
1127    #[tokio::test]
1128    async fn execute_myself_not_blocked_in_readonly_mode() {
1129        // myself is a Read operation — the security policy should not block it.
1130        // The call will fail at the HTTP level (no real server), not at the
1131        // policy level, so the error must NOT contain "read-only".
1132        let security = Arc::new(SecurityPolicy {
1133            autonomy: AutonomyLevel::ReadOnly,
1134            ..SecurityPolicy::default()
1135        });
1136        let tool = JiraTool::new(
1137            "https://test.atlassian.net".into(),
1138            "test@example.com".into(),
1139            "token".into(),
1140            vec!["myself".into()],
1141            security,
1142            30,
1143        );
1144        let result = tool.execute(json!({"action": "myself"})).await.unwrap();
1145        assert!(!result.success);
1146        assert!(!result.error.as_deref().unwrap_or("").contains("read-only"));
1147    }
1148
1149    // ── Issue key validation ──────────────────────────────────────────────────
1150
1151    #[test]
1152    fn validate_issue_key_accepts_valid_keys() {
1153        assert!(validate_issue_key("PROJ-1").is_ok());
1154        assert!(validate_issue_key("PROJ-123").is_ok());
1155        assert!(validate_issue_key("AB-99").is_ok());
1156        assert!(validate_issue_key("MYPROJECT-1000").is_ok());
1157        assert!(validate_issue_key("proj-1").is_ok());
1158        assert!(validate_issue_key("proj-123").is_ok());
1159    }
1160
1161    #[test]
1162    fn validate_issue_key_rejects_path_traversal() {
1163        assert!(validate_issue_key("../../etc/passwd").is_err());
1164        assert!(validate_issue_key("../other").is_err());
1165    }
1166
1167    #[test]
1168    fn validate_issue_key_rejects_malformed() {
1169        assert!(validate_issue_key("PROJ").is_err()); // no number
1170        assert!(validate_issue_key("PROJ-").is_err()); // empty number
1171        assert!(validate_issue_key("-123").is_err()); // no project
1172        assert!(validate_issue_key("PROJ-12x").is_err()); // non-digit in number
1173    }
1174
1175    // ── ADF builder unit tests ────────────────────────────────────────────────
1176
1177    #[test]
1178    fn build_adf_plain_text() {
1179        let adf = build_adf("Hello world", &HashMap::new());
1180        assert_eq!(adf["type"], "doc");
1181        assert_eq!(adf["version"], 1);
1182        let para = &adf["content"][0];
1183        assert_eq!(para["type"], "paragraph");
1184        assert_eq!(para["content"][0]["text"], "Hello world");
1185    }
1186
1187    #[test]
1188    fn build_adf_bold() {
1189        let adf = build_adf("**bold**", &HashMap::new());
1190        let text_node = &adf["content"][0]["content"][0];
1191        assert_eq!(text_node["text"], "bold");
1192        assert_eq!(text_node["marks"][0]["type"], "strong");
1193    }
1194
1195    #[test]
1196    fn build_adf_unmatched_bold_is_literal() {
1197        let adf = build_adf("**no closing", &HashMap::new());
1198        let text = &adf["content"][0]["content"][0]["text"];
1199        assert!(text.as_str().unwrap().contains("**no closing"));
1200    }
1201
1202    #[test]
1203    fn build_adf_bullet_list() {
1204        let adf = build_adf("- first\n- second", &HashMap::new());
1205        let list = &adf["content"][0];
1206        assert_eq!(list["type"], "bulletList");
1207        assert_eq!(list["content"].as_array().unwrap().len(), 2);
1208        assert_eq!(list["content"][0]["type"], "listItem");
1209    }
1210
1211    #[test]
1212    fn build_adf_mention_resolved() {
1213        let mut mentions = HashMap::new();
1214        mentions.insert(
1215            "john@company.com".to_string(),
1216            ("acc-123".to_string(), "John Doe".to_string()),
1217        );
1218        let adf = build_adf("Hi @john@company.com done", &mentions);
1219        let content = &adf["content"][0]["content"];
1220        let mention = content
1221            .as_array()
1222            .unwrap()
1223            .iter()
1224            .find(|n| n["type"] == "mention")
1225            .unwrap();
1226        assert_eq!(mention["attrs"]["id"], "acc-123");
1227        assert_eq!(mention["attrs"]["text"], "@John Doe");
1228    }
1229
1230    #[test]
1231    fn build_adf_unresolved_mention_rendered_as_plain_text() {
1232        let adf = build_adf("Hi @unknown@example.com", &HashMap::new());
1233        let text = &adf["content"][0]["content"][0]["text"];
1234        assert!(text.as_str().unwrap().contains("@unknown@example.com"));
1235    }
1236
1237    #[test]
1238    fn extract_emails_finds_at_prefixed_emails() {
1239        let emails = extract_emails("Hello @john@company.com and @jane@corp.io done");
1240        assert_eq!(emails, vec!["john@company.com", "jane@corp.io"]);
1241    }
1242
1243    #[test]
1244    fn extract_emails_deduplicates() {
1245        let emails = extract_emails("@a@b.com @a@b.com");
1246        assert_eq!(emails.len(), 1);
1247    }
1248
1249    #[test]
1250    fn extract_emails_deduplicates_non_adjacent() {
1251        let emails = extract_emails("@a@b.com @c@d.com @a@b.com");
1252        assert_eq!(emails, vec!["a@b.com", "c@d.com"]);
1253    }
1254
1255    #[test]
1256    fn extract_emails_strips_trailing_punctuation() {
1257        let emails = extract_emails("@john@company.com,");
1258        assert_eq!(emails, vec!["john@company.com"]);
1259    }
1260
1261    #[test]
1262    fn extract_emails_strips_leading_punctuation() {
1263        let emails = extract_emails("@(john@company.com)");
1264        assert_eq!(emails, vec!["john@company.com"]);
1265    }
1266
1267    #[test]
1268    fn shape_basic_search_extracts_expected_fields() {
1269        let raw = json!({
1270            "key": "PROJ-1",
1271            "fields": {
1272                "summary": "Fix bug",
1273                "status": { "name": "In Progress" },
1274                "priority": { "name": "High" },
1275                "assignee": { "displayName": "Jane" },
1276                "created": "2024-01-15T10:00:00.000Z",
1277                "updated": "2024-03-01T12:00:00.000Z"
1278            }
1279        });
1280        let shaped = shape_basic_search(&raw);
1281        assert_eq!(shaped["key"], "PROJ-1");
1282        assert_eq!(shaped["summary"], "Fix bug");
1283        assert_eq!(shaped["status"], "In Progress");
1284        assert_eq!(shaped["priority"], "High");
1285        assert_eq!(shaped["assignee"], "Jane");
1286        assert_eq!(shaped["created"], "2024-01-15");
1287        assert_eq!(shaped["updated"], "2024-03-01");
1288    }
1289
1290    #[test]
1291    fn shape_changelog_extracts_key_and_changelog() {
1292        let raw = json!({
1293            "key": "PROJ-42",
1294            "changelog": { "histories": [] },
1295            "fields": {}
1296        });
1297        let shaped = shape_changelog(&raw);
1298        assert_eq!(shaped["key"], "PROJ-42");
1299        assert!(shaped.get("changelog").is_some());
1300        assert!(shaped.get("fields").is_none());
1301    }
1302
1303    #[test]
1304    fn shape_comment_response_extracts_id_author_created() {
1305        let raw = json!({
1306            "id": "12345",
1307            "author": { "displayName": "Alice", "accountId": "abc" },
1308            "created": "2024-06-01T09:00:00.000Z",
1309            "body": { "type": "doc" },
1310            "self": "https://internal.url"
1311        });
1312        let shaped = shape_comment_response(&raw);
1313        assert_eq!(shaped["id"], "12345");
1314        assert_eq!(shaped["author"], "Alice");
1315        assert_eq!(shaped["created"], "2024-06-01");
1316        assert!(shaped.get("body").is_none());
1317        assert!(shaped.get("self").is_none());
1318    }
1319
1320    // ── date_prefix helper ─────────────────────────────────────────────────
1321
1322    #[test]
1323    fn date_prefix_normal_date_string() {
1324        assert_eq!(date_prefix("2024-01-15T10:00:00.000Z"), "2024-01-15");
1325    }
1326
1327    #[test]
1328    fn date_prefix_empty_string() {
1329        assert_eq!(date_prefix(""), "");
1330    }
1331
1332    #[test]
1333    fn date_prefix_short_string() {
1334        assert_eq!(date_prefix("2024"), "2024");
1335    }
1336
1337    #[test]
1338    fn date_prefix_exactly_ten_chars() {
1339        assert_eq!(date_prefix("2024-01-15"), "2024-01-15");
1340    }
1341
1342    #[test]
1343    fn shape_basic_uses_o1_comment_lookup() {
1344        // Verify that comments are matched by ID, not by position.
1345        let raw = json!({
1346            "key": "PROJ-1",
1347            "fields": {
1348                "summary": "s", "priority": {"name":"P"}, "status": {"name":"S"},
1349                "assignee": {"displayName":"A"},
1350                "created": "2024-01-01T00:00:00.000Z",
1351                "updated": "2024-01-01T00:00:00.000Z",
1352                "comment": {
1353                    "comments": [
1354                        { "id": "2", "author": {"displayName":"Bob"}, "created": "2024-01-02T00:00:00.000Z" },
1355                        { "id": "1", "author": {"displayName":"Alice"}, "created": "2024-01-01T00:00:00.000Z" }
1356                    ]
1357                }
1358            },
1359            "renderedFields": {
1360                "description": "",
1361                "comment": {
1362                    "comments": [
1363                        { "id": "1", "body": "Alice's body" },
1364                        { "id": "2", "body": "Bob's body" }
1365                    ]
1366                }
1367            }
1368        });
1369        let shaped = shape_basic(&raw);
1370        // Comment with id "2" (Bob) should get Bob's rendered body, not Alice's
1371        assert_eq!(shaped["comments"][0]["author"], "Bob");
1372        assert_eq!(shaped["comments"][0]["body"], "Bob's body");
1373        assert_eq!(shaped["comments"][1]["author"], "Alice");
1374        assert_eq!(shaped["comments"][1]["body"], "Alice's body");
1375    }
1376
1377    // ── list_projects action ────────────────────────────────────────────────
1378
1379    #[test]
1380    fn parameters_schema_includes_list_projects_action() {
1381        let schema = test_tool(vec!["list_projects"]).parameters_schema();
1382        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
1383        let action_strs: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
1384        assert!(action_strs.contains(&"list_projects"));
1385    }
1386
1387    #[tokio::test]
1388    async fn execute_list_projects_disallowed_returns_error() {
1389        let result = test_tool(vec!["get_ticket"])
1390            .execute(json!({"action": "list_projects"}))
1391            .await
1392            .unwrap();
1393        assert!(!result.success);
1394        let err = result.error.unwrap();
1395        assert!(err.contains("not enabled"));
1396        assert!(err.contains("allowed_actions"));
1397    }
1398
1399    #[tokio::test]
1400    async fn execute_list_projects_not_blocked_in_readonly_mode() {
1401        let security = Arc::new(SecurityPolicy {
1402            autonomy: AutonomyLevel::ReadOnly,
1403            ..SecurityPolicy::default()
1404        });
1405        let tool = JiraTool::new(
1406            "https://127.0.0.1:1".into(),
1407            "test@example.com".into(),
1408            "token".into(),
1409            vec!["list_projects".into()],
1410            security,
1411            30,
1412        );
1413        let result = tool
1414            .execute(json!({"action": "list_projects"}))
1415            .await
1416            .unwrap();
1417        assert!(!result.success);
1418        assert!(
1419            !result.error.as_deref().unwrap_or("").contains("read-only"),
1420            "error should not mention read-only policy: {:?}",
1421            result.error
1422        );
1423    }
1424
1425    #[test]
1426    fn shape_projects_extracts_expected_fields() {
1427        let projects = json!([
1428            { "key": "AT", "name": "ALL TASKS", "projectTypeKey": "business", "style": "next-gen" },
1429            { "key": "GP", "name": "G-PROJECT", "projectTypeKey": "software", "style": "next-gen" }
1430        ]);
1431        let statuses: Vec<Value> = vec![
1432            json!([
1433                { "name": "Task", "statuses": [
1434                    { "name": "To Do" }, { "name": "In Progress" }, { "name": "Collecting Intel" }, { "name": "Done" }
1435                ]},
1436                { "name": "Sub-task", "statuses": [
1437                    { "name": "To Do" }, { "name": "Verification" }
1438                ]}
1439            ]),
1440            json!([
1441                { "name": "Task", "statuses": [
1442                    { "name": "To Do" }, { "name": "Design" }, { "name": "Done" }
1443                ]},
1444                { "name": "Epic", "statuses": [
1445                    { "name": "To Do" }, { "name": "Done" }
1446                ]}
1447            ]),
1448        ];
1449        let shaped = shape_projects(projects.as_array().unwrap(), &statuses);
1450        let arr = &shaped;
1451
1452        assert_eq!(arr.len(), 2);
1453
1454        assert_eq!(arr[0]["key"], "AT");
1455        assert_eq!(arr[0]["name"], "ALL TASKS");
1456        assert_eq!(arr[0]["projectType"], "business");
1457        let at_statuses: Vec<&str> = arr[0]["statuses"]
1458            .as_array()
1459            .unwrap()
1460            .iter()
1461            .filter_map(|v| v.as_str())
1462            .collect();
1463        assert_eq!(
1464            at_statuses,
1465            vec![
1466                "Collecting Intel",
1467                "Done",
1468                "In Progress",
1469                "To Do",
1470                "Verification",
1471            ]
1472        );
1473        let at_types: Vec<&str> = arr[0]["issueTypes"]
1474            .as_array()
1475            .unwrap()
1476            .iter()
1477            .filter_map(|v| v.as_str())
1478            .collect();
1479        assert!(at_types.contains(&"Task"));
1480        assert!(at_types.contains(&"Sub-task"));
1481
1482        assert_eq!(arr[1]["key"], "GP");
1483        assert_eq!(arr[1]["projectType"], "software");
1484        let gp_statuses: Vec<&str> = arr[1]["statuses"]
1485            .as_array()
1486            .unwrap()
1487            .iter()
1488            .filter_map(|v| v.as_str())
1489            .collect();
1490        assert_eq!(gp_statuses, vec!["Design", "Done", "To Do"]);
1491
1492        assert!(
1493            arr[0].get("users").is_none(),
1494            "users should not be in per-project data"
1495        );
1496    }
1497
1498    #[test]
1499    fn shape_projects_sorts_statuses_alphabetically() {
1500        let projects = json!([
1501            { "key": "P", "name": "P", "projectTypeKey": "software", "style": "next-gen" }
1502        ]);
1503        let statuses: Vec<Value> = vec![json!([
1504            { "name": "Task", "statuses": [
1505                { "name": "Done" }, { "name": "Custom" }, { "name": "To Do" }, { "name": "Alpha" }
1506            ]}
1507        ])];
1508        let shaped = shape_projects(projects.as_array().unwrap(), &statuses);
1509        let ordered: Vec<&str> = shaped[0]["statuses"]
1510            .as_array()
1511            .unwrap()
1512            .iter()
1513            .filter_map(|v| v.as_str())
1514            .collect();
1515        assert_eq!(ordered, vec!["Alpha", "Custom", "Done", "To Do"]);
1516    }
1517
1518    #[test]
1519    fn shape_projects_empty_inputs() {
1520        let shaped = shape_projects(&[], &[]);
1521        assert_eq!(shaped.len(), 0);
1522    }
1523}