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