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#[derive(Default)]
14enum LevelOfDetails {
15 Basic,
16 #[default]
17 BasicSearch,
18 Full,
19 Changelog,
20}
21
22pub 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 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
632fn 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
653fn 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 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
749fn 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
797fn 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(); 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(); 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 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 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#[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 #[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 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 #[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()); assert!(validate_issue_key("PROJ-").is_err()); assert!(validate_issue_key("-123").is_err()); assert!(validate_issue_key("PROJ-12x").is_err()); }
1174
1175 #[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 #[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 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 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 #[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}