1use std::path::PathBuf;
7use std::time::Instant;
8
9use anyhow::{Context, Result};
10use base64::Engine;
11use reqwest::{header::HeaderValue, Client};
12
13use tokio_util::io::ReaderStream;
14
15use crate::atlassian::adf::AdfDocument;
16use crate::atlassian::adf_validated::ValidatedAdfDocument;
17use crate::atlassian::auth::{AtlassianAuth, AuthMode};
18use crate::atlassian::confluence_types::{
19 ConfluenceContentSearchResponse, ConfluenceSearchResult, ConfluenceSearchResults,
20 ConfluenceUserGetEntry, ConfluenceUserGetResults, ConfluenceUserRecord,
21 ConfluenceUserSearchResponse, ConfluenceUserSearchResult, ConfluenceUserSearchResults,
22};
23use crate::atlassian::convert::adf_to_markdown;
24use crate::atlassian::error::AtlassianError;
25use crate::atlassian::jira_types::{
26 AgileBoard, AgileBoardList, AgileBoardListResponse, AgileIssueListResponse, AgileSprint,
27 AgileSprintEntry, AgileSprintList, AgileSprintListResponse, CreateMeta, CreateMetaField,
28 DevStatusCommit, DevStatusResponse, DevStatusSummaryCategory, DevStatusSummaryResponse,
29 EditMeta, EditMetaField, FieldSelection, JiraAllowedValueRaw, JiraAttachment,
30 JiraAttachmentEntry, JiraAttachmentIssueResponse, JiraChangelogEntry, JiraChangelogItem,
31 JiraChangelogResponse, JiraComment, JiraCommentEntry, JiraCommentsResponse, JiraComponent,
32 JiraComponentEntry, JiraCreateMetaFullResponse, JiraCreateMetaResponse,
33 JiraCreateMetaSchemaRaw, JiraCreateResponse, JiraCreatedIssue, JiraDevBranch, JiraDevCommit,
34 JiraDevProvider, JiraDevPullRequest, JiraDevRepository, JiraDevStatus, JiraDevStatusCount,
35 JiraDevStatusSummary, JiraEditMetaField, JiraEditMetaResponse, JiraField,
36 JiraFieldContextsResponse, JiraFieldEntry, JiraFieldOption, JiraFieldOptionsResponse,
37 JiraIssue, JiraIssueEnvelope, JiraIssueIdResponse, JiraIssueLink, JiraIssueLinksResponse,
38 JiraLinkType, JiraLinkTypesResponse, JiraProject, JiraProjectList, JiraProjectSearchResponse,
39 JiraProjectVersion, JiraProjectVersionEntry, JiraProjectVersionList, JiraRemoteIssueLink,
40 JiraRemoteIssueLinkEntry, JiraRemoteIssueLinkIcon, JiraRemoteIssueLinkObject,
41 JiraRemoteLinkCreateResponse, JiraSearchResponse, JiraSearchResult, JiraTransition,
42 JiraTransitionEntry, JiraTransitionToStatus, JiraTransitionsResponse, JiraUser,
43 JiraUserGetResults, JiraUserRecord, JiraUserSearchEntry, JiraUserSearchResult,
44 JiraUserSearchResults, JiraVisibility, JiraWatcherList, JiraWorklog, JiraWorklogList,
45 JiraWorklogResponse, TEXTAREA_CUSTOM_TYPE,
46};
47use crate::request_log;
48use crate::utils::http::{connect_timeout, read_timeout, retry_429};
49
50#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
52pub enum AuthService {
53 #[default]
55 Jira,
56 Confluence,
58}
59
60#[derive(Debug, serde::Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct AuthIdentity {
64 pub display_name: String,
66 pub email_address: Option<String>,
68 pub account_id: Option<String>,
70 #[serde(alias = "name")]
72 pub username: Option<String>,
73 #[serde(alias = "userKey")]
75 pub key: Option<String>,
76 #[serde(rename = "type")]
77 user_type: Option<String>,
78}
79
80const PAGE_SIZE: u32 = 100;
83
84#[derive(serde::Deserialize)]
87struct JiraErrorEnvelope {
88 #[serde(default, rename = "errorMessages")]
89 _error_messages: Vec<String>,
90 #[serde(default)]
91 errors: std::collections::BTreeMap<String, String>,
92}
93
94fn jira_write_error(status: u16, body: String) -> anyhow::Error {
105 if status == 400 {
106 if let Ok(parsed) = serde_json::from_str::<JiraErrorEnvelope>(&body) {
107 let needle = "atlassian document";
108 let matching: Vec<(&String, &String)> = parsed
109 .errors
110 .iter()
111 .filter(|(_, msg)| msg.to_ascii_lowercase().contains(needle))
112 .collect();
113 if !matching.is_empty() {
114 let fields: Vec<String> = matching.iter().map(|(k, _)| (*k).clone()).collect();
115 let original_message = matching[0].1.clone();
116 return AtlassianError::JiraAdfFieldRequired {
117 fields,
118 original_message,
119 body,
120 }
121 .into();
122 }
123 }
124 }
125 AtlassianError::ApiRequestFailed { status, body }.into()
126}
127
128pub struct AtlassianClient {
144 client: Client,
145 instance_url: String,
146 auth_header: HeaderValue,
147 auth_mode: AuthMode,
148}
149
150fn map_schema_type(raw_type: Option<String>, raw_custom: Option<&str>) -> Option<String> {
155 if raw_custom == Some(TEXTAREA_CUSTOM_TYPE) {
156 return Some("richtext".to_string());
157 }
158 raw_type
159}
160
161fn edit_meta_from_raw_fields(
167 raw: std::collections::BTreeMap<String, JiraEditMetaField>,
168) -> EditMeta {
169 let fields = raw
170 .into_iter()
171 .map(|(id, field)| {
172 let allowed_values = field.allowed_value_strings();
173 (
174 id,
175 EditMetaField {
176 name: field.name.unwrap_or_default(),
177 schema: field.schema.into(),
178 allowed_values,
179 },
180 )
181 })
182 .collect();
183 EditMeta { fields }
184}
185
186fn transition_from_entry(t: JiraTransitionEntry) -> JiraTransition {
190 JiraTransition {
191 id: t.id,
192 name: t.name,
193 to_status: t.to.map(|to| JiraTransitionToStatus {
194 id: to.id,
195 name: to.name,
196 category: to.status_category.and_then(|sc| sc.key),
197 }),
198 has_screen: t.has_screen,
199 }
200}
201
202fn validate_iso_date(date: Option<&str>, field: &str) -> Result<()> {
207 let Some(d) = date else { return Ok(()) };
208 chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
209 .with_context(|| format!("{field} must be YYYY-MM-DD, got {d:?}"))?;
210 Ok(())
211}
212
213fn user_lookup_error(status: u16, body: &str) -> String {
217 let snippet = body.trim();
218 if snippet.is_empty() {
219 format!("HTTP {status}")
220 } else {
221 let snippet: String = snippet.chars().take(200).collect();
222 format!("HTTP {status}: {snippet}")
223 }
224}
225
226#[cfg(test)]
229#[allow(
230 clippy::unwrap_used,
231 clippy::expect_used,
232 clippy::items_after_test_module
233)]
234mod tests {
235 use super::*;
236 use crate::atlassian::jira_types::{DevStatusAuthor, JiraIssueResponse, JiraVisibilityType};
237
238 #[test]
239 fn new_client_strips_trailing_slash() {
240 let client =
241 AtlassianClient::new("https://org.atlassian.net/", "user@test.com", "token").unwrap();
242 assert_eq!(client.instance_url(), "https://org.atlassian.net");
243 }
244
245 #[test]
246 fn new_client_preserves_clean_url() {
247 let client =
248 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
249 assert_eq!(client.instance_url(), "https://org.atlassian.net");
250 }
251
252 #[test]
253 fn new_client_sets_basic_auth() {
254 let client =
255 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
256 let expected_credentials = "user@test.com:token";
257 let expected_encoded =
258 base64::engine::general_purpose::STANDARD.encode(expected_credentials);
259 assert_eq!(
260 client.auth_header.to_str().unwrap(),
261 format!("Basic {expected_encoded}")
262 );
263 }
264
265 #[test]
266 fn from_credentials() {
267 let creds = crate::atlassian::auth::AtlassianCredentials {
268 instance_url: "https://org.atlassian.net".to_string(),
269 auth: crate::atlassian::auth::AtlassianAuth::Basic {
270 email: "user@test.com".to_string(),
271 api_token: "token123".into(),
272 },
273 };
274 let client = AtlassianClient::from_credentials(&creds).unwrap();
275 assert_eq!(client.instance_url(), "https://org.atlassian.net");
276 }
277
278 #[test]
279 fn jira_issue_struct_fields() {
280 let issue = JiraIssue {
281 key: "TEST-1".to_string(),
282 summary: "Test issue".to_string(),
283 description_adf: None,
284 status: Some("Open".to_string()),
285 issue_type: Some("Bug".to_string()),
286 assignee: Some("Alice".to_string()),
287 priority: Some("High".to_string()),
288 labels: vec!["backend".to_string()],
289 custom_fields: Vec::new(),
290 };
291 assert_eq!(issue.key, "TEST-1");
292 assert_eq!(issue.labels.len(), 1);
293 }
294
295 #[test]
296 fn jira_user_deserialization() {
297 let json = r#"{
298 "displayName": "Alice Smith",
299 "emailAddress": "alice@example.com",
300 "accountId": "abc123"
301 }"#;
302 let user: JiraUser = serde_json::from_str(json).unwrap();
303 assert_eq!(user.display_name, "Alice Smith");
304 assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
305 assert_eq!(user.account_id, "abc123");
306 }
307
308 #[test]
309 fn jira_user_optional_email() {
310 let json = r#"{
311 "displayName": "Bot",
312 "accountId": "bot123"
313 }"#;
314 let user: JiraUser = serde_json::from_str(json).unwrap();
315 assert!(user.email_address.is_none());
316 }
317
318 #[test]
319 fn jira_issue_response_deserialization() {
320 let json = r#"{
321 "key": "PROJ-42",
322 "fields": {
323 "summary": "Test",
324 "description": null,
325 "status": {"name": "Open"},
326 "issuetype": {"name": "Bug"},
327 "assignee": {"displayName": "Bob"},
328 "priority": {"name": "Medium"},
329 "labels": ["frontend"]
330 }
331 }"#;
332 let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
333 assert_eq!(response.key, "PROJ-42");
334 assert_eq!(response.fields.summary.as_deref(), Some("Test"));
335 assert_eq!(response.fields.labels, vec!["frontend"]);
336 }
337
338 #[test]
339 fn jira_issue_response_minimal_fields() {
340 let json = r#"{
341 "key": "PROJ-1",
342 "fields": {
343 "summary": null,
344 "description": null,
345 "status": null,
346 "issuetype": null,
347 "assignee": null,
348 "priority": null,
349 "labels": []
350 }
351 }"#;
352 let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
353 assert_eq!(response.key, "PROJ-1");
354 assert!(response.fields.summary.is_none());
355 }
356
357 #[tokio::test]
358 async fn get_json_retries_on_429() {
359 let server = wiremock::MockServer::start().await;
360
361 wiremock::Mock::given(wiremock::matchers::method("GET"))
363 .and(wiremock::matchers::path("/test"))
364 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
365 .up_to_n_times(1)
366 .mount(&server)
367 .await;
368
369 wiremock::Mock::given(wiremock::matchers::method("GET"))
371 .and(wiremock::matchers::path("/test"))
372 .respond_with(
373 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
374 )
375 .up_to_n_times(1)
376 .mount(&server)
377 .await;
378
379 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
380 let resp = client
381 .get_json(&format!("{}/test", server.uri()))
382 .await
383 .unwrap();
384 assert!(resp.status().is_success());
385 }
386
387 #[tokio::test]
388 async fn get_json_returns_429_after_max_retries() {
389 let server = wiremock::MockServer::start().await;
390
391 wiremock::Mock::given(wiremock::matchers::method("GET"))
393 .and(wiremock::matchers::path("/test"))
394 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
395 .mount(&server)
396 .await;
397
398 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
399 let resp = client
400 .get_json(&format!("{}/test", server.uri()))
401 .await
402 .unwrap();
403 assert_eq!(resp.status().as_u16(), 429);
405 }
406
407 #[test]
410 fn user_lookup_error_formats() {
411 assert_eq!(user_lookup_error(404, ""), "HTTP 404");
412 assert_eq!(user_lookup_error(404, " "), "HTTP 404");
413 assert_eq!(
414 user_lookup_error(403, "no permission"),
415 "HTTP 403: no permission"
416 );
417 }
418
419 #[tokio::test]
420 async fn get_jira_user_success() {
421 let server = wiremock::MockServer::start().await;
422 wiremock::Mock::given(wiremock::matchers::method("GET"))
423 .and(wiremock::matchers::path("/rest/api/3/user"))
424 .and(wiremock::matchers::query_param("accountId", "abc123"))
425 .respond_with(
426 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
427 "accountId": "abc123",
428 "displayName": "Alice Smith",
429 "emailAddress": "alice@example.com",
430 "active": true,
431 "accountType": "atlassian"
432 })),
433 )
434 .mount(&server)
435 .await;
436
437 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
438 let record = client.get_jira_user("abc123").await.unwrap();
439 assert_eq!(record.account_id, "abc123");
440 assert_eq!(record.display_name.as_deref(), Some("Alice Smith"));
441 assert_eq!(record.email_address.as_deref(), Some("alice@example.com"));
442 assert_eq!(record.active, Some(true));
443 assert_eq!(record.account_type.as_deref(), Some("atlassian"));
444 assert!(record.error.is_none());
445 }
446
447 #[tokio::test]
448 async fn get_jira_user_deactivated_is_a_record_not_an_error() {
449 let server = wiremock::MockServer::start().await;
450 wiremock::Mock::given(wiremock::matchers::method("GET"))
451 .and(wiremock::matchers::path("/rest/api/3/user"))
452 .respond_with(
453 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
454 "accountId": "gone1",
455 "active": false,
456 "accountType": "atlassian"
457 })),
458 )
459 .mount(&server)
460 .await;
461
462 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
463 let record = client.get_jira_user("gone1").await.unwrap();
464 assert_eq!(record.account_id, "gone1");
465 assert_eq!(record.active, Some(false));
466 assert!(record.display_name.is_none());
467 assert!(record.error.is_none());
468 }
469
470 #[tokio::test]
471 async fn get_jira_user_not_found_yields_stub() {
472 let server = wiremock::MockServer::start().await;
473 wiremock::Mock::given(wiremock::matchers::method("GET"))
474 .and(wiremock::matchers::path("/rest/api/3/user"))
475 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
476 .mount(&server)
477 .await;
478
479 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
480 let record = client.get_jira_user("missing").await.unwrap();
481 assert_eq!(record.account_id, "missing");
482 assert!(record.display_name.is_none());
483 assert!(record.error.as_deref().unwrap().starts_with("HTTP 404"));
484 }
485
486 #[tokio::test]
487 async fn get_jira_user_unauthorized_is_hard_error() {
488 let server = wiremock::MockServer::start().await;
489 wiremock::Mock::given(wiremock::matchers::method("GET"))
490 .and(wiremock::matchers::path("/rest/api/3/user"))
491 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
492 .mount(&server)
493 .await;
494
495 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
496 assert!(client.get_jira_user("whoever").await.is_err());
497 }
498
499 #[tokio::test]
500 async fn get_jira_users_batch_survives_one_bad_id() {
501 let server = wiremock::MockServer::start().await;
502 wiremock::Mock::given(wiremock::matchers::method("GET"))
503 .and(wiremock::matchers::path("/rest/api/3/user"))
504 .and(wiremock::matchers::query_param("accountId", "good"))
505 .respond_with(
506 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
507 "accountId": "good",
508 "displayName": "Good User",
509 "active": true
510 })),
511 )
512 .mount(&server)
513 .await;
514 wiremock::Mock::given(wiremock::matchers::method("GET"))
515 .and(wiremock::matchers::path("/rest/api/3/user"))
516 .and(wiremock::matchers::query_param("accountId", "bad"))
517 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
518 .mount(&server)
519 .await;
520
521 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
522 let ids = vec!["good".to_string(), "bad".to_string()];
523 let results = client.get_jira_users(&ids).await.unwrap();
524 assert_eq!(results.users.len(), 2);
525 assert_eq!(results.users[0].account_id, "good");
526 assert_eq!(results.users[0].display_name.as_deref(), Some("Good User"));
527 assert!(results.users[0].error.is_none());
528 assert_eq!(results.users[1].account_id, "bad");
529 assert!(results.users[1].error.is_some());
530 }
531
532 #[tokio::test]
533 async fn get_jira_users_empty_input_makes_no_requests() {
534 let client = AtlassianClient::new("https://org.atlassian.net", "u@t.com", "tok").unwrap();
535 let results = client.get_jira_users(&[]).await.unwrap();
536 assert!(results.users.is_empty());
537 }
538
539 #[tokio::test]
540 async fn get_confluence_user_success() {
541 let server = wiremock::MockServer::start().await;
542 wiremock::Mock::given(wiremock::matchers::method("GET"))
543 .and(wiremock::matchers::path("/wiki/rest/api/user"))
544 .and(wiremock::matchers::query_param("accountId", "abc123"))
545 .respond_with(
546 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
547 "accountId": "abc123",
548 "accountType": "atlassian",
549 "displayName": "Alice Smith",
550 "email": "alice@example.com"
551 })),
552 )
553 .mount(&server)
554 .await;
555
556 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
557 let record = client.get_confluence_user("abc123").await.unwrap();
558 assert_eq!(record.account_id, "abc123");
559 assert_eq!(record.display_name.as_deref(), Some("Alice Smith"));
560 assert_eq!(record.email.as_deref(), Some("alice@example.com"));
561 assert_eq!(record.account_type.as_deref(), Some("atlassian"));
562 assert!(record.active.is_none());
563 assert!(record.error.is_none());
564 }
565
566 #[tokio::test]
567 async fn get_confluence_user_falls_back_to_public_name() {
568 let server = wiremock::MockServer::start().await;
569 wiremock::Mock::given(wiremock::matchers::method("GET"))
570 .and(wiremock::matchers::path("/wiki/rest/api/user"))
571 .respond_with(
572 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
573 "accountId": "app1",
574 "accountType": "app",
575 "publicName": "Automation App"
576 })),
577 )
578 .mount(&server)
579 .await;
580
581 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
582 let record = client.get_confluence_user("app1").await.unwrap();
583 assert_eq!(record.display_name.as_deref(), Some("Automation App"));
584 }
585
586 #[tokio::test]
587 async fn get_confluence_user_not_found_yields_stub() {
588 let server = wiremock::MockServer::start().await;
589 wiremock::Mock::given(wiremock::matchers::method("GET"))
590 .and(wiremock::matchers::path("/wiki/rest/api/user"))
591 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
592 .mount(&server)
593 .await;
594
595 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
596 let record = client.get_confluence_user("missing").await.unwrap();
597 assert_eq!(record.account_id, "missing");
598 assert!(record.error.as_deref().unwrap().starts_with("HTTP 404"));
599 }
600
601 #[tokio::test]
602 async fn get_confluence_user_unauthorized_is_hard_error() {
603 let server = wiremock::MockServer::start().await;
604 wiremock::Mock::given(wiremock::matchers::method("GET"))
605 .and(wiremock::matchers::path("/wiki/rest/api/user"))
606 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
607 .mount(&server)
608 .await;
609
610 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
611 assert!(client.get_confluence_user("whoever").await.is_err());
612 }
613
614 #[tokio::test]
615 async fn get_confluence_users_batch_survives_one_bad_id() {
616 let server = wiremock::MockServer::start().await;
617 wiremock::Mock::given(wiremock::matchers::method("GET"))
618 .and(wiremock::matchers::path("/wiki/rest/api/user"))
619 .and(wiremock::matchers::query_param("accountId", "good"))
620 .respond_with(
621 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
622 "accountId": "good",
623 "displayName": "Good User"
624 })),
625 )
626 .mount(&server)
627 .await;
628 wiremock::Mock::given(wiremock::matchers::method("GET"))
629 .and(wiremock::matchers::path("/wiki/rest/api/user"))
630 .and(wiremock::matchers::query_param("accountId", "bad"))
631 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
632 .mount(&server)
633 .await;
634
635 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
636 let ids = vec!["good".to_string(), "bad".to_string()];
637 let results = client.get_confluence_users(&ids).await.unwrap();
638 assert_eq!(results.users.len(), 2);
639 assert_eq!(results.users[0].display_name.as_deref(), Some("Good User"));
640 assert!(results.users[0].error.is_none());
641 assert!(results.users[1].error.is_some());
642 }
643
644 #[tokio::test]
645 async fn post_json_retries_on_429() {
646 let server = wiremock::MockServer::start().await;
647
648 wiremock::Mock::given(wiremock::matchers::method("POST"))
649 .and(wiremock::matchers::path("/test"))
650 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
651 .up_to_n_times(1)
652 .mount(&server)
653 .await;
654
655 wiremock::Mock::given(wiremock::matchers::method("POST"))
656 .and(wiremock::matchers::path("/test"))
657 .respond_with(wiremock::ResponseTemplate::new(201))
658 .up_to_n_times(1)
659 .mount(&server)
660 .await;
661
662 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
663 let body = serde_json::json!({"key": "value"});
664 let resp = client
665 .post_json(&format!("{}/test", server.uri()), &body)
666 .await
667 .unwrap();
668 assert_eq!(resp.status().as_u16(), 201);
669 }
670
671 #[tokio::test]
672 async fn delete_retries_on_429() {
673 let server = wiremock::MockServer::start().await;
674
675 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
676 .and(wiremock::matchers::path("/test"))
677 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
678 .up_to_n_times(1)
679 .mount(&server)
680 .await;
681
682 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
683 .and(wiremock::matchers::path("/test"))
684 .respond_with(wiremock::ResponseTemplate::new(204))
685 .up_to_n_times(1)
686 .mount(&server)
687 .await;
688
689 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
690 let resp = client
691 .delete(&format!("{}/test", server.uri()))
692 .await
693 .unwrap();
694 assert_eq!(resp.status().as_u16(), 204);
695 }
696
697 #[tokio::test]
698 async fn get_json_sends_auth_header() {
699 let server = wiremock::MockServer::start().await;
700
701 wiremock::Mock::given(wiremock::matchers::method("GET"))
702 .and(wiremock::matchers::header(
703 "Authorization",
704 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
705 ))
706 .and(wiremock::matchers::header("Accept", "application/json"))
707 .respond_with(
708 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
709 )
710 .expect(1)
711 .mount(&server)
712 .await;
713
714 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
715 let resp = client
716 .get_json(&format!("{}/test", server.uri()))
717 .await
718 .unwrap();
719 assert!(resp.status().is_success());
720 }
721
722 #[tokio::test]
723 async fn put_json_sends_body_and_auth() {
724 let server = wiremock::MockServer::start().await;
725
726 wiremock::Mock::given(wiremock::matchers::method("PUT"))
727 .and(wiremock::matchers::header(
728 "Authorization",
729 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
730 ))
731 .and(wiremock::matchers::header(
732 "Content-Type",
733 "application/json",
734 ))
735 .respond_with(wiremock::ResponseTemplate::new(200))
736 .expect(1)
737 .mount(&server)
738 .await;
739
740 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
741 let body = serde_json::json!({"key": "value"});
742 let resp = client
743 .put_json(&format!("{}/test", server.uri()), &body)
744 .await
745 .unwrap();
746 assert!(resp.status().is_success());
747 }
748
749 #[tokio::test]
750 async fn post_json_sends_body_and_auth() {
751 let server = wiremock::MockServer::start().await;
752
753 wiremock::Mock::given(wiremock::matchers::method("POST"))
754 .and(wiremock::matchers::header(
755 "Authorization",
756 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
757 ))
758 .and(wiremock::matchers::header(
759 "Content-Type",
760 "application/json",
761 ))
762 .respond_with(
763 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "1"})),
764 )
765 .expect(1)
766 .mount(&server)
767 .await;
768
769 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
770 let body = serde_json::json!({"name": "test"});
771 let resp = client
772 .post_json(&format!("{}/test", server.uri()), &body)
773 .await
774 .unwrap();
775 assert_eq!(resp.status().as_u16(), 201);
776 }
777
778 #[tokio::test]
779 async fn post_json_error_response() {
780 let server = wiremock::MockServer::start().await;
781
782 wiremock::Mock::given(wiremock::matchers::method("POST"))
783 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
784 .expect(1)
785 .mount(&server)
786 .await;
787
788 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
789 let body = serde_json::json!({});
790 let resp = client
791 .post_json(&format!("{}/test", server.uri()), &body)
792 .await
793 .unwrap();
794 assert_eq!(resp.status().as_u16(), 400);
795 }
796
797 #[tokio::test]
798 async fn delete_sends_auth_header() {
799 let server = wiremock::MockServer::start().await;
800
801 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
802 .and(wiremock::matchers::header(
803 "Authorization",
804 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
805 ))
806 .respond_with(wiremock::ResponseTemplate::new(204))
807 .expect(1)
808 .mount(&server)
809 .await;
810
811 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
812 let resp = client
813 .delete(&format!("{}/test", server.uri()))
814 .await
815 .unwrap();
816 assert_eq!(resp.status().as_u16(), 204);
817 }
818
819 #[tokio::test]
820 async fn delete_error_response() {
821 let server = wiremock::MockServer::start().await;
822
823 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
824 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
825 .expect(1)
826 .mount(&server)
827 .await;
828
829 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
830 let resp = client
831 .delete(&format!("{}/test", server.uri()))
832 .await
833 .unwrap();
834 assert_eq!(resp.status().as_u16(), 404);
835 }
836
837 #[tokio::test]
838 async fn get_issue_success() {
839 let server = wiremock::MockServer::start().await;
840
841 let issue_json = serde_json::json!({
842 "key": "PROJ-42",
843 "fields": {
844 "summary": "Fix the bug",
845 "description": {
846 "version": 1,
847 "type": "doc",
848 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Details"}]}]
849 },
850 "status": {"name": "Open"},
851 "issuetype": {"name": "Bug"},
852 "assignee": {"displayName": "Alice"},
853 "priority": {"name": "High"},
854 "labels": ["backend", "urgent"]
855 }
856 });
857
858 wiremock::Mock::given(wiremock::matchers::method("GET"))
859 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
860 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
861 .expect(1)
862 .mount(&server)
863 .await;
864
865 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
866 let issue = client.get_issue("PROJ-42").await.unwrap();
867
868 assert_eq!(issue.key, "PROJ-42");
869 assert_eq!(issue.summary, "Fix the bug");
870 assert_eq!(issue.status.as_deref(), Some("Open"));
871 assert_eq!(issue.issue_type.as_deref(), Some("Bug"));
872 assert_eq!(issue.assignee.as_deref(), Some("Alice"));
873 assert_eq!(issue.priority.as_deref(), Some("High"));
874 assert_eq!(issue.labels, vec!["backend", "urgent"]);
875 assert!(issue.description_adf.is_some());
876 }
877
878 #[tokio::test]
879 async fn get_issue_api_error() {
880 let server = wiremock::MockServer::start().await;
881
882 wiremock::Mock::given(wiremock::matchers::method("GET"))
883 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
884 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
885 .expect(1)
886 .mount(&server)
887 .await;
888
889 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
890 let err = client.get_issue("NOPE-1").await.unwrap_err();
891 assert!(err.to_string().contains("404"));
892 }
893
894 #[tokio::test]
895 async fn get_issue_with_fields_named_populates_custom_fields() {
896 let server = wiremock::MockServer::start().await;
897
898 let issue_json = serde_json::json!({
899 "key": "ACME-1",
900 "fields": {
901 "summary": "S",
902 "description": null,
903 "status": {"name": "Open"},
904 "issuetype": {"name": "Bug"},
905 "assignee": null,
906 "priority": null,
907 "labels": [],
908 "customfield_10088": {
909 "type": "doc",
910 "version": 1,
911 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "AC"}]}]
912 }
913 },
914 "names": {
915 "customfield_10088": "Acceptance Criteria"
916 }
917 });
918
919 wiremock::Mock::given(wiremock::matchers::method("GET"))
920 .and(wiremock::matchers::path("/rest/api/3/issue/ACME-1"))
921 .and(wiremock::matchers::query_param("expand", "names,schema"))
922 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
923 .expect(1)
924 .mount(&server)
925 .await;
926
927 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
928 let issue = client
929 .get_issue_with_fields(
930 "ACME-1",
931 FieldSelection::Named(vec!["customfield_10088".to_string()]),
932 )
933 .await
934 .unwrap();
935
936 assert_eq!(issue.key, "ACME-1");
937 assert_eq!(issue.custom_fields.len(), 1);
938 let cf = &issue.custom_fields[0];
939 assert_eq!(cf.id, "customfield_10088");
940 assert_eq!(cf.name, "Acceptance Criteria");
941 assert_eq!(cf.value["type"], "doc");
942 }
943
944 #[tokio::test]
945 async fn get_issue_with_fields_standard_omits_custom_fields() {
946 let server = wiremock::MockServer::start().await;
947
948 let issue_json = serde_json::json!({
949 "key": "ACME-1",
950 "fields": {
951 "summary": "S",
952 "description": null,
953 "status": null,
954 "issuetype": null,
955 "assignee": null,
956 "priority": null,
957 "labels": [],
958 "customfield_10088": {"value": "Unplanned"}
959 },
960 "names": {
961 "customfield_10088": "Planned / Unplanned Work"
962 }
963 });
964
965 wiremock::Mock::given(wiremock::matchers::method("GET"))
966 .and(wiremock::matchers::path("/rest/api/3/issue/ACME-1"))
967 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
968 .expect(1)
969 .mount(&server)
970 .await;
971
972 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
973 let issue = client
974 .get_issue_with_fields("ACME-1", FieldSelection::Standard)
975 .await
976 .unwrap();
977
978 assert!(issue.custom_fields.is_empty());
979 }
980
981 #[tokio::test]
982 async fn get_issue_with_fields_all_uses_star_param() {
983 let server = wiremock::MockServer::start().await;
984
985 let issue_json = serde_json::json!({
986 "key": "ACME-1",
987 "fields": {
988 "summary": "S",
989 "description": null,
990 "status": null,
991 "issuetype": null,
992 "assignee": null,
993 "priority": null,
994 "labels": [],
995 "customfield_10001": {"value": "Unplanned"},
996 "customfield_10002": 42
997 },
998 "names": {
999 "customfield_10001": "Planned / Unplanned Work",
1000 "customfield_10002": "Story points"
1001 }
1002 });
1003
1004 wiremock::Mock::given(wiremock::matchers::method("GET"))
1005 .and(wiremock::matchers::path("/rest/api/3/issue/ACME-1"))
1006 .and(wiremock::matchers::query_param("fields", "*all"))
1007 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
1008 .expect(1)
1009 .mount(&server)
1010 .await;
1011
1012 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1013 let issue = client
1014 .get_issue_with_fields("ACME-1", FieldSelection::All)
1015 .await
1016 .unwrap();
1017
1018 assert_eq!(issue.custom_fields.len(), 2);
1019 let names: Vec<&str> = issue
1020 .custom_fields
1021 .iter()
1022 .map(|c| c.name.as_str())
1023 .collect();
1024 assert!(names.contains(&"Planned / Unplanned Work"));
1025 assert!(names.contains(&"Story points"));
1026 }
1027
1028 #[tokio::test]
1029 async fn get_editmeta_parses_field_schema() {
1030 let server = wiremock::MockServer::start().await;
1031
1032 let editmeta_json = serde_json::json!({
1033 "fields": {
1034 "customfield_10088": {
1035 "name": "Acceptance Criteria",
1036 "schema": {
1037 "type": "string",
1038 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:textarea",
1039 "customId": 19300
1040 }
1041 },
1042 "customfield_10001": {
1043 "name": "Planned / Unplanned Work",
1044 "schema": {
1045 "type": "option",
1046 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:select",
1047 "customId": 10001
1048 }
1049 },
1050 "labels": {
1051 "name": "Labels",
1052 "schema": {
1053 "type": "array",
1054 "items": "string",
1055 "system": "labels"
1056 }
1057 },
1058 "description": {
1059 "name": "Description",
1060 "schema": {
1061 "type": "string",
1062 "system": "description"
1063 }
1064 }
1065 }
1066 });
1067
1068 wiremock::Mock::given(wiremock::matchers::method("GET"))
1069 .and(wiremock::matchers::path(
1070 "/rest/api/3/issue/ACME-1/editmeta",
1071 ))
1072 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&editmeta_json))
1073 .expect(1)
1074 .mount(&server)
1075 .await;
1076
1077 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1078 let meta = client.get_editmeta("ACME-1").await.unwrap();
1079
1080 assert_eq!(meta.fields.len(), 4);
1081 let ac = meta.fields.get("customfield_10088").unwrap();
1082 assert_eq!(ac.name, "Acceptance Criteria");
1083 assert!(ac.is_adf_rich_text());
1084 let opt = meta.fields.get("customfield_10001").unwrap();
1085 assert_eq!(opt.schema.kind, "option");
1086 assert!(!opt.is_adf_rich_text());
1087 let labels = meta.fields.get("labels").unwrap();
1088 assert_eq!(labels.schema.kind, "array");
1089 assert_eq!(labels.schema.items.as_deref(), Some("string"));
1090 assert_eq!(labels.schema.system.as_deref(), Some("labels"));
1091 assert!(!labels.is_adf_rich_text());
1092 let description = meta.fields.get("description").unwrap();
1093 assert_eq!(description.schema.system.as_deref(), Some("description"));
1094 assert!(description.is_adf_rich_text());
1095 }
1096
1097 #[tokio::test]
1098 async fn get_editmeta_api_error_surfaces_status() {
1099 let server = wiremock::MockServer::start().await;
1100
1101 wiremock::Mock::given(wiremock::matchers::method("GET"))
1102 .and(wiremock::matchers::path(
1103 "/rest/api/3/issue/NOPE-1/editmeta",
1104 ))
1105 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1106 .mount(&server)
1107 .await;
1108
1109 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1110 let err = client.get_editmeta("NOPE-1").await.unwrap_err();
1111 assert!(err.to_string().contains("404"));
1112 }
1113
1114 #[tokio::test]
1115 async fn update_issue_with_custom_fields_merges_into_payload() {
1116 let server = wiremock::MockServer::start().await;
1117
1118 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1119 .and(wiremock::matchers::path("/rest/api/3/issue/ACME-1"))
1120 .and(wiremock::matchers::body_json(serde_json::json!({
1121 "fields": {
1122 "description": {"version": 1, "type": "doc", "content": []},
1123 "summary": "New title",
1124 "customfield_10001": {"value": "Unplanned"},
1125 "customfield_10088": {
1126 "type": "doc",
1127 "version": 1,
1128 "content": [{"type": "paragraph"}]
1129 }
1130 }
1131 })))
1132 .respond_with(wiremock::ResponseTemplate::new(204))
1133 .expect(1)
1134 .mount(&server)
1135 .await;
1136
1137 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1138 let adf = ValidatedAdfDocument::empty();
1139 let mut custom = std::collections::BTreeMap::new();
1140 custom.insert(
1141 "customfield_10001".to_string(),
1142 serde_json::json!({"value": "Unplanned"}),
1143 );
1144 custom.insert(
1145 "customfield_10088".to_string(),
1146 serde_json::json!({"type": "doc", "version": 1, "content": [{"type": "paragraph"}]}),
1147 );
1148 let result = client
1149 .update_issue_with_custom_fields("ACME-1", Some(&adf), Some("New title"), &custom)
1150 .await;
1151 assert!(result.is_ok());
1152 }
1153
1154 #[tokio::test]
1155 async fn update_issue_with_no_fields_errors() {
1156 let client =
1157 AtlassianClient::new("https://example.atlassian.net", "user@test.com", "token")
1158 .unwrap();
1159 let err = client
1160 .update_issue_with_custom_fields(
1161 "ACME-1",
1162 None,
1163 None,
1164 &std::collections::BTreeMap::new(),
1165 )
1166 .await
1167 .unwrap_err();
1168 assert!(err.to_string().contains("no fields to update"));
1169 }
1170
1171 #[tokio::test]
1172 async fn update_issue_shim_sends_no_custom_fields() {
1173 let server = wiremock::MockServer::start().await;
1174
1175 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1176 .and(wiremock::matchers::path("/rest/api/3/issue/ACME-1"))
1177 .and(wiremock::matchers::body_json(serde_json::json!({
1178 "fields": {
1179 "description": {"version": 1, "type": "doc", "content": []}
1180 }
1181 })))
1182 .respond_with(wiremock::ResponseTemplate::new(204))
1183 .expect(1)
1184 .mount(&server)
1185 .await;
1186
1187 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1188 let adf = ValidatedAdfDocument::empty();
1189 client.update_issue("ACME-1", &adf, None).await.unwrap();
1190 }
1191
1192 #[tokio::test]
1193 async fn get_issue_with_fields_falls_back_to_id_when_names_missing() {
1194 let server = wiremock::MockServer::start().await;
1195
1196 let issue_json = serde_json::json!({
1197 "key": "ACME-1",
1198 "fields": {
1199 "summary": "S",
1200 "description": null,
1201 "status": null,
1202 "issuetype": null,
1203 "assignee": null,
1204 "priority": null,
1205 "labels": [],
1206 "customfield_99999": "raw"
1207 }
1208 });
1209
1210 wiremock::Mock::given(wiremock::matchers::method("GET"))
1211 .and(wiremock::matchers::path("/rest/api/3/issue/ACME-1"))
1212 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
1213 .expect(1)
1214 .mount(&server)
1215 .await;
1216
1217 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1218 let issue = client
1219 .get_issue_with_fields("ACME-1", FieldSelection::All)
1220 .await
1221 .unwrap();
1222
1223 assert_eq!(issue.custom_fields.len(), 1);
1224 assert_eq!(issue.custom_fields[0].name, "customfield_99999");
1225 }
1226
1227 #[tokio::test]
1228 async fn update_issue_success() {
1229 let server = wiremock::MockServer::start().await;
1230
1231 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1232 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1233 .respond_with(wiremock::ResponseTemplate::new(204))
1234 .expect(1)
1235 .mount(&server)
1236 .await;
1237
1238 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1239 let adf = ValidatedAdfDocument::empty();
1240 let result = client
1241 .update_issue("PROJ-42", &adf, Some("New title"))
1242 .await;
1243 assert!(result.is_ok());
1244 }
1245
1246 #[tokio::test]
1247 async fn update_issue_without_summary() {
1248 let server = wiremock::MockServer::start().await;
1249
1250 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1251 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1252 .respond_with(wiremock::ResponseTemplate::new(204))
1253 .expect(1)
1254 .mount(&server)
1255 .await;
1256
1257 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1258 let adf = ValidatedAdfDocument::empty();
1259 let result = client.update_issue("PROJ-42", &adf, None).await;
1260 assert!(result.is_ok());
1261 }
1262
1263 #[tokio::test]
1264 async fn update_issue_api_error() {
1265 let server = wiremock::MockServer::start().await;
1266
1267 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1268 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1269 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1270 .expect(1)
1271 .mount(&server)
1272 .await;
1273
1274 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1275 let adf = ValidatedAdfDocument::empty();
1276 let err = client
1277 .update_issue("PROJ-42", &adf, None)
1278 .await
1279 .unwrap_err();
1280 assert!(err.to_string().contains("403"));
1281 }
1282
1283 #[tokio::test]
1284 async fn search_issues_success() {
1285 let server = wiremock::MockServer::start().await;
1286
1287 let search_json = serde_json::json!({
1288 "issues": [
1289 {
1290 "key": "PROJ-1",
1291 "fields": {
1292 "summary": "First issue",
1293 "description": null,
1294 "status": {"name": "Open"},
1295 "issuetype": {"name": "Bug"},
1296 "assignee": {"displayName": "Alice"},
1297 "priority": {"name": "High"},
1298 "labels": []
1299 }
1300 },
1301 {
1302 "key": "PROJ-2",
1303 "fields": {
1304 "summary": "Second issue",
1305 "description": null,
1306 "status": {"name": "Done"},
1307 "issuetype": {"name": "Task"},
1308 "assignee": null,
1309 "priority": null,
1310 "labels": ["backend"]
1311 }
1312 }
1313 ],
1314 "total": 2
1315 });
1316
1317 wiremock::Mock::given(wiremock::matchers::method("POST"))
1318 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1319 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&search_json))
1320 .expect(1)
1321 .mount(&server)
1322 .await;
1323
1324 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1325 let result = client.search_issues("project = PROJ", 50).await.unwrap();
1326
1327 assert_eq!(result.total, 2);
1328 assert_eq!(result.issues.len(), 2);
1329 assert_eq!(result.issues[0].key, "PROJ-1");
1330 assert_eq!(result.issues[0].summary, "First issue");
1331 assert_eq!(result.issues[0].status.as_deref(), Some("Open"));
1332 assert_eq!(result.issues[1].key, "PROJ-2");
1333 assert!(result.issues[1].assignee.is_none());
1334 }
1335
1336 #[tokio::test]
1337 async fn search_issues_without_total() {
1338 let server = wiremock::MockServer::start().await;
1339
1340 wiremock::Mock::given(wiremock::matchers::method("POST"))
1341 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1342 .respond_with(
1343 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1344 "issues": [{
1345 "key": "PROJ-1",
1346 "fields": {
1347 "summary": "Test",
1348 "description": null,
1349 "status": null,
1350 "issuetype": null,
1351 "assignee": null,
1352 "priority": null,
1353 "labels": []
1354 }
1355 }]
1356 })),
1357 )
1358 .expect(1)
1359 .mount(&server)
1360 .await;
1361
1362 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1363 let result = client.search_issues("project = PROJ", 50).await.unwrap();
1364
1365 assert_eq!(result.issues.len(), 1);
1366 assert_eq!(result.total, 1);
1368 }
1369
1370 #[tokio::test]
1371 async fn search_issues_empty_results() {
1372 let server = wiremock::MockServer::start().await;
1373
1374 wiremock::Mock::given(wiremock::matchers::method("POST"))
1375 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1376 .respond_with(
1377 wiremock::ResponseTemplate::new(200)
1378 .set_body_json(serde_json::json!({"issues": [], "total": 0})),
1379 )
1380 .expect(1)
1381 .mount(&server)
1382 .await;
1383
1384 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1385 let result = client.search_issues("project = NOPE", 50).await.unwrap();
1386
1387 assert_eq!(result.total, 0);
1388 assert!(result.issues.is_empty());
1389 }
1390
1391 #[tokio::test]
1392 async fn search_issues_api_error() {
1393 let server = wiremock::MockServer::start().await;
1394
1395 wiremock::Mock::given(wiremock::matchers::method("POST"))
1396 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1397 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid JQL query"))
1398 .expect(1)
1399 .mount(&server)
1400 .await;
1401
1402 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1403 let err = client
1404 .search_issues("invalid jql !!!", 50)
1405 .await
1406 .unwrap_err();
1407 assert!(err.to_string().contains("400"));
1408 }
1409
1410 #[tokio::test]
1411 async fn create_issue_success() {
1412 let server = wiremock::MockServer::start().await;
1413
1414 wiremock::Mock::given(wiremock::matchers::method("POST"))
1415 .and(wiremock::matchers::path("/rest/api/3/issue"))
1416 .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
1417 serde_json::json!({"key": "PROJ-124", "id": "10042", "self": "https://org.atlassian.net/rest/api/3/issue/10042"}),
1418 ))
1419 .expect(1)
1420 .mount(&server)
1421 .await;
1422
1423 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1424 let result = client
1425 .create_issue("PROJ", "Bug", "Fix login", None, &[])
1426 .await
1427 .unwrap();
1428
1429 assert_eq!(result.key, "PROJ-124");
1430 assert_eq!(result.id, "10042");
1431 assert!(result.self_url.contains("10042"));
1432 }
1433
1434 #[tokio::test]
1435 async fn create_issue_with_description_and_labels() {
1436 let server = wiremock::MockServer::start().await;
1437
1438 wiremock::Mock::given(wiremock::matchers::method("POST"))
1439 .and(wiremock::matchers::path("/rest/api/3/issue"))
1440 .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
1441 serde_json::json!({"key": "PROJ-125", "id": "10043", "self": "https://org.atlassian.net/rest/api/3/issue/10043"}),
1442 ))
1443 .expect(1)
1444 .mount(&server)
1445 .await;
1446
1447 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1448 let adf = ValidatedAdfDocument::empty();
1449 let labels = vec!["backend".to_string(), "urgent".to_string()];
1450 let result = client
1451 .create_issue("PROJ", "Task", "Add feature", Some(&adf), &labels)
1452 .await
1453 .unwrap();
1454
1455 assert_eq!(result.key, "PROJ-125");
1456 }
1457
1458 #[tokio::test]
1459 async fn create_issue_api_error() {
1460 let server = wiremock::MockServer::start().await;
1461
1462 wiremock::Mock::given(wiremock::matchers::method("POST"))
1463 .and(wiremock::matchers::path("/rest/api/3/issue"))
1464 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Project not found"))
1465 .expect(1)
1466 .mount(&server)
1467 .await;
1468
1469 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1470 let err = client
1471 .create_issue("NOPE", "Bug", "Test", None, &[])
1472 .await
1473 .unwrap_err();
1474 assert!(err.to_string().contains("400"));
1475 }
1476
1477 #[tokio::test]
1478 async fn create_issue_with_custom_fields_merges_into_payload() {
1479 let server = wiremock::MockServer::start().await;
1480
1481 wiremock::Mock::given(wiremock::matchers::method("POST"))
1482 .and(wiremock::matchers::path("/rest/api/3/issue"))
1483 .and(wiremock::matchers::body_json(serde_json::json!({
1484 "fields": {
1485 "project": {"key": "PROJ"},
1486 "issuetype": {"name": "Task"},
1487 "summary": "Test",
1488 "customfield_10001": {"value": "Unplanned"}
1489 }
1490 })))
1491 .respond_with(
1492 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
1493 "id": "100",
1494 "key": "PROJ-100",
1495 "self": "https://org.atlassian.net/rest/api/3/issue/100"
1496 })),
1497 )
1498 .expect(1)
1499 .mount(&server)
1500 .await;
1501
1502 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1503 let mut custom = std::collections::BTreeMap::new();
1504 custom.insert(
1505 "customfield_10001".to_string(),
1506 serde_json::json!({"value": "Unplanned"}),
1507 );
1508 let result = client
1509 .create_issue_with_custom_fields("PROJ", "Task", "Test", None, &[], &custom)
1510 .await
1511 .unwrap();
1512 assert_eq!(result.key, "PROJ-100");
1513 }
1514
1515 #[tokio::test]
1516 async fn create_issue_surfaces_adf_field_required_on_400() {
1517 let server = wiremock::MockServer::start().await;
1521 wiremock::Mock::given(wiremock::matchers::method("POST"))
1522 .and(wiremock::matchers::path("/rest/api/3/issue"))
1523 .respond_with(
1524 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1525 "errorMessages": [],
1526 "errors": {
1527 "description": "Operation value must be an Atlassian document."
1528 }
1529 })),
1530 )
1531 .mount(&server)
1532 .await;
1533
1534 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1535 let err = client
1536 .create_issue_with_custom_fields(
1537 "PROJ",
1538 "Task",
1539 "Test",
1540 None,
1541 &[],
1542 &std::collections::BTreeMap::new(),
1543 )
1544 .await
1545 .unwrap_err();
1546 let msg = err.to_string();
1547 assert!(msg.contains("description"), "got: {msg}");
1548 assert!(msg.contains("ADF"), "got: {msg}");
1549 }
1550
1551 #[tokio::test]
1552 async fn create_issue_shim_sends_no_custom_fields() {
1553 let server = wiremock::MockServer::start().await;
1554
1555 wiremock::Mock::given(wiremock::matchers::method("POST"))
1556 .and(wiremock::matchers::path("/rest/api/3/issue"))
1557 .and(wiremock::matchers::body_json(serde_json::json!({
1558 "fields": {
1559 "project": {"key": "PROJ"},
1560 "issuetype": {"name": "Task"},
1561 "summary": "Test"
1562 }
1563 })))
1564 .respond_with(
1565 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
1566 "id": "100",
1567 "key": "PROJ-100",
1568 "self": "https://org.atlassian.net/rest/api/3/issue/100"
1569 })),
1570 )
1571 .expect(1)
1572 .mount(&server)
1573 .await;
1574
1575 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1576 client
1577 .create_issue("PROJ", "Task", "Test", None, &[])
1578 .await
1579 .unwrap();
1580 }
1581
1582 #[tokio::test]
1583 async fn get_createmeta_parses_nested_fields() {
1584 let server = wiremock::MockServer::start().await;
1585
1586 wiremock::Mock::given(wiremock::matchers::method("GET"))
1587 .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1588 .and(wiremock::matchers::query_param("projectKeys", "PROJ"))
1589 .and(wiremock::matchers::query_param("issuetypeNames", "Task"))
1590 .and(wiremock::matchers::query_param(
1591 "expand",
1592 "projects.issuetypes.fields",
1593 ))
1594 .respond_with(
1595 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1596 "projects": [{
1597 "key": "PROJ",
1598 "issuetypes": [{
1599 "name": "Task",
1600 "fields": {
1601 "customfield_10001": {
1602 "name": "Planned / Unplanned Work",
1603 "schema": {
1604 "type": "option",
1605 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:select",
1606 "customId": 10001
1607 },
1608 "allowedValues": [
1609 {"value": "Planned", "id": "10100"},
1610 {"value": "Unplanned", "id": "10101"}
1611 ]
1612 }
1613 }
1614 }]
1615 }]
1616 })),
1617 )
1618 .expect(1)
1619 .mount(&server)
1620 .await;
1621
1622 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1623 let meta = client.get_createmeta("PROJ", "Task").await.unwrap();
1624 assert_eq!(meta.fields.len(), 1);
1625 let field = meta.fields.get("customfield_10001").unwrap();
1626 assert_eq!(field.name, "Planned / Unplanned Work");
1627 assert_eq!(field.schema.kind, "option");
1628 assert_eq!(field.allowed_values, vec!["Planned", "Unplanned"]);
1630 }
1631
1632 #[tokio::test]
1633 async fn get_createmeta_empty_projects_returns_empty_meta() {
1634 let server = wiremock::MockServer::start().await;
1635
1636 wiremock::Mock::given(wiremock::matchers::method("GET"))
1637 .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1638 .respond_with(
1639 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1640 "projects": []
1641 })),
1642 )
1643 .mount(&server)
1644 .await;
1645
1646 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1647 let meta = client.get_createmeta("PROJ", "Task").await.unwrap();
1648 assert!(meta.fields.is_empty());
1649 }
1650
1651 #[tokio::test]
1652 async fn get_createmeta_api_error_surfaces_status() {
1653 let server = wiremock::MockServer::start().await;
1654
1655 wiremock::Mock::given(wiremock::matchers::method("GET"))
1656 .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1657 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
1658 .mount(&server)
1659 .await;
1660
1661 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1662 let err = client.get_createmeta("NOPE", "Task").await.unwrap_err();
1663 assert!(err.to_string().contains("404"));
1664 }
1665
1666 #[tokio::test]
1667 async fn get_project_create_meta_parses_required_allowed_and_default() {
1668 let server = wiremock::MockServer::start().await;
1669
1670 wiremock::Mock::given(wiremock::matchers::method("GET"))
1671 .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1672 .respond_with(
1673 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1674 "projects": [{
1675 "issuetypes": [{
1676 "fields": {
1677 "summary": {
1678 "name": "Summary",
1679 "required": true,
1680 "schema": { "type": "string" }
1681 },
1682 "customfield_10001": {
1683 "name": "Work Type",
1684 "required": true,
1685 "schema": {
1686 "type": "option",
1687 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:select"
1688 },
1689 "defaultValue": { "id": "10100", "value": "Planned" },
1690 "allowedValues": [
1691 { "id": "10100", "value": "Planned" },
1692 { "id": "10101", "value": "Unplanned" }
1693 ]
1694 },
1695 "customfield_10002": {
1696 "name": "Region",
1697 "required": false,
1698 "schema": {
1699 "type": "option-with-child",
1700 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:cascadingselect"
1701 },
1702 "allowedValues": [
1703 {
1704 "id": "20000",
1705 "value": "APAC",
1706 "children": [
1707 { "id": "20001", "value": "AU" },
1708 { "id": "20002", "value": "NZ" }
1709 ]
1710 }
1711 ]
1712 },
1713 "labels": {
1714 "name": "Labels",
1715 "required": false,
1716 "schema": { "type": "array", "items": "string" }
1717 }
1718 }
1719 }]
1720 }]
1721 })),
1722 )
1723 .mount(&server)
1724 .await;
1725
1726 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1727 let meta = client
1728 .get_project_create_meta("PROJ", "Task")
1729 .await
1730 .unwrap();
1731
1732 assert_eq!(meta.project, "PROJ");
1733 assert_eq!(meta.issue_type, "Task");
1734 assert_eq!(meta.fields.len(), 4);
1735
1736 assert_eq!(meta.fields[0].field_id, "summary");
1738 assert!(meta.fields[0].required);
1739 assert_eq!(meta.fields[1].field_id, "customfield_10001");
1740 assert!(meta.fields[1].required);
1741 assert!(!meta.fields[2].required);
1743 assert_eq!(meta.fields[2].name, "Labels");
1744 assert!(!meta.fields[3].required);
1745 assert_eq!(meta.fields[3].name, "Region");
1746
1747 let work_type = &meta.fields[1];
1748 assert_eq!(work_type.schema_type, "option");
1749 assert_eq!(
1750 work_type.custom.as_deref(),
1751 Some("com.atlassian.jira.plugin.system.customfieldtypes:select")
1752 );
1753 assert_eq!(work_type.allowed_values.len(), 2);
1754 assert_eq!(
1755 work_type.allowed_values[0].value.as_deref(),
1756 Some("Planned")
1757 );
1758 assert!(work_type.default_value.is_some());
1759
1760 let labels = &meta.fields[2];
1762 assert_eq!(labels.schema_type, "array");
1763 assert_eq!(labels.items.as_deref(), Some("string"));
1764
1765 let region = &meta.fields[3];
1767 assert_eq!(region.allowed_values.len(), 1);
1768 assert_eq!(region.allowed_values[0].value.as_deref(), Some("APAC"));
1769 assert_eq!(region.allowed_values[0].children.len(), 2);
1770 assert_eq!(
1771 region.allowed_values[0].children[0].value.as_deref(),
1772 Some("AU")
1773 );
1774 }
1775
1776 #[tokio::test]
1777 async fn get_project_create_meta_empty_projects_returns_empty_fields() {
1778 let server = wiremock::MockServer::start().await;
1779
1780 wiremock::Mock::given(wiremock::matchers::method("GET"))
1781 .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1782 .respond_with(
1783 wiremock::ResponseTemplate::new(200)
1784 .set_body_json(serde_json::json!({ "projects": [] })),
1785 )
1786 .mount(&server)
1787 .await;
1788
1789 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1790 let meta = client
1791 .get_project_create_meta("PROJ", "Task")
1792 .await
1793 .unwrap();
1794 assert!(meta.fields.is_empty());
1795 assert_eq!(meta.project, "PROJ");
1796 }
1797
1798 #[tokio::test]
1799 async fn get_project_create_meta_api_error_surfaces_status() {
1800 let server = wiremock::MockServer::start().await;
1801
1802 wiremock::Mock::given(wiremock::matchers::method("GET"))
1803 .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1804 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1805 .mount(&server)
1806 .await;
1807
1808 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1809 let err = client
1810 .get_project_create_meta("NOPE", "Task")
1811 .await
1812 .unwrap_err();
1813 assert!(err.to_string().contains("403"));
1814 }
1815
1816 #[tokio::test]
1817 async fn get_comments_success() {
1818 let server = wiremock::MockServer::start().await;
1819
1820 wiremock::Mock::given(wiremock::matchers::method("GET"))
1821 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1822 .respond_with(
1823 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1824 "startAt": 0,
1825 "maxResults": 100,
1826 "total": 2,
1827 "comments": [
1828 {
1829 "id": "100",
1830 "author": {"displayName": "Alice"},
1831 "body": {"version": 1, "type": "doc", "content": []},
1832 "created": "2026-04-01T10:00:00.000+0000"
1833 },
1834 {
1835 "id": "101",
1836 "author": {"displayName": "Bob"},
1837 "body": null,
1838 "created": "2026-04-02T14:00:00.000+0000"
1839 }
1840 ]
1841 })),
1842 )
1843 .expect(1)
1844 .mount(&server)
1845 .await;
1846
1847 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1848 let comments = client.get_comments("PROJ-1", 0).await.unwrap();
1849
1850 assert_eq!(comments.len(), 2);
1851 assert_eq!(comments[0].id, "100");
1852 assert_eq!(comments[0].author, "Alice");
1853 assert!(comments[0].body_adf.is_some());
1854 assert!(comments[0].created.contains("2026-04-01"));
1855 assert_eq!(comments[1].id, "101");
1856 assert_eq!(comments[1].author, "Bob");
1857 assert!(comments[1].body_adf.is_none());
1858 }
1859
1860 #[tokio::test]
1861 async fn get_comments_empty() {
1862 let server = wiremock::MockServer::start().await;
1863
1864 wiremock::Mock::given(wiremock::matchers::method("GET"))
1865 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1866 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1867 serde_json::json!({"startAt": 0, "maxResults": 100, "total": 0, "comments": []}),
1868 ))
1869 .expect(1)
1870 .mount(&server)
1871 .await;
1872
1873 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1874 let comments = client.get_comments("PROJ-1", 0).await.unwrap();
1875 assert!(comments.is_empty());
1876 }
1877
1878 #[tokio::test]
1879 async fn get_comments_api_error() {
1880 let server = wiremock::MockServer::start().await;
1881
1882 wiremock::Mock::given(wiremock::matchers::method("GET"))
1883 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1/comment"))
1884 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1885 .expect(1)
1886 .mount(&server)
1887 .await;
1888
1889 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1890 let err = client.get_comments("NOPE-1", 0).await.unwrap_err();
1891 assert!(err.to_string().contains("404"));
1892 }
1893
1894 #[tokio::test]
1895 async fn get_comments_paginates_with_offset() {
1896 let server = wiremock::MockServer::start().await;
1897
1898 wiremock::Mock::given(wiremock::matchers::method("GET"))
1899 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1900 .and(wiremock::matchers::query_param("startAt", "0"))
1901 .respond_with(
1902 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1903 "startAt": 0,
1904 "maxResults": 2,
1905 "total": 3,
1906 "comments": [
1907 {"id": "1", "author": {"displayName": "A"}, "body": null, "created": "2026-04-01T10:00:00.000+0000"},
1908 {"id": "2", "author": {"displayName": "B"}, "body": null, "created": "2026-04-02T10:00:00.000+0000"}
1909 ]
1910 })),
1911 )
1912 .up_to_n_times(1)
1913 .mount(&server)
1914 .await;
1915
1916 wiremock::Mock::given(wiremock::matchers::method("GET"))
1917 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1918 .and(wiremock::matchers::query_param("startAt", "2"))
1919 .respond_with(
1920 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1921 "startAt": 2,
1922 "maxResults": 2,
1923 "total": 3,
1924 "comments": [
1925 {"id": "3", "author": {"displayName": "C"}, "body": null, "created": "2026-04-03T10:00:00.000+0000"}
1926 ]
1927 })),
1928 )
1929 .up_to_n_times(1)
1930 .mount(&server)
1931 .await;
1932
1933 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1934 let comments = client.get_comments("PROJ-1", 0).await.unwrap();
1935
1936 assert_eq!(comments.len(), 3);
1937 assert_eq!(comments[0].id, "1");
1938 assert_eq!(comments[1].id, "2");
1939 assert_eq!(comments[2].id, "3");
1940 }
1941
1942 #[tokio::test]
1943 async fn get_comments_respects_limit_single_page() {
1944 let server = wiremock::MockServer::start().await;
1945
1946 wiremock::Mock::given(wiremock::matchers::method("GET"))
1948 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1949 .and(wiremock::matchers::query_param("maxResults", "2"))
1950 .and(wiremock::matchers::query_param("startAt", "0"))
1951 .respond_with(
1952 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1953 "startAt": 0,
1954 "maxResults": 2,
1955 "total": 5,
1956 "comments": [
1957 {"id": "1", "author": {"displayName": "A"}, "body": null, "created": "2026-04-01T10:00:00.000+0000"},
1958 {"id": "2", "author": {"displayName": "B"}, "body": null, "created": "2026-04-02T10:00:00.000+0000"}
1959 ]
1960 })),
1961 )
1962 .expect(1)
1963 .mount(&server)
1964 .await;
1965
1966 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1967 let comments = client.get_comments("PROJ-1", 2).await.unwrap();
1968
1969 assert_eq!(comments.len(), 2);
1970 }
1971
1972 #[tokio::test]
1973 async fn add_comment_success() {
1974 let server = wiremock::MockServer::start().await;
1975
1976 wiremock::Mock::given(wiremock::matchers::method("POST"))
1977 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1978 .respond_with(
1979 wiremock::ResponseTemplate::new(201).set_body_json(
1980 serde_json::json!({"id": "200", "author": {"displayName": "Me"}}),
1981 ),
1982 )
1983 .expect(1)
1984 .mount(&server)
1985 .await;
1986
1987 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1988 let adf = ValidatedAdfDocument::empty();
1989 let result = client.add_comment("PROJ-1", &adf).await;
1990 assert!(result.is_ok());
1991 }
1992
1993 #[tokio::test]
1994 async fn add_comment_api_error() {
1995 let server = wiremock::MockServer::start().await;
1996
1997 wiremock::Mock::given(wiremock::matchers::method("POST"))
1998 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1999 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2000 .expect(1)
2001 .mount(&server)
2002 .await;
2003
2004 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2005 let adf = ValidatedAdfDocument::empty();
2006 let err = client.add_comment("PROJ-1", &adf).await.unwrap_err();
2007 assert!(err.to_string().contains("403"));
2008 }
2009
2010 #[tokio::test]
2011 async fn update_comment_success() {
2012 let server = wiremock::MockServer::start().await;
2013
2014 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2015 .and(wiremock::matchers::path(
2016 "/rest/api/3/issue/PROJ-1/comment/100",
2017 ))
2018 .respond_with(
2019 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2020 "id": "100",
2021 "author": {"displayName": "Me"},
2022 "created": "2026-04-01T10:00:00.000+0000",
2023 "updated": "2026-05-10T12:00:00.000+0000",
2024 "body": {"type": "doc", "version": 1, "content": []}
2025 })),
2026 )
2027 .expect(1)
2028 .mount(&server)
2029 .await;
2030
2031 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2032 let adf = ValidatedAdfDocument::empty();
2033 let comment = client
2034 .update_comment("PROJ-1", "100", &adf, None)
2035 .await
2036 .unwrap();
2037 assert_eq!(comment.id, "100");
2038 assert_eq!(comment.author, "Me");
2039 assert_eq!(
2040 comment.updated.as_deref(),
2041 Some("2026-05-10T12:00:00.000+0000")
2042 );
2043 }
2044
2045 #[tokio::test]
2046 async fn update_comment_sends_visibility() {
2047 let server = wiremock::MockServer::start().await;
2048
2049 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2050 .and(wiremock::matchers::path(
2051 "/rest/api/3/issue/PROJ-1/comment/100",
2052 ))
2053 .and(wiremock::matchers::body_partial_json(serde_json::json!({
2054 "visibility": {"type": "role", "identifier": "Administrators"}
2055 })))
2056 .respond_with(
2057 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2058 "id": "100",
2059 "author": {"displayName": "Me"},
2060 "created": "2026-04-01T10:00:00.000+0000",
2061 "updated": "2026-05-10T12:00:00.000+0000",
2062 "body": null
2063 })),
2064 )
2065 .expect(1)
2066 .mount(&server)
2067 .await;
2068
2069 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2070 let adf = ValidatedAdfDocument::empty();
2071 let visibility = JiraVisibility {
2072 ty: JiraVisibilityType::Role,
2073 value: "Administrators".to_string(),
2074 };
2075 client
2076 .update_comment("PROJ-1", "100", &adf, Some(&visibility))
2077 .await
2078 .unwrap();
2079 }
2080
2081 #[tokio::test]
2082 async fn update_comment_forbidden_surfaces_jira_message() {
2083 let server = wiremock::MockServer::start().await;
2084
2085 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2086 .and(wiremock::matchers::path(
2087 "/rest/api/3/issue/PROJ-1/comment/100",
2088 ))
2089 .respond_with(
2090 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
2091 "errorMessages": ["You do not have permission to edit this comment"],
2092 "errors": {}
2093 })),
2094 )
2095 .expect(1)
2096 .mount(&server)
2097 .await;
2098
2099 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2100 let adf = ValidatedAdfDocument::empty();
2101 let err = client
2102 .update_comment("PROJ-1", "100", &adf, None)
2103 .await
2104 .unwrap_err();
2105 let msg = err.to_string();
2106 assert!(msg.contains("403"));
2107 assert!(msg.contains("permission to edit"));
2108 }
2109
2110 #[tokio::test]
2111 async fn update_comment_not_found() {
2112 let server = wiremock::MockServer::start().await;
2113
2114 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2115 .and(wiremock::matchers::path(
2116 "/rest/api/3/issue/PROJ-1/comment/9999",
2117 ))
2118 .respond_with(
2119 wiremock::ResponseTemplate::new(404).set_body_json(serde_json::json!({
2120 "errorMessages": ["Comment not found"]
2121 })),
2122 )
2123 .expect(1)
2124 .mount(&server)
2125 .await;
2126
2127 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2128 let adf = ValidatedAdfDocument::empty();
2129 let err = client
2130 .update_comment("PROJ-1", "9999", &adf, None)
2131 .await
2132 .unwrap_err();
2133 let msg = err.to_string();
2134 assert!(msg.contains("404"));
2135 assert!(msg.contains("Comment not found"));
2136 }
2137
2138 #[tokio::test]
2139 async fn delete_comment_success() {
2140 let server = wiremock::MockServer::start().await;
2141
2142 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2143 .and(wiremock::matchers::path(
2144 "/rest/api/3/issue/PROJ-1/comment/10010",
2145 ))
2146 .respond_with(wiremock::ResponseTemplate::new(204))
2147 .expect(1)
2148 .mount(&server)
2149 .await;
2150
2151 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2152 assert!(client.delete_comment("PROJ-1", "10010").await.is_ok());
2153 }
2154
2155 #[tokio::test]
2156 async fn delete_comment_not_found() {
2157 let server = wiremock::MockServer::start().await;
2158
2159 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2160 .and(wiremock::matchers::path(
2161 "/rest/api/3/issue/PROJ-1/comment/9999",
2162 ))
2163 .respond_with(
2164 wiremock::ResponseTemplate::new(404).set_body_json(serde_json::json!({
2165 "errorMessages": ["Comment not found"]
2166 })),
2167 )
2168 .expect(1)
2169 .mount(&server)
2170 .await;
2171
2172 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2173 let err = client.delete_comment("PROJ-1", "9999").await.unwrap_err();
2174 let msg = err.to_string();
2175 assert!(msg.contains("404"));
2176 assert!(msg.contains("Comment not found"));
2177 }
2178
2179 #[tokio::test]
2180 async fn get_transitions_success() {
2181 let server = wiremock::MockServer::start().await;
2182
2183 wiremock::Mock::given(wiremock::matchers::method("GET"))
2184 .and(wiremock::matchers::path(
2185 "/rest/api/3/issue/PROJ-1/transitions",
2186 ))
2187 .respond_with(
2188 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2189 "transitions": [
2190 {"id": "11", "name": "In Progress"},
2191 {"id": "21", "name": "Done"},
2192 {"id": "31", "name": "Won't Do"}
2193 ]
2194 })),
2195 )
2196 .expect(1)
2197 .mount(&server)
2198 .await;
2199
2200 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2201 let transitions = client.get_transitions("PROJ-1").await.unwrap();
2202
2203 assert_eq!(transitions.len(), 3);
2204 assert_eq!(transitions[0].id, "11");
2205 assert_eq!(transitions[0].name, "In Progress");
2206 assert_eq!(transitions[1].id, "21");
2207 assert_eq!(transitions[2].name, "Won't Do");
2208 }
2209
2210 #[tokio::test]
2211 async fn get_transitions_empty() {
2212 let server = wiremock::MockServer::start().await;
2213
2214 wiremock::Mock::given(wiremock::matchers::method("GET"))
2215 .and(wiremock::matchers::path(
2216 "/rest/api/3/issue/PROJ-1/transitions",
2217 ))
2218 .respond_with(
2219 wiremock::ResponseTemplate::new(200)
2220 .set_body_json(serde_json::json!({"transitions": []})),
2221 )
2222 .expect(1)
2223 .mount(&server)
2224 .await;
2225
2226 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2227 let transitions = client.get_transitions("PROJ-1").await.unwrap();
2228 assert!(transitions.is_empty());
2229 }
2230
2231 #[tokio::test]
2232 async fn get_transitions_rich_fields() {
2233 let server = wiremock::MockServer::start().await;
2234
2235 wiremock::Mock::given(wiremock::matchers::method("GET"))
2236 .and(wiremock::matchers::path(
2237 "/rest/api/3/issue/PROJ-1/transitions",
2238 ))
2239 .respond_with(
2240 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2241 "transitions": [
2242 {
2243 "id": "21",
2244 "name": "In Progress",
2245 "hasScreen": false,
2246 "to": {
2247 "id": "3",
2248 "name": "In Progress",
2249 "statusCategory": {"key": "indeterminate"}
2250 }
2251 },
2252 {
2253 "id": "31",
2254 "name": "Done",
2255 "hasScreen": true,
2256 "to": {
2257 "id": "10000",
2258 "name": "Done",
2259 "statusCategory": {"key": "done"}
2260 }
2261 }
2262 ]
2263 })),
2264 )
2265 .expect(1)
2266 .mount(&server)
2267 .await;
2268
2269 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2270 let transitions = client.get_transitions("PROJ-1").await.unwrap();
2271
2272 assert_eq!(transitions.len(), 2);
2273 assert_eq!(transitions[0].id, "21");
2274 assert_eq!(transitions[0].has_screen, Some(false));
2275 let to0 = transitions[0].to_status.as_ref().unwrap();
2276 assert_eq!(to0.id, "3");
2277 assert_eq!(to0.name, "In Progress");
2278 assert_eq!(to0.category.as_deref(), Some("indeterminate"));
2279 assert_eq!(transitions[1].has_screen, Some(true));
2280 let to1 = transitions[1].to_status.as_ref().unwrap();
2281 assert_eq!(to1.category.as_deref(), Some("done"));
2282 }
2283
2284 #[tokio::test]
2285 async fn get_transitions_api_error() {
2286 let server = wiremock::MockServer::start().await;
2287
2288 wiremock::Mock::given(wiremock::matchers::method("GET"))
2289 .and(wiremock::matchers::path(
2290 "/rest/api/3/issue/NOPE-1/transitions",
2291 ))
2292 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2293 .expect(1)
2294 .mount(&server)
2295 .await;
2296
2297 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2298 let err = client.get_transitions("NOPE-1").await.unwrap_err();
2299 assert!(err.to_string().contains("404"));
2300 }
2301
2302 #[tokio::test]
2303 async fn do_transition_success() {
2304 let server = wiremock::MockServer::start().await;
2305
2306 wiremock::Mock::given(wiremock::matchers::method("POST"))
2307 .and(wiremock::matchers::path(
2308 "/rest/api/3/issue/PROJ-1/transitions",
2309 ))
2310 .respond_with(wiremock::ResponseTemplate::new(204))
2311 .expect(1)
2312 .mount(&server)
2313 .await;
2314
2315 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2316 let result = client.do_transition("PROJ-1", "21").await;
2317 assert!(result.is_ok());
2318 }
2319
2320 #[tokio::test]
2321 async fn do_transition_api_error() {
2322 let server = wiremock::MockServer::start().await;
2323
2324 wiremock::Mock::given(wiremock::matchers::method("POST"))
2325 .and(wiremock::matchers::path(
2326 "/rest/api/3/issue/PROJ-1/transitions",
2327 ))
2328 .respond_with(
2329 wiremock::ResponseTemplate::new(400).set_body_string("Invalid transition"),
2330 )
2331 .expect(1)
2332 .mount(&server)
2333 .await;
2334
2335 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2336 let err = client.do_transition("PROJ-1", "999").await.unwrap_err();
2337 assert!(err.to_string().contains("400"));
2338 }
2339
2340 #[tokio::test]
2341 async fn get_transitions_with_fields_parses_screen_metadata() {
2342 let server = wiremock::MockServer::start().await;
2343
2344 wiremock::Mock::given(wiremock::matchers::method("GET"))
2345 .and(wiremock::matchers::path(
2346 "/rest/api/3/issue/PROJ-1/transitions",
2347 ))
2348 .and(wiremock::matchers::query_param(
2349 "expand",
2350 "transitions.fields",
2351 ))
2352 .respond_with(
2353 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2354 "transitions": [
2355 {"id": "11", "name": "In Progress"},
2356 {
2357 "id": "21",
2358 "name": "Resolve",
2359 "hasScreen": true,
2360 "fields": {
2361 "resolution": {
2362 "name": "Resolution",
2363 "schema": {"type": "resolution"}
2364 },
2365 "comment": {
2366 "name": "Comment",
2367 "schema": {"type": "comment"}
2368 }
2369 }
2370 }
2371 ]
2372 })),
2373 )
2374 .expect(1)
2375 .mount(&server)
2376 .await;
2377
2378 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2379 let (transitions, metas) = client.get_transitions_with_fields("PROJ-1").await.unwrap();
2380
2381 assert_eq!(transitions.len(), 2);
2382 assert!(!metas.contains_key("11"));
2384 let resolve_meta = metas.get("21").unwrap();
2386 assert!(resolve_meta.fields.contains_key("resolution"));
2387 assert_eq!(
2388 resolve_meta.fields.get("comment").map(|f| f.name.as_str()),
2389 Some("Comment")
2390 );
2391 }
2392
2393 #[tokio::test]
2394 async fn do_transition_with_fields_posts_fields_and_comment() {
2395 let server = wiremock::MockServer::start().await;
2396
2397 let comment = crate::atlassian::adf_validated::markdown_to_validated_adf("done").unwrap();
2400 let comment_json = serde_json::to_value(&comment).unwrap();
2401 let expected = serde_json::json!({
2402 "transition": {"id": "21"},
2403 "fields": {"resolution": {"name": "Fixed"}},
2404 "update": { "comment": [ { "add": { "body": comment_json } } ] }
2405 });
2406
2407 wiremock::Mock::given(wiremock::matchers::method("POST"))
2408 .and(wiremock::matchers::path(
2409 "/rest/api/3/issue/PROJ-1/transitions",
2410 ))
2411 .and(wiremock::matchers::body_json(expected))
2412 .respond_with(wiremock::ResponseTemplate::new(204))
2413 .expect(1)
2414 .mount(&server)
2415 .await;
2416
2417 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2418 let mut fields = std::collections::BTreeMap::new();
2419 fields.insert(
2420 "resolution".to_string(),
2421 serde_json::json!({ "name": "Fixed" }),
2422 );
2423 let result = client
2424 .do_transition_with_fields("PROJ-1", "21", &fields, Some(&comment))
2425 .await;
2426 assert!(result.is_ok(), "{result:?}");
2427 }
2428
2429 #[tokio::test]
2430 async fn do_transition_with_fields_bare_body_when_empty() {
2431 let server = wiremock::MockServer::start().await;
2432
2433 wiremock::Mock::given(wiremock::matchers::method("POST"))
2434 .and(wiremock::matchers::path(
2435 "/rest/api/3/issue/PROJ-1/transitions",
2436 ))
2437 .and(wiremock::matchers::body_json(serde_json::json!({
2438 "transition": {"id": "21"}
2439 })))
2440 .respond_with(wiremock::ResponseTemplate::new(204))
2441 .expect(1)
2442 .mount(&server)
2443 .await;
2444
2445 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2446 let result = client
2447 .do_transition_with_fields("PROJ-1", "21", &std::collections::BTreeMap::new(), None)
2448 .await;
2449 assert!(result.is_ok(), "{result:?}");
2450 }
2451
2452 #[tokio::test]
2453 async fn search_confluence_success() {
2454 let server = wiremock::MockServer::start().await;
2455
2456 wiremock::Mock::given(wiremock::matchers::method("GET"))
2457 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2458 .respond_with(
2459 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2460 "results": [
2461 {
2462 "id": "12345",
2463 "title": "Architecture Overview",
2464 "_expandable": {"space": "/wiki/rest/api/space/ENG"}
2465 },
2466 {
2467 "id": "67890",
2468 "title": "Getting Started",
2469 "_expandable": {"space": "/wiki/rest/api/space/DOC"}
2470 }
2471 ],
2472 "size": 2
2473 })),
2474 )
2475 .expect(1)
2476 .mount(&server)
2477 .await;
2478
2479 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2480 let result = client.search_confluence("type = page", 25).await.unwrap();
2481
2482 assert_eq!(result.total, 2);
2483 assert_eq!(result.results.len(), 2);
2484 assert_eq!(result.results[0].id, "12345");
2485 assert_eq!(result.results[0].title, "Architecture Overview");
2486 assert_eq!(result.results[0].space_key, "ENG");
2487 assert_eq!(result.results[1].space_key, "DOC");
2488 }
2489
2490 #[tokio::test]
2491 async fn search_confluence_empty() {
2492 let server = wiremock::MockServer::start().await;
2493
2494 wiremock::Mock::given(wiremock::matchers::method("GET"))
2495 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2496 .respond_with(
2497 wiremock::ResponseTemplate::new(200)
2498 .set_body_json(serde_json::json!({"results": [], "size": 0})),
2499 )
2500 .expect(1)
2501 .mount(&server)
2502 .await;
2503
2504 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2505 let result = client
2506 .search_confluence("title = \"Nonexistent\"", 25)
2507 .await
2508 .unwrap();
2509 assert_eq!(result.total, 0);
2510 assert!(result.results.is_empty());
2511 }
2512
2513 #[tokio::test]
2514 async fn search_confluence_api_error() {
2515 let server = wiremock::MockServer::start().await;
2516
2517 wiremock::Mock::given(wiremock::matchers::method("GET"))
2518 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2519 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid CQL"))
2520 .expect(1)
2521 .mount(&server)
2522 .await;
2523
2524 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2525 let err = client
2526 .search_confluence("bad cql !!!", 25)
2527 .await
2528 .unwrap_err();
2529 assert!(err.to_string().contains("400"));
2530 }
2531
2532 #[tokio::test]
2533 async fn search_confluence_missing_space() {
2534 let server = wiremock::MockServer::start().await;
2535
2536 wiremock::Mock::given(wiremock::matchers::method("GET"))
2537 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2538 .respond_with(
2539 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2540 "results": [{"id": "111", "title": "No Space"}],
2541 "size": 1
2542 })),
2543 )
2544 .expect(1)
2545 .mount(&server)
2546 .await;
2547
2548 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2549 let result = client.search_confluence("type = page", 10).await.unwrap();
2550 assert_eq!(result.results[0].space_key, "");
2551 }
2552
2553 #[tokio::test]
2556 async fn search_jira_users_returns_decoded_results() {
2557 let server = wiremock::MockServer::start().await;
2558 wiremock::Mock::given(wiremock::matchers::method("GET"))
2559 .and(wiremock::matchers::path("/rest/api/3/user/search"))
2560 .and(wiremock::matchers::query_param("query", "alice"))
2561 .respond_with(
2562 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
2563 {
2564 "accountId": "abc123",
2565 "displayName": "Alice Smith",
2566 "emailAddress": "alice@example.com",
2567 "active": true,
2568 "accountType": "atlassian"
2569 },
2570 {
2571 "accountId": "def456",
2572 "displayName": "Alice Jones",
2573 "active": true,
2574 "accountType": "atlassian"
2575 }
2576 ])),
2577 )
2578 .expect(1)
2579 .mount(&server)
2580 .await;
2581
2582 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2583 let result = client.search_jira_users("alice", 25).await.unwrap();
2584 assert_eq!(result.count, 2);
2585 assert_eq!(result.users[0].account_id, "abc123");
2586 assert_eq!(result.users[0].display_name.as_deref(), Some("Alice Smith"));
2587 assert_eq!(
2588 result.users[0].email_address.as_deref(),
2589 Some("alice@example.com")
2590 );
2591 assert!(result.users[0].active);
2592 assert!(result.users[1].email_address.is_none());
2594 }
2595
2596 #[tokio::test]
2597 async fn search_jira_users_empty_returns_empty_list() {
2598 let server = wiremock::MockServer::start().await;
2599 wiremock::Mock::given(wiremock::matchers::method("GET"))
2600 .and(wiremock::matchers::path("/rest/api/3/user/search"))
2601 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2602 .expect(1)
2603 .mount(&server)
2604 .await;
2605 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2606 let result = client.search_jira_users("nobody", 25).await.unwrap();
2607 assert_eq!(result.count, 0);
2608 assert!(result.users.is_empty());
2609 }
2610
2611 #[tokio::test]
2612 async fn search_jira_users_truncates_at_limit() {
2613 let server = wiremock::MockServer::start().await;
2614 let users_page_1 = serde_json::json!([
2615 {"accountId": "u1", "displayName": "U1", "active": true, "accountType": "atlassian"},
2616 {"accountId": "u2", "displayName": "U2", "active": true, "accountType": "atlassian"}
2617 ]);
2618
2619 wiremock::Mock::given(wiremock::matchers::method("GET"))
2621 .and(wiremock::matchers::path("/rest/api/3/user/search"))
2622 .and(wiremock::matchers::query_param("startAt", "0"))
2623 .and(wiremock::matchers::query_param("maxResults", "2"))
2624 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&users_page_1))
2625 .expect(1)
2626 .mount(&server)
2627 .await;
2628
2629 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2630 let result = client.search_jira_users("u", 2).await.unwrap();
2631 assert_eq!(result.count, 2);
2632 }
2633
2634 #[tokio::test]
2635 async fn search_jira_users_unlimited_paginates_to_completion() {
2636 let server = wiremock::MockServer::start().await;
2637
2638 let full_page: Vec<serde_json::Value> = (0..100)
2640 .map(|i| {
2641 serde_json::json!({
2642 "accountId": format!("u{i}"),
2643 "displayName": format!("User {i}"),
2644 "active": true,
2645 "accountType": "atlassian"
2646 })
2647 })
2648 .collect();
2649 let short_page: Vec<serde_json::Value> = (100..103)
2650 .map(|i| {
2651 serde_json::json!({
2652 "accountId": format!("u{i}"),
2653 "displayName": format!("User {i}"),
2654 "active": true,
2655 "accountType": "atlassian"
2656 })
2657 })
2658 .collect();
2659
2660 wiremock::Mock::given(wiremock::matchers::method("GET"))
2661 .and(wiremock::matchers::path("/rest/api/3/user/search"))
2662 .and(wiremock::matchers::query_param("startAt", "0"))
2663 .respond_with(
2664 wiremock::ResponseTemplate::new(200)
2665 .set_body_json(serde_json::Value::Array(full_page)),
2666 )
2667 .expect(1)
2668 .mount(&server)
2669 .await;
2670
2671 wiremock::Mock::given(wiremock::matchers::method("GET"))
2672 .and(wiremock::matchers::path("/rest/api/3/user/search"))
2673 .and(wiremock::matchers::query_param("startAt", "100"))
2674 .respond_with(
2675 wiremock::ResponseTemplate::new(200)
2676 .set_body_json(serde_json::Value::Array(short_page)),
2677 )
2678 .expect(1)
2679 .mount(&server)
2680 .await;
2681
2682 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2683 let result = client.search_jira_users("u", 0).await.unwrap();
2684 assert_eq!(result.count, 103);
2685 }
2686
2687 #[tokio::test]
2688 async fn search_jira_users_propagates_403() {
2689 let server = wiremock::MockServer::start().await;
2690 wiremock::Mock::given(wiremock::matchers::method("GET"))
2691 .and(wiremock::matchers::path("/rest/api/3/user/search"))
2692 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2693 .expect(1)
2694 .mount(&server)
2695 .await;
2696
2697 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2698 let err = client.search_jira_users("alice", 25).await.unwrap_err();
2699 assert!(err.to_string().contains("403"));
2700 }
2701
2702 #[tokio::test]
2703 async fn search_jira_users_inactive_user_passes_through() {
2704 let server = wiremock::MockServer::start().await;
2705 wiremock::Mock::given(wiremock::matchers::method("GET"))
2706 .and(wiremock::matchers::path("/rest/api/3/user/search"))
2707 .respond_with(
2708 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
2709 {
2710 "accountId": "old1",
2711 "displayName": "Former Employee",
2712 "active": false,
2713 "accountType": "atlassian"
2714 }
2715 ])),
2716 )
2717 .expect(1)
2718 .mount(&server)
2719 .await;
2720 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2721 let result = client.search_jira_users("former", 25).await.unwrap();
2722 assert_eq!(result.count, 1);
2723 assert!(!result.users[0].active);
2724 }
2725
2726 #[tokio::test]
2729 async fn search_confluence_users_success() {
2730 let server = wiremock::MockServer::start().await;
2731
2732 wiremock::Mock::given(wiremock::matchers::method("GET"))
2733 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2734 .respond_with(
2735 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2736 "results": [
2737 {
2738 "user": {
2739 "accountId": "abc123",
2740 "displayName": "Alice Smith",
2741 "email": "alice@example.com"
2742 },
2743 "entityType": "user"
2744 },
2745 {
2746 "user": {
2747 "accountId": "def456",
2748 "displayName": "Bob Jones",
2749 "email": "bob@example.com"
2750 },
2751 "entityType": "user"
2752 }
2753 ]
2754 })),
2755 )
2756 .expect(1)
2757 .mount(&server)
2758 .await;
2759
2760 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2761 let result = client.search_confluence_users("alice", 25).await.unwrap();
2762
2763 assert_eq!(result.total, 2);
2764 assert_eq!(result.users.len(), 2);
2765 assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
2766 assert_eq!(result.users[0].display_name, "Alice Smith");
2767 assert_eq!(result.users[0].email.as_deref(), Some("alice@example.com"));
2768 assert_eq!(result.users[1].account_id.as_deref(), Some("def456"));
2769 assert_eq!(result.users[1].display_name, "Bob Jones");
2770 }
2771
2772 #[tokio::test]
2773 async fn search_confluence_users_empty() {
2774 let server = wiremock::MockServer::start().await;
2775
2776 wiremock::Mock::given(wiremock::matchers::method("GET"))
2777 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2778 .respond_with(
2779 wiremock::ResponseTemplate::new(200)
2780 .set_body_json(serde_json::json!({"results": []})),
2781 )
2782 .expect(1)
2783 .mount(&server)
2784 .await;
2785
2786 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2787 let result = client
2788 .search_confluence_users("nonexistent", 25)
2789 .await
2790 .unwrap();
2791 assert_eq!(result.total, 0);
2792 assert!(result.users.is_empty());
2793 }
2794
2795 #[tokio::test]
2796 async fn search_confluence_users_api_error() {
2797 let server = wiremock::MockServer::start().await;
2798
2799 wiremock::Mock::given(wiremock::matchers::method("GET"))
2800 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2801 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2802 .expect(1)
2803 .mount(&server)
2804 .await;
2805
2806 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2807 let err = client
2808 .search_confluence_users("alice", 25)
2809 .await
2810 .unwrap_err();
2811 assert!(err.to_string().contains("403"));
2812 }
2813
2814 #[tokio::test]
2815 async fn search_confluence_users_missing_email() {
2816 let server = wiremock::MockServer::start().await;
2817
2818 wiremock::Mock::given(wiremock::matchers::method("GET"))
2819 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2820 .respond_with(
2821 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2822 "results": [
2823 {
2824 "user": {
2825 "accountId": "xyz789",
2826 "displayName": "No Email User"
2827 }
2828 }
2829 ]
2830 })),
2831 )
2832 .expect(1)
2833 .mount(&server)
2834 .await;
2835
2836 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2837 let result = client
2838 .search_confluence_users("no email", 25)
2839 .await
2840 .unwrap();
2841 assert_eq!(result.users.len(), 1);
2842 assert_eq!(result.users[0].display_name, "No Email User");
2843 assert!(result.users[0].email.is_none());
2844 }
2845
2846 #[tokio::test]
2847 async fn search_confluence_users_missing_account_id() {
2848 let server = wiremock::MockServer::start().await;
2852
2853 wiremock::Mock::given(wiremock::matchers::method("GET"))
2854 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2855 .respond_with(
2856 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2857 "results": [
2858 {
2859 "user": {
2860 "accountId": "abc123",
2861 "displayName": "Alice Smith",
2862 "email": "alice@example.com"
2863 }
2864 },
2865 {
2866 "user": {
2867 "displayName": "App Bot",
2868 "accountType": "app"
2869 }
2870 }
2871 ]
2872 })),
2873 )
2874 .expect(1)
2875 .mount(&server)
2876 .await;
2877
2878 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2879 let result = client.search_confluence_users("any", 25).await.unwrap();
2880 assert_eq!(result.users.len(), 2);
2881 assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
2882 assert!(result.users[1].account_id.is_none());
2883 assert_eq!(result.users[1].display_name, "App Bot");
2884 }
2885
2886 #[tokio::test]
2887 async fn search_confluence_users_uses_public_name_when_no_display_name() {
2888 let server = wiremock::MockServer::start().await;
2889
2890 wiremock::Mock::given(wiremock::matchers::method("GET"))
2891 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2892 .respond_with(
2893 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2894 "results": [
2895 {
2896 "user": {
2897 "accountId": "abc123",
2898 "publicName": "alice.smith"
2899 }
2900 }
2901 ]
2902 })),
2903 )
2904 .expect(1)
2905 .mount(&server)
2906 .await;
2907
2908 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2909 let result = client.search_confluence_users("alice", 25).await.unwrap();
2910 assert_eq!(result.users.len(), 1);
2911 assert_eq!(result.users[0].display_name, "alice.smith");
2912 }
2913
2914 #[tokio::test]
2915 async fn search_confluence_users_skips_entries_without_user() {
2916 let server = wiremock::MockServer::start().await;
2919
2920 wiremock::Mock::given(wiremock::matchers::method("GET"))
2921 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2922 .respond_with(
2923 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2924 "results": [
2925 {"title": "Not a user", "entityType": "content"},
2926 {
2927 "user": {
2928 "accountId": "abc123",
2929 "displayName": "Alice Smith"
2930 }
2931 }
2932 ]
2933 })),
2934 )
2935 .expect(1)
2936 .mount(&server)
2937 .await;
2938
2939 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2940 let result = client.search_confluence_users("alice", 25).await.unwrap();
2941 assert_eq!(result.users.len(), 1);
2942 assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
2943 }
2944
2945 #[tokio::test]
2946 async fn search_confluence_users_pagination() {
2947 let server = wiremock::MockServer::start().await;
2948
2949 wiremock::Mock::given(wiremock::matchers::method("GET"))
2951 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2952 .and(wiremock::matchers::query_param("start", "0"))
2953 .respond_with(
2954 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2955 "results": [
2956 {
2957 "user": {
2958 "accountId": "page1",
2959 "displayName": "User One"
2960 }
2961 }
2962 ],
2963 "_links": {"next": "/wiki/rest/api/search/user?start=1"}
2964 })),
2965 )
2966 .expect(1)
2967 .mount(&server)
2968 .await;
2969
2970 wiremock::Mock::given(wiremock::matchers::method("GET"))
2972 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2973 .and(wiremock::matchers::query_param("start", "1"))
2974 .respond_with(
2975 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2976 "results": [
2977 {
2978 "user": {
2979 "accountId": "page2",
2980 "displayName": "User Two"
2981 }
2982 }
2983 ]
2984 })),
2985 )
2986 .expect(1)
2987 .mount(&server)
2988 .await;
2989
2990 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2991 let result = client.search_confluence_users("user", 0).await.unwrap();
2992
2993 assert_eq!(result.total, 2);
2994 assert_eq!(result.users[0].account_id.as_deref(), Some("page1"));
2995 assert_eq!(result.users[1].account_id.as_deref(), Some("page2"));
2996 }
2997
2998 #[tokio::test]
2999 async fn get_boards_success() {
3000 let server = wiremock::MockServer::start().await;
3001
3002 wiremock::Mock::given(wiremock::matchers::method("GET"))
3003 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3004 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3005 serde_json::json!({
3006 "values": [
3007 {"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}},
3008 {"id": 2, "name": "Kanban", "type": "kanban"}
3009 ],
3010 "total": 2, "isLast": true
3011 }),
3012 ))
3013 .expect(1)
3014 .mount(&server)
3015 .await;
3016
3017 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3018 let result = client.get_boards(None, None, 50).await.unwrap();
3019
3020 assert_eq!(result.total, 2);
3021 assert_eq!(result.boards.len(), 2);
3022 assert_eq!(result.boards[0].id, 1);
3023 assert_eq!(result.boards[0].name, "PROJ Board");
3024 assert_eq!(result.boards[0].board_type, "scrum");
3025 assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
3026 assert!(result.boards[1].project_key.is_none());
3027 }
3028
3029 #[tokio::test]
3030 async fn get_boards_with_filters() {
3031 let server = wiremock::MockServer::start().await;
3032
3033 wiremock::Mock::given(wiremock::matchers::method("GET"))
3034 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3035 .and(wiremock::matchers::query_param("projectKeyOrId", "PROJ"))
3036 .and(wiremock::matchers::query_param("type", "scrum"))
3037 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3038 serde_json::json!({
3039 "values": [{"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}}],
3040 "total": 1, "isLast": true
3041 }),
3042 ))
3043 .expect(1)
3044 .mount(&server)
3045 .await;
3046
3047 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3048 let result = client
3049 .get_boards(Some("PROJ"), Some("scrum"), 50)
3050 .await
3051 .unwrap();
3052
3053 assert_eq!(result.boards.len(), 1);
3054 assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
3055 }
3056
3057 #[tokio::test]
3058 async fn search_issues_paginates_with_token() {
3059 let server = wiremock::MockServer::start().await;
3060
3061 wiremock::Mock::given(wiremock::matchers::method("POST"))
3063 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
3064 .and(wiremock::matchers::body_partial_json(serde_json::json!({"jql": "project = PROJ"})))
3065 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3066 serde_json::json!({
3067 "issues": [{"key": "PROJ-1", "fields": {"summary": "First", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}],
3068 "nextPageToken": "token123"
3069 }),
3070 ))
3071 .up_to_n_times(1)
3072 .mount(&server)
3073 .await;
3074
3075 wiremock::Mock::given(wiremock::matchers::method("POST"))
3077 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
3078 .and(wiremock::matchers::body_partial_json(serde_json::json!({"nextPageToken": "token123"})))
3079 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3080 serde_json::json!({
3081 "issues": [{"key": "PROJ-2", "fields": {"summary": "Second", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}]
3082 }),
3083 ))
3084 .up_to_n_times(1)
3085 .mount(&server)
3086 .await;
3087
3088 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3089 let result = client.search_issues("project = PROJ", 0).await.unwrap();
3090
3091 assert_eq!(result.issues.len(), 2);
3092 assert_eq!(result.issues[0].key, "PROJ-1");
3093 assert_eq!(result.issues[1].key, "PROJ-2");
3094 }
3095
3096 #[tokio::test]
3097 async fn search_issues_respects_limit() {
3098 let server = wiremock::MockServer::start().await;
3099
3100 wiremock::Mock::given(wiremock::matchers::method("POST"))
3101 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
3102 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3103 serde_json::json!({
3104 "issues": [
3105 {"key": "PROJ-1", "fields": {"summary": "A", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}},
3106 {"key": "PROJ-2", "fields": {"summary": "B", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}
3107 ],
3108 "nextPageToken": "more"
3109 }),
3110 ))
3111 .up_to_n_times(1)
3112 .mount(&server)
3113 .await;
3114
3115 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3116 let result = client.search_issues("project = PROJ", 2).await.unwrap();
3118 assert_eq!(result.issues.len(), 2);
3119 }
3120
3121 #[tokio::test]
3122 async fn get_boards_paginates_with_offset() {
3123 let server = wiremock::MockServer::start().await;
3124
3125 wiremock::Mock::given(wiremock::matchers::method("GET"))
3127 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3128 .and(wiremock::matchers::query_param("startAt", "0"))
3129 .respond_with(
3130 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3131 "values": [{"id": 1, "name": "Board 1", "type": "scrum"}],
3132 "total": 2, "isLast": false
3133 })),
3134 )
3135 .up_to_n_times(1)
3136 .mount(&server)
3137 .await;
3138
3139 wiremock::Mock::given(wiremock::matchers::method("GET"))
3141 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3142 .and(wiremock::matchers::query_param("startAt", "1"))
3143 .respond_with(
3144 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3145 "values": [{"id": 2, "name": "Board 2", "type": "kanban"}],
3146 "total": 2, "isLast": true
3147 })),
3148 )
3149 .up_to_n_times(1)
3150 .mount(&server)
3151 .await;
3152
3153 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3154 let result = client.get_boards(None, None, 0).await.unwrap();
3155
3156 assert_eq!(result.boards.len(), 2);
3157 assert_eq!(result.boards[0].name, "Board 1");
3158 assert_eq!(result.boards[1].name, "Board 2");
3159 }
3160
3161 #[tokio::test]
3162 async fn get_boards_empty() {
3163 let server = wiremock::MockServer::start().await;
3164
3165 wiremock::Mock::given(wiremock::matchers::method("GET"))
3166 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3167 .respond_with(
3168 wiremock::ResponseTemplate::new(200)
3169 .set_body_json(serde_json::json!({"values": [], "total": 0})),
3170 )
3171 .expect(1)
3172 .mount(&server)
3173 .await;
3174
3175 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3176 let result = client.get_boards(None, None, 50).await.unwrap();
3177 assert!(result.boards.is_empty());
3178 }
3179
3180 #[tokio::test]
3181 async fn get_boards_api_error() {
3182 let server = wiremock::MockServer::start().await;
3183
3184 wiremock::Mock::given(wiremock::matchers::method("GET"))
3185 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3186 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
3187 .expect(1)
3188 .mount(&server)
3189 .await;
3190
3191 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3192 let err = client.get_boards(None, None, 50).await.unwrap_err();
3193 assert!(err.to_string().contains("401"));
3194 }
3195
3196 #[tokio::test]
3197 async fn get_board_issues_success() {
3198 let server = wiremock::MockServer::start().await;
3199
3200 wiremock::Mock::given(wiremock::matchers::method("GET"))
3201 .and(wiremock::matchers::path("/rest/agile/1.0/board/1/issue"))
3202 .respond_with(
3203 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3204 "issues": [{
3205 "key": "PROJ-1",
3206 "fields": {
3207 "summary": "Board issue",
3208 "description": null,
3209 "status": {"name": "Open"},
3210 "issuetype": {"name": "Task"},
3211 "assignee": null,
3212 "priority": null,
3213 "labels": []
3214 }
3215 }],
3216 "total": 1, "isLast": true
3217 })),
3218 )
3219 .expect(1)
3220 .mount(&server)
3221 .await;
3222
3223 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3224 let result = client.get_board_issues(1, None, 50).await.unwrap();
3225
3226 assert_eq!(result.total, 1);
3227 assert_eq!(result.issues[0].key, "PROJ-1");
3228 assert_eq!(result.issues[0].summary, "Board issue");
3229 }
3230
3231 #[tokio::test]
3232 async fn get_board_issues_api_error() {
3233 let server = wiremock::MockServer::start().await;
3234
3235 wiremock::Mock::given(wiremock::matchers::method("GET"))
3236 .and(wiremock::matchers::path("/rest/agile/1.0/board/999/issue"))
3237 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3238 .expect(1)
3239 .mount(&server)
3240 .await;
3241
3242 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3243 let err = client.get_board_issues(999, None, 50).await.unwrap_err();
3244 assert!(err.to_string().contains("404"));
3245 }
3246
3247 #[tokio::test]
3248 async fn get_sprints_success() {
3249 let server = wiremock::MockServer::start().await;
3250
3251 wiremock::Mock::given(wiremock::matchers::method("GET"))
3252 .and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
3253 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3254 serde_json::json!({
3255 "values": [
3256 {"id": 10, "name": "Sprint 1", "state": "closed", "startDate": "2026-03-01", "endDate": "2026-03-14", "goal": "MVP"},
3257 {"id": 11, "name": "Sprint 2", "state": "active", "startDate": "2026-03-15", "endDate": "2026-03-28"}
3258 ],
3259 "total": 2, "isLast": true
3260 }),
3261 ))
3262 .expect(1)
3263 .mount(&server)
3264 .await;
3265
3266 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3267 let result = client.get_sprints(1, None, 50).await.unwrap();
3268
3269 assert_eq!(result.total, 2);
3270 assert_eq!(result.sprints.len(), 2);
3271 assert_eq!(result.sprints[0].id, 10);
3272 assert_eq!(result.sprints[0].name, "Sprint 1");
3273 assert_eq!(result.sprints[0].state, "closed");
3274 assert_eq!(result.sprints[0].goal.as_deref(), Some("MVP"));
3275 assert!(result.sprints[1].goal.is_none());
3276 }
3277
3278 #[tokio::test]
3279 async fn get_sprints_with_state_filter() {
3280 let server = wiremock::MockServer::start().await;
3281
3282 wiremock::Mock::given(wiremock::matchers::method("GET"))
3283 .and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
3284 .and(wiremock::matchers::query_param("state", "active"))
3285 .respond_with(
3286 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3287 "values": [{"id": 11, "name": "Sprint 2", "state": "active"}],
3288 "total": 1, "isLast": true
3289 })),
3290 )
3291 .expect(1)
3292 .mount(&server)
3293 .await;
3294
3295 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3296 let result = client.get_sprints(1, Some("active"), 50).await.unwrap();
3297 assert_eq!(result.sprints.len(), 1);
3298 assert_eq!(result.sprints[0].state, "active");
3299 }
3300
3301 #[tokio::test]
3302 async fn get_sprints_api_error() {
3303 let server = wiremock::MockServer::start().await;
3304
3305 wiremock::Mock::given(wiremock::matchers::method("GET"))
3306 .and(wiremock::matchers::path("/rest/agile/1.0/board/999/sprint"))
3307 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3308 .expect(1)
3309 .mount(&server)
3310 .await;
3311
3312 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3313 let err = client.get_sprints(999, None, 50).await.unwrap_err();
3314 assert!(err.to_string().contains("404"));
3315 }
3316
3317 #[tokio::test]
3318 async fn get_sprint_issues_success() {
3319 let server = wiremock::MockServer::start().await;
3320
3321 wiremock::Mock::given(wiremock::matchers::method("GET"))
3322 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
3323 .respond_with(
3324 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3325 "issues": [{
3326 "key": "PROJ-1",
3327 "fields": {
3328 "summary": "Sprint issue",
3329 "description": null,
3330 "status": {"name": "In Progress"},
3331 "issuetype": {"name": "Story"},
3332 "assignee": {"displayName": "Alice"},
3333 "priority": null,
3334 "labels": []
3335 }
3336 }],
3337 "total": 1, "isLast": true
3338 })),
3339 )
3340 .expect(1)
3341 .mount(&server)
3342 .await;
3343
3344 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3345 let result = client.get_sprint_issues(10, None, 50).await.unwrap();
3346
3347 assert_eq!(result.total, 1);
3348 assert_eq!(result.issues[0].key, "PROJ-1");
3349 assert_eq!(result.issues[0].assignee.as_deref(), Some("Alice"));
3350 }
3351
3352 #[tokio::test]
3353 async fn get_sprint_issues_api_error() {
3354 let server = wiremock::MockServer::start().await;
3355
3356 wiremock::Mock::given(wiremock::matchers::method("GET"))
3357 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
3358 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3359 .expect(1)
3360 .mount(&server)
3361 .await;
3362
3363 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3364 let err = client.get_sprint_issues(999, None, 50).await.unwrap_err();
3365 assert!(err.to_string().contains("404"));
3366 }
3367
3368 #[tokio::test]
3369 async fn add_issues_to_sprint_success() {
3370 let server = wiremock::MockServer::start().await;
3371
3372 wiremock::Mock::given(wiremock::matchers::method("POST"))
3373 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
3374 .respond_with(wiremock::ResponseTemplate::new(204))
3375 .expect(1)
3376 .mount(&server)
3377 .await;
3378
3379 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3380 let result = client.add_issues_to_sprint(10, &["PROJ-1", "PROJ-2"]).await;
3381 assert!(result.is_ok());
3382 }
3383
3384 #[tokio::test]
3385 async fn add_issues_to_sprint_api_error() {
3386 let server = wiremock::MockServer::start().await;
3387
3388 wiremock::Mock::given(wiremock::matchers::method("POST"))
3389 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
3390 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
3391 .expect(1)
3392 .mount(&server)
3393 .await;
3394
3395 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3396 let err = client
3397 .add_issues_to_sprint(999, &["NOPE-1"])
3398 .await
3399 .unwrap_err();
3400 assert!(err.to_string().contains("400"));
3401 }
3402
3403 #[tokio::test]
3404 async fn create_sprint_success() {
3405 let server = wiremock::MockServer::start().await;
3406
3407 wiremock::Mock::given(wiremock::matchers::method("POST"))
3408 .and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
3409 .respond_with(
3410 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
3411 "id": 42,
3412 "name": "Sprint 5",
3413 "state": "future",
3414 "startDate": "2026-05-01",
3415 "endDate": "2026-05-14",
3416 "goal": "Ship v2"
3417 })),
3418 )
3419 .expect(1)
3420 .mount(&server)
3421 .await;
3422
3423 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3424 let sprint = client
3425 .create_sprint(
3426 1,
3427 "Sprint 5",
3428 Some("2026-05-01"),
3429 Some("2026-05-14"),
3430 Some("Ship v2"),
3431 )
3432 .await
3433 .unwrap();
3434
3435 assert_eq!(sprint.id, 42);
3436 assert_eq!(sprint.name, "Sprint 5");
3437 assert_eq!(sprint.state, "future");
3438 assert_eq!(sprint.goal.as_deref(), Some("Ship v2"));
3439 }
3440
3441 #[tokio::test]
3442 async fn create_sprint_minimal() {
3443 let server = wiremock::MockServer::start().await;
3444
3445 wiremock::Mock::given(wiremock::matchers::method("POST"))
3446 .and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
3447 .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
3448 serde_json::json!({"id": 43, "name": "Sprint 6", "state": "future"}),
3449 ))
3450 .expect(1)
3451 .mount(&server)
3452 .await;
3453
3454 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3455 let sprint = client
3456 .create_sprint(1, "Sprint 6", None, None, None)
3457 .await
3458 .unwrap();
3459
3460 assert_eq!(sprint.id, 43);
3461 assert!(sprint.start_date.is_none());
3462 }
3463
3464 #[tokio::test]
3465 async fn create_sprint_api_error() {
3466 let server = wiremock::MockServer::start().await;
3467
3468 wiremock::Mock::given(wiremock::matchers::method("POST"))
3469 .and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
3470 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
3471 .expect(1)
3472 .mount(&server)
3473 .await;
3474
3475 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3476 let err = client
3477 .create_sprint(999, "Bad", None, None, None)
3478 .await
3479 .unwrap_err();
3480 assert!(err.to_string().contains("400"));
3481 }
3482
3483 #[tokio::test]
3484 async fn update_sprint_success() {
3485 let server = wiremock::MockServer::start().await;
3486
3487 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3488 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
3489 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3490 serde_json::json!({"id": 42, "name": "Sprint 5 Updated", "state": "active"}),
3491 ))
3492 .expect(1)
3493 .mount(&server)
3494 .await;
3495
3496 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3497 let result = client
3498 .update_sprint(
3499 42,
3500 Some("Sprint 5 Updated"),
3501 Some("active"),
3502 None,
3503 None,
3504 None,
3505 )
3506 .await;
3507 assert!(result.is_ok());
3508 }
3509
3510 #[tokio::test]
3511 async fn update_sprint_all_fields() {
3512 let server = wiremock::MockServer::start().await;
3513
3514 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3515 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
3516 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3517 serde_json::json!({"id": 42, "name": "Sprint 5", "state": "active"}),
3518 ))
3519 .expect(1)
3520 .mount(&server)
3521 .await;
3522
3523 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3524 let result = client
3525 .update_sprint(
3526 42,
3527 Some("Sprint 5"),
3528 Some("active"),
3529 Some("2026-05-01"),
3530 Some("2026-05-14"),
3531 Some("Ship v2"),
3532 )
3533 .await;
3534 assert!(result.is_ok());
3535 }
3536
3537 #[tokio::test]
3538 async fn update_sprint_api_error() {
3539 let server = wiremock::MockServer::start().await;
3540
3541 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3542 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999"))
3543 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3544 .expect(1)
3545 .mount(&server)
3546 .await;
3547
3548 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3549 let err = client
3550 .update_sprint(999, Some("Nope"), None, None, None, None)
3551 .await
3552 .unwrap_err();
3553 assert!(err.to_string().contains("404"));
3554 }
3555
3556 #[tokio::test]
3557 async fn delete_sprint_success() {
3558 let server = wiremock::MockServer::start().await;
3559 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3560 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
3561 .respond_with(wiremock::ResponseTemplate::new(204))
3562 .expect(1)
3563 .mount(&server)
3564 .await;
3565
3566 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3567 assert!(client.delete_sprint(42).await.is_ok());
3568 }
3569
3570 #[tokio::test]
3571 async fn delete_sprint_api_error() {
3572 let server = wiremock::MockServer::start().await;
3573 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3574 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999"))
3575 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
3576 .mount(&server)
3577 .await;
3578
3579 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3580 let err = client.delete_sprint(999).await.unwrap_err();
3581 assert!(err.to_string().contains("403"));
3582 }
3583
3584 #[tokio::test]
3585 async fn get_project_versions_success() {
3586 let server = wiremock::MockServer::start().await;
3587
3588 wiremock::Mock::given(wiremock::matchers::method("GET"))
3589 .and(wiremock::matchers::path(
3590 "/rest/api/3/project/PROJ/versions",
3591 ))
3592 .respond_with(
3593 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
3594 {
3595 "id": "10000",
3596 "name": "1.0.0",
3597 "description": "First release",
3598 "released": true,
3599 "archived": false,
3600 "releaseDate": "2026-04-01",
3601 "startDate": "2026-03-01",
3602 },
3603 {
3604 "id": "10001",
3605 "name": "1.1.0",
3606 "released": false,
3607 "archived": false,
3608 }
3609 ])),
3610 )
3611 .expect(1)
3612 .mount(&server)
3613 .await;
3614
3615 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3616 let result = client
3617 .get_project_versions("PROJ", None, None)
3618 .await
3619 .unwrap();
3620
3621 assert_eq!(result.total, 2);
3622 assert_eq!(result.versions[0].id, "10000");
3623 assert_eq!(result.versions[0].name, "1.0.0");
3624 assert_eq!(result.versions[0].project_key, "PROJ");
3625 assert!(result.versions[0].released);
3626 assert_eq!(
3627 result.versions[0].release_date.as_deref(),
3628 Some("2026-04-01")
3629 );
3630 assert_eq!(result.versions[1].name, "1.1.0");
3631 assert!(!result.versions[1].released);
3632 }
3633
3634 #[tokio::test]
3635 async fn get_project_versions_filters_released() {
3636 let server = wiremock::MockServer::start().await;
3637
3638 wiremock::Mock::given(wiremock::matchers::method("GET"))
3639 .and(wiremock::matchers::path(
3640 "/rest/api/3/project/PROJ/versions",
3641 ))
3642 .respond_with(
3643 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
3644 {"id": "1", "name": "1.0", "released": true, "archived": false},
3645 {"id": "2", "name": "2.0", "released": false, "archived": false},
3646 {"id": "3", "name": "0.9", "released": true, "archived": true},
3647 ])),
3648 )
3649 .expect(1)
3650 .mount(&server)
3651 .await;
3652
3653 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3654 let result = client
3655 .get_project_versions("PROJ", Some(true), Some(false))
3656 .await
3657 .unwrap();
3658
3659 assert_eq!(result.total, 1);
3660 assert_eq!(result.versions[0].name, "1.0");
3661 }
3662
3663 #[tokio::test]
3664 async fn get_project_versions_api_error() {
3665 let server = wiremock::MockServer::start().await;
3666
3667 wiremock::Mock::given(wiremock::matchers::method("GET"))
3668 .and(wiremock::matchers::path(
3669 "/rest/api/3/project/NONE/versions",
3670 ))
3671 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3672 .expect(1)
3673 .mount(&server)
3674 .await;
3675
3676 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3677 let err = client
3678 .get_project_versions("NONE", None, None)
3679 .await
3680 .unwrap_err();
3681 assert!(err.to_string().contains("404"));
3682 }
3683
3684 #[tokio::test]
3685 async fn create_project_version_success() {
3686 let server = wiremock::MockServer::start().await;
3687
3688 wiremock::Mock::given(wiremock::matchers::method("POST"))
3689 .and(wiremock::matchers::path("/rest/api/3/version"))
3690 .respond_with(
3691 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
3692 "id": "10010",
3693 "name": "1.2.0",
3694 "description": "Bugfix release",
3695 "released": false,
3696 "archived": false,
3697 "releaseDate": "2026-06-01",
3698 "startDate": "2026-05-01",
3699 })),
3700 )
3701 .expect(1)
3702 .mount(&server)
3703 .await;
3704
3705 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3706 let version = client
3707 .create_project_version(
3708 "PROJ",
3709 "1.2.0",
3710 Some("Bugfix release"),
3711 Some("2026-06-01"),
3712 Some("2026-05-01"),
3713 false,
3714 false,
3715 )
3716 .await
3717 .unwrap();
3718
3719 assert_eq!(version.id, "10010");
3720 assert_eq!(version.name, "1.2.0");
3721 assert_eq!(version.project_key, "PROJ");
3722 assert_eq!(version.description.as_deref(), Some("Bugfix release"));
3723 assert_eq!(version.release_date.as_deref(), Some("2026-06-01"));
3724 }
3725
3726 #[tokio::test]
3727 async fn create_project_version_minimal() {
3728 let server = wiremock::MockServer::start().await;
3729
3730 wiremock::Mock::given(wiremock::matchers::method("POST"))
3731 .and(wiremock::matchers::path("/rest/api/3/version"))
3732 .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
3733 serde_json::json!({"id": "10011", "name": "2.0.0", "released": false, "archived": false}),
3734 ))
3735 .expect(1)
3736 .mount(&server)
3737 .await;
3738
3739 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3740 let version = client
3741 .create_project_version("PROJ", "2.0.0", None, None, None, false, false)
3742 .await
3743 .unwrap();
3744
3745 assert_eq!(version.id, "10011");
3746 assert!(version.release_date.is_none());
3747 }
3748
3749 #[tokio::test]
3750 async fn create_project_version_forbidden() {
3751 let server = wiremock::MockServer::start().await;
3752
3753 wiremock::Mock::given(wiremock::matchers::method("POST"))
3754 .and(wiremock::matchers::path("/rest/api/3/version"))
3755 .respond_with(
3756 wiremock::ResponseTemplate::new(403)
3757 .set_body_string("You do not have permission to administer this project."),
3758 )
3759 .expect(1)
3760 .mount(&server)
3761 .await;
3762
3763 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3764 let err = client
3765 .create_project_version("PROJ", "1.0", None, None, None, false, false)
3766 .await
3767 .unwrap_err();
3768 assert!(err.to_string().contains("403"));
3769 }
3770
3771 #[tokio::test]
3772 async fn create_project_version_invalid_date_short_circuits() {
3773 let server = wiremock::MockServer::start().await;
3775 wiremock::Mock::given(wiremock::matchers::method("POST"))
3776 .and(wiremock::matchers::path("/rest/api/3/version"))
3777 .respond_with(wiremock::ResponseTemplate::new(500))
3778 .expect(0)
3779 .mount(&server)
3780 .await;
3781
3782 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3783 let err = client
3784 .create_project_version("PROJ", "1.0", None, Some("06-01-2026"), None, false, false)
3785 .await
3786 .unwrap_err();
3787 let msg = err.to_string();
3788 assert!(msg.contains("release_date"));
3789 assert!(msg.contains("YYYY-MM-DD"));
3790 }
3791
3792 #[tokio::test]
3793 async fn create_project_version_invalid_start_date_short_circuits() {
3794 let server = wiremock::MockServer::start().await;
3798 wiremock::Mock::given(wiremock::matchers::method("POST"))
3799 .and(wiremock::matchers::path("/rest/api/3/version"))
3800 .respond_with(wiremock::ResponseTemplate::new(500))
3801 .expect(0)
3802 .mount(&server)
3803 .await;
3804
3805 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3806 let err = client
3807 .create_project_version(
3808 "PROJ",
3809 "1.0",
3810 None,
3811 Some("2026-06-01"),
3812 Some("not-a-date"),
3813 false,
3814 false,
3815 )
3816 .await
3817 .unwrap_err();
3818 let msg = err.to_string();
3819 assert!(msg.contains("start_date"));
3820 assert!(msg.contains("YYYY-MM-DD"));
3821 }
3822
3823 #[test]
3824 fn validate_iso_date_accepts_valid() {
3825 assert!(validate_iso_date(Some("2026-05-10"), "release_date").is_ok());
3826 assert!(validate_iso_date(None, "release_date").is_ok());
3827 }
3828
3829 #[test]
3830 fn validate_iso_date_rejects_bad_shape() {
3831 let err = validate_iso_date(Some("2026/05/10"), "release_date").unwrap_err();
3832 assert!(err.to_string().contains("release_date"));
3833 }
3834
3835 #[test]
3836 fn validate_iso_date_rejects_impossible() {
3837 let err = validate_iso_date(Some("2026-13-40"), "start_date").unwrap_err();
3838 assert!(err.to_string().contains("start_date"));
3839 }
3840
3841 #[tokio::test]
3844 async fn get_project_versions_transport_error() {
3845 let client = AtlassianClient::new("http://127.0.0.1:1", "user@test.com", "token").unwrap();
3848 let err = client
3849 .get_project_versions("PROJ", None, None)
3850 .await
3851 .unwrap_err();
3852 assert!(err.to_string().contains("Failed to send GET request"));
3854 }
3855
3856 #[tokio::test]
3860 async fn get_project_versions_invalid_json() {
3861 let server = wiremock::MockServer::start().await;
3862 wiremock::Mock::given(wiremock::matchers::method("GET"))
3863 .and(wiremock::matchers::path(
3864 "/rest/api/3/project/PROJ/versions",
3865 ))
3866 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not-json"))
3867 .expect(1)
3868 .mount(&server)
3869 .await;
3870
3871 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3872 let err = client
3873 .get_project_versions("PROJ", None, None)
3874 .await
3875 .unwrap_err();
3876 assert!(err
3877 .to_string()
3878 .contains("Failed to parse project versions response"));
3879 }
3880
3881 #[tokio::test]
3884 async fn create_project_version_transport_error() {
3885 let client = AtlassianClient::new("http://127.0.0.1:1", "user@test.com", "token").unwrap();
3886 let err = client
3887 .create_project_version("PROJ", "1.0", None, None, None, false, false)
3888 .await
3889 .unwrap_err();
3890 assert!(err.to_string().contains("Failed to send POST request"));
3891 }
3892
3893 #[tokio::test]
3896 async fn create_project_version_invalid_json() {
3897 let server = wiremock::MockServer::start().await;
3898 wiremock::Mock::given(wiremock::matchers::method("POST"))
3899 .and(wiremock::matchers::path("/rest/api/3/version"))
3900 .respond_with(wiremock::ResponseTemplate::new(201).set_body_string("not-json"))
3901 .expect(1)
3902 .mount(&server)
3903 .await;
3904
3905 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3906 let err = client
3907 .create_project_version("PROJ", "1.0", None, None, None, false, false)
3908 .await
3909 .unwrap_err();
3910 assert!(err
3911 .to_string()
3912 .contains("Failed to parse version create response"));
3913 }
3914
3915 #[tokio::test]
3916 async fn update_project_version_success() {
3917 let server = wiremock::MockServer::start().await;
3918 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3919 .and(wiremock::matchers::path("/rest/api/3/version/10000"))
3920 .respond_with(
3921 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3922 "id": "10000",
3923 "name": "1.0",
3924 "released": true
3925 })),
3926 )
3927 .expect(1)
3928 .mount(&server)
3929 .await;
3930
3931 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3932 let result = client
3933 .update_project_version(
3934 "10000",
3935 None,
3936 None,
3937 Some(true),
3938 Some("2026-04-16"),
3939 None,
3940 None,
3941 )
3942 .await;
3943 assert!(result.is_ok());
3944 }
3945
3946 #[tokio::test]
3949 async fn update_project_version_sends_start_date_and_archived() {
3950 let server = wiremock::MockServer::start().await;
3951 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3952 .and(wiremock::matchers::path("/rest/api/3/version/10000"))
3953 .and(wiremock::matchers::body_json(serde_json::json!({
3954 "archived": true,
3955 "startDate": "2026-01-01"
3956 })))
3957 .respond_with(
3958 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3959 "id": "10000", "name": "1.0"
3960 })),
3961 )
3962 .expect(1)
3963 .mount(&server)
3964 .await;
3965
3966 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3967 client
3968 .update_project_version(
3969 "10000",
3970 None,
3971 None,
3972 None,
3973 None,
3974 Some(true),
3975 Some("2026-01-01"),
3976 )
3977 .await
3978 .unwrap();
3979 }
3980
3981 #[tokio::test]
3982 async fn update_project_version_invalid_date_short_circuits() {
3983 let client = AtlassianClient::new("http://127.0.0.1:1", "u@t.com", "tok").unwrap();
3985 let err = client
3986 .update_project_version("10000", None, None, Some(true), Some("nope"), None, None)
3987 .await
3988 .unwrap_err();
3989 assert!(err.to_string().contains("release_date"));
3990 }
3991
3992 #[tokio::test]
3993 async fn update_project_version_api_error() {
3994 let server = wiremock::MockServer::start().await;
3995 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3996 .and(wiremock::matchers::path("/rest/api/3/version/9999"))
3997 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3998 .mount(&server)
3999 .await;
4000
4001 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4002 let err = client
4003 .update_project_version("9999", Some("2.0"), None, None, None, None, None)
4004 .await
4005 .unwrap_err();
4006 assert!(err.to_string().contains("404"));
4007 }
4008
4009 #[tokio::test]
4010 async fn delete_project_version_success() {
4011 let server = wiremock::MockServer::start().await;
4012 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4013 .and(wiremock::matchers::path("/rest/api/3/version/10000"))
4014 .respond_with(wiremock::ResponseTemplate::new(204))
4015 .expect(1)
4016 .mount(&server)
4017 .await;
4018
4019 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4020 assert!(client
4021 .delete_project_version("10000", None, None)
4022 .await
4023 .is_ok());
4024 }
4025
4026 #[tokio::test]
4027 async fn delete_project_version_with_move_targets_sets_query() {
4028 let server = wiremock::MockServer::start().await;
4029 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4030 .and(wiremock::matchers::path("/rest/api/3/version/10000"))
4031 .and(wiremock::matchers::query_param("moveFixIssuesTo", "10001"))
4032 .and(wiremock::matchers::query_param(
4033 "moveAffectedIssuesTo",
4034 "10002",
4035 ))
4036 .respond_with(wiremock::ResponseTemplate::new(204))
4037 .expect(1)
4038 .mount(&server)
4039 .await;
4040
4041 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4042 assert!(client
4043 .delete_project_version("10000", Some("10001"), Some("10002"))
4044 .await
4045 .is_ok());
4046 }
4047
4048 #[tokio::test]
4049 async fn delete_project_version_api_error() {
4050 let server = wiremock::MockServer::start().await;
4051 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4052 .and(wiremock::matchers::path("/rest/api/3/version/9999"))
4053 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4054 .mount(&server)
4055 .await;
4056
4057 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4058 let err = client
4059 .delete_project_version("9999", None, None)
4060 .await
4061 .unwrap_err();
4062 assert!(err.to_string().contains("403"));
4063 }
4064
4065 #[tokio::test]
4066 async fn get_project_components_success() {
4067 let server = wiremock::MockServer::start().await;
4068 wiremock::Mock::given(wiremock::matchers::method("GET"))
4069 .and(wiremock::matchers::path(
4070 "/rest/api/3/project/PROJ/components",
4071 ))
4072 .respond_with(
4073 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
4074 {"id": "10000", "name": "Backend", "description": "Server side"},
4075 {"id": "10001", "name": "Frontend"}
4076 ])),
4077 )
4078 .expect(1)
4079 .mount(&server)
4080 .await;
4081
4082 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4083 let components = client.get_project_components("PROJ").await.unwrap();
4084 assert_eq!(components.len(), 2);
4085 assert_eq!(components[0].name, "Backend");
4086 assert_eq!(components[0].description.as_deref(), Some("Server side"));
4087 assert!(components[1].description.is_none());
4088 }
4089
4090 #[tokio::test]
4091 async fn create_component_success() {
4092 let server = wiremock::MockServer::start().await;
4093 wiremock::Mock::given(wiremock::matchers::method("POST"))
4094 .and(wiremock::matchers::path("/rest/api/3/component"))
4095 .and(wiremock::matchers::body_json(serde_json::json!({
4096 "project": "PROJ", "name": "Backend", "description": "Server side"
4097 })))
4098 .respond_with(
4099 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
4100 "id": "10000", "name": "Backend", "description": "Server side"
4101 })),
4102 )
4103 .expect(1)
4104 .mount(&server)
4105 .await;
4106
4107 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4108 let component = client
4109 .create_component("PROJ", "Backend", Some("Server side"))
4110 .await
4111 .unwrap();
4112 assert_eq!(component.id, "10000");
4113 assert_eq!(component.name, "Backend");
4114 }
4115
4116 #[tokio::test]
4117 async fn update_component_success() {
4118 let server = wiremock::MockServer::start().await;
4119 wiremock::Mock::given(wiremock::matchers::method("PUT"))
4120 .and(wiremock::matchers::path("/rest/api/3/component/10000"))
4121 .and(wiremock::matchers::body_json(serde_json::json!({
4122 "name": "Backend Services"
4123 })))
4124 .respond_with(
4125 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4126 "id": "10000", "name": "Backend Services"
4127 })),
4128 )
4129 .expect(1)
4130 .mount(&server)
4131 .await;
4132
4133 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4134 assert!(client
4135 .update_component("10000", Some("Backend Services"), None)
4136 .await
4137 .is_ok());
4138 }
4139
4140 #[tokio::test]
4141 async fn delete_component_with_move_target() {
4142 let server = wiremock::MockServer::start().await;
4143 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4144 .and(wiremock::matchers::path("/rest/api/3/component/10000"))
4145 .and(wiremock::matchers::query_param("moveIssuesTo", "10001"))
4146 .respond_with(wiremock::ResponseTemplate::new(204))
4147 .expect(1)
4148 .mount(&server)
4149 .await;
4150
4151 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4152 assert!(client
4153 .delete_component("10000", Some("10001"))
4154 .await
4155 .is_ok());
4156 }
4157
4158 #[tokio::test]
4159 async fn create_component_api_error() {
4160 let server = wiremock::MockServer::start().await;
4161 wiremock::Mock::given(wiremock::matchers::method("POST"))
4162 .and(wiremock::matchers::path("/rest/api/3/component"))
4163 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4164 .mount(&server)
4165 .await;
4166
4167 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4168 let err = client
4169 .create_component("PROJ", "X", None)
4170 .await
4171 .unwrap_err();
4172 assert!(err.to_string().contains("403"));
4173 }
4174
4175 #[tokio::test]
4176 async fn get_issue_links_success() {
4177 let server = wiremock::MockServer::start().await;
4178
4179 wiremock::Mock::given(wiremock::matchers::method("GET"))
4180 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4181 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4182 serde_json::json!({
4183 "fields": {
4184 "issuelinks": [
4185 {
4186 "id": "100",
4187 "type": {"name": "Blocks"},
4188 "outwardIssue": {"key": "PROJ-2", "fields": {"summary": "Blocked issue"}}
4189 },
4190 {
4191 "id": "101",
4192 "type": {"name": "Relates"},
4193 "inwardIssue": {"key": "PROJ-3", "fields": {"summary": "Related issue"}}
4194 }
4195 ]
4196 }
4197 }),
4198 ))
4199 .expect(1)
4200 .mount(&server)
4201 .await;
4202
4203 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4204 let links = client.get_issue_links("PROJ-1").await.unwrap();
4205
4206 assert_eq!(links.len(), 2);
4207 assert_eq!(links[0].id, "100");
4208 assert_eq!(links[0].link_type, "Blocks");
4209 assert_eq!(links[0].direction, "outward");
4210 assert_eq!(links[0].linked_issue_key, "PROJ-2");
4211 assert_eq!(links[0].linked_issue_summary, "Blocked issue");
4212 assert_eq!(links[1].id, "101");
4213 assert_eq!(links[1].direction, "inward");
4214 assert_eq!(links[1].linked_issue_key, "PROJ-3");
4215 }
4216
4217 #[tokio::test]
4218 async fn get_issue_links_empty() {
4219 let server = wiremock::MockServer::start().await;
4220
4221 wiremock::Mock::given(wiremock::matchers::method("GET"))
4222 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4223 .respond_with(
4224 wiremock::ResponseTemplate::new(200)
4225 .set_body_json(serde_json::json!({"fields": {"issuelinks": []}})),
4226 )
4227 .expect(1)
4228 .mount(&server)
4229 .await;
4230
4231 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4232 let links = client.get_issue_links("PROJ-1").await.unwrap();
4233 assert!(links.is_empty());
4234 }
4235
4236 #[tokio::test]
4237 async fn get_issue_links_api_error() {
4238 let server = wiremock::MockServer::start().await;
4239
4240 wiremock::Mock::given(wiremock::matchers::method("GET"))
4241 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
4242 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4243 .expect(1)
4244 .mount(&server)
4245 .await;
4246
4247 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4248 let err = client.get_issue_links("NOPE-1").await.unwrap_err();
4249 assert!(err.to_string().contains("404"));
4250 }
4251
4252 #[tokio::test]
4253 async fn get_remote_issue_links_success() {
4254 let server = wiremock::MockServer::start().await;
4255
4256 wiremock::Mock::given(wiremock::matchers::method("GET"))
4257 .and(wiremock::matchers::path(
4258 "/rest/api/3/issue/PROJ-1/remotelink",
4259 ))
4260 .respond_with(
4261 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
4262 {
4263 "id": 10001,
4264 "globalId": "system=https://example.atlassian.net/wiki&id=12345",
4265 "relationship": "mentioned in",
4266 "object": {
4267 "url": "https://example.atlassian.net/wiki/spaces/X/pages/12345",
4268 "title": "Design doc",
4269 "summary": "Architecture overview",
4270 "icon": {
4271 "url16x16": "https://example.atlassian.net/icons/page.png",
4272 "title": "Confluence Page"
4273 }
4274 }
4275 },
4276 {
4277 "id": "10002",
4278 "object": {
4279 "url": "https://bitbucket.org/acme/repo/pull-requests/42"
4280 }
4281 }
4282 ])),
4283 )
4284 .expect(1)
4285 .mount(&server)
4286 .await;
4287
4288 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4289 let links = client.get_remote_issue_links("PROJ-1").await.unwrap();
4290
4291 assert_eq!(links.len(), 2);
4292
4293 assert_eq!(links[0].id, "10001");
4295 assert_eq!(
4296 links[0].global_id.as_deref(),
4297 Some("system=https://example.atlassian.net/wiki&id=12345")
4298 );
4299 assert_eq!(links[0].relationship.as_deref(), Some("mentioned in"));
4300 assert_eq!(
4301 links[0].object.url,
4302 "https://example.atlassian.net/wiki/spaces/X/pages/12345"
4303 );
4304 assert_eq!(links[0].object.title.as_deref(), Some("Design doc"));
4305 assert_eq!(
4306 links[0].object.summary.as_deref(),
4307 Some("Architecture overview")
4308 );
4309 let icon = links[0].object.icon.as_ref().expect("icon present");
4310 assert_eq!(
4311 icon.url.as_deref(),
4312 Some("https://example.atlassian.net/icons/page.png")
4313 );
4314 assert_eq!(icon.title.as_deref(), Some("Confluence Page"));
4315
4316 assert_eq!(links[1].id, "10002");
4318 assert!(links[1].global_id.is_none());
4319 assert!(links[1].relationship.is_none());
4320 assert_eq!(
4321 links[1].object.url,
4322 "https://bitbucket.org/acme/repo/pull-requests/42"
4323 );
4324 assert!(links[1].object.title.is_none());
4325 assert!(links[1].object.summary.is_none());
4326 assert!(links[1].object.icon.is_none());
4327 }
4328
4329 #[tokio::test]
4330 async fn get_remote_issue_links_empty() {
4331 let server = wiremock::MockServer::start().await;
4332 wiremock::Mock::given(wiremock::matchers::method("GET"))
4333 .and(wiremock::matchers::path(
4334 "/rest/api/3/issue/PROJ-1/remotelink",
4335 ))
4336 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
4337 .expect(1)
4338 .mount(&server)
4339 .await;
4340 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4341 let links = client.get_remote_issue_links("PROJ-1").await.unwrap();
4342 assert!(links.is_empty());
4343 }
4344
4345 #[tokio::test]
4346 async fn get_remote_issue_links_rejects_unexpected_id_type() {
4347 let server = wiremock::MockServer::start().await;
4352 wiremock::Mock::given(wiremock::matchers::method("GET"))
4353 .and(wiremock::matchers::path(
4354 "/rest/api/3/issue/PROJ-1/remotelink",
4355 ))
4356 .respond_with(
4357 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
4358 {
4359 "id": null,
4360 "object": {"url": "https://example.com/x"}
4361 }
4362 ])),
4363 )
4364 .expect(1)
4365 .mount(&server)
4366 .await;
4367 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4368 let err = client.get_remote_issue_links("PROJ-1").await.unwrap_err();
4369 assert!(err.to_string().contains("unexpected remote link id type"));
4370 }
4371
4372 #[tokio::test]
4373 async fn get_remote_issue_links_api_error() {
4374 let server = wiremock::MockServer::start().await;
4375 wiremock::Mock::given(wiremock::matchers::method("GET"))
4376 .and(wiremock::matchers::path(
4377 "/rest/api/3/issue/NOPE-1/remotelink",
4378 ))
4379 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4380 .expect(1)
4381 .mount(&server)
4382 .await;
4383 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4384 let err = client.get_remote_issue_links("NOPE-1").await.unwrap_err();
4385 assert!(err.to_string().contains("404"));
4386 }
4387
4388 #[tokio::test]
4389 async fn create_remote_issue_link_success() {
4390 let server = wiremock::MockServer::start().await;
4391 wiremock::Mock::given(wiremock::matchers::method("POST"))
4392 .and(wiremock::matchers::path(
4393 "/rest/api/3/issue/PROJ-1/remotelink",
4394 ))
4395 .respond_with(
4396 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
4397 "id": 10010,
4398 "self": "https://example.atlassian.net/rest/api/3/issue/PROJ-1/remotelink/10010"
4399 })),
4400 )
4401 .expect(1)
4402 .mount(&server)
4403 .await;
4404
4405 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4406 let id = client
4407 .create_remote_issue_link(
4408 "PROJ-1",
4409 "https://example.com/doc",
4410 "Design doc",
4411 Some("Architecture"),
4412 Some("relates to"),
4413 Some("sys=example/doc"),
4414 )
4415 .await
4416 .unwrap();
4417 assert_eq!(id, "10010");
4419 }
4420
4421 #[tokio::test]
4424 async fn create_remote_issue_link_accepts_string_id() {
4425 let server = wiremock::MockServer::start().await;
4426 wiremock::Mock::given(wiremock::matchers::method("POST"))
4427 .and(wiremock::matchers::path(
4428 "/rest/api/3/issue/PROJ-1/remotelink",
4429 ))
4430 .respond_with(
4431 wiremock::ResponseTemplate::new(201)
4432 .set_body_json(serde_json::json!({"id": "10010"})),
4433 )
4434 .expect(1)
4435 .mount(&server)
4436 .await;
4437
4438 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4439 let id = client
4440 .create_remote_issue_link("PROJ-1", "https://x", "t", None, None, None)
4441 .await
4442 .unwrap();
4443 assert_eq!(id, "10010");
4444 }
4445
4446 #[tokio::test]
4449 async fn create_remote_issue_link_rejects_unexpected_id_type() {
4450 let server = wiremock::MockServer::start().await;
4451 wiremock::Mock::given(wiremock::matchers::method("POST"))
4452 .and(wiremock::matchers::path(
4453 "/rest/api/3/issue/PROJ-1/remotelink",
4454 ))
4455 .respond_with(
4456 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": true})),
4457 )
4458 .mount(&server)
4459 .await;
4460
4461 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4462 let err = client
4463 .create_remote_issue_link("PROJ-1", "https://x", "t", None, None, None)
4464 .await
4465 .unwrap_err();
4466 assert!(err
4467 .to_string()
4468 .contains("unexpected remote link id type in create response"));
4469 }
4470
4471 #[tokio::test]
4472 async fn create_remote_issue_link_api_error() {
4473 let server = wiremock::MockServer::start().await;
4474 wiremock::Mock::given(wiremock::matchers::method("POST"))
4475 .and(wiremock::matchers::path(
4476 "/rest/api/3/issue/PROJ-1/remotelink",
4477 ))
4478 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
4479 .mount(&server)
4480 .await;
4481
4482 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4483 let err = client
4484 .create_remote_issue_link("PROJ-1", "https://x", "t", None, None, None)
4485 .await
4486 .unwrap_err();
4487 assert!(err.to_string().contains("400"));
4488 }
4489
4490 #[tokio::test]
4491 async fn delete_remote_issue_link_success() {
4492 let server = wiremock::MockServer::start().await;
4493 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4494 .and(wiremock::matchers::path(
4495 "/rest/api/3/issue/PROJ-1/remotelink/10010",
4496 ))
4497 .respond_with(wiremock::ResponseTemplate::new(204))
4498 .expect(1)
4499 .mount(&server)
4500 .await;
4501
4502 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4503 assert!(client
4504 .delete_remote_issue_link("PROJ-1", "10010")
4505 .await
4506 .is_ok());
4507 }
4508
4509 #[tokio::test]
4510 async fn delete_remote_issue_link_api_error() {
4511 let server = wiremock::MockServer::start().await;
4512 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4513 .and(wiremock::matchers::path(
4514 "/rest/api/3/issue/PROJ-1/remotelink/9999",
4515 ))
4516 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4517 .mount(&server)
4518 .await;
4519
4520 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4521 let err = client
4522 .delete_remote_issue_link("PROJ-1", "9999")
4523 .await
4524 .unwrap_err();
4525 assert!(err.to_string().contains("404"));
4526 }
4527
4528 #[tokio::test]
4529 async fn get_link_types_success() {
4530 let server = wiremock::MockServer::start().await;
4531 wiremock::Mock::given(wiremock::matchers::method("GET"))
4532 .and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
4533 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"issueLinkTypes": [{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}, {"id": "2", "name": "Clones", "inward": "is cloned by", "outward": "clones"}]})))
4534 .expect(1).mount(&server).await;
4535 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4536 let types = client.get_link_types().await.unwrap();
4537 assert_eq!(types.len(), 2);
4538 assert_eq!(types[0].name, "Blocks");
4539 assert_eq!(types[0].inward, "is blocked by");
4540 }
4541
4542 #[tokio::test]
4543 async fn get_link_types_api_error() {
4544 let server = wiremock::MockServer::start().await;
4545 wiremock::Mock::given(wiremock::matchers::method("GET"))
4546 .and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
4547 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
4548 .expect(1)
4549 .mount(&server)
4550 .await;
4551 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4552 let err = client.get_link_types().await.unwrap_err();
4553 assert!(err.to_string().contains("401"));
4554 }
4555
4556 #[tokio::test]
4557 async fn create_issue_link_success() {
4558 let server = wiremock::MockServer::start().await;
4559 wiremock::Mock::given(wiremock::matchers::method("POST"))
4560 .and(wiremock::matchers::path("/rest/api/3/issueLink"))
4561 .respond_with(wiremock::ResponseTemplate::new(201))
4562 .expect(1)
4563 .mount(&server)
4564 .await;
4565 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4566 assert!(client
4567 .create_issue_link("Blocks", "PROJ-1", "PROJ-2")
4568 .await
4569 .is_ok());
4570 }
4571
4572 #[tokio::test]
4573 async fn create_issue_link_api_error() {
4574 let server = wiremock::MockServer::start().await;
4575 wiremock::Mock::given(wiremock::matchers::method("POST"))
4576 .and(wiremock::matchers::path("/rest/api/3/issueLink"))
4577 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
4578 .expect(1)
4579 .mount(&server)
4580 .await;
4581 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4582 let err = client
4583 .create_issue_link("Invalid", "NOPE-1", "NOPE-2")
4584 .await
4585 .unwrap_err();
4586 assert!(err.to_string().contains("400"));
4587 }
4588
4589 #[tokio::test]
4590 async fn remove_issue_link_success() {
4591 let server = wiremock::MockServer::start().await;
4592 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4593 .and(wiremock::matchers::path("/rest/api/3/issueLink/12345"))
4594 .respond_with(wiremock::ResponseTemplate::new(204))
4595 .expect(1)
4596 .mount(&server)
4597 .await;
4598 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4599 assert!(client.remove_issue_link("12345").await.is_ok());
4600 }
4601
4602 #[tokio::test]
4603 async fn remove_issue_link_api_error() {
4604 let server = wiremock::MockServer::start().await;
4605 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4606 .and(wiremock::matchers::path("/rest/api/3/issueLink/99999"))
4607 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4608 .expect(1)
4609 .mount(&server)
4610 .await;
4611 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4612 let err = client.remove_issue_link("99999").await.unwrap_err();
4613 assert!(err.to_string().contains("404"));
4614 }
4615
4616 #[tokio::test]
4617 async fn set_issue_parent_success() {
4618 let server = wiremock::MockServer::start().await;
4619 wiremock::Mock::given(wiremock::matchers::method("PUT"))
4620 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
4621 .and(wiremock::matchers::body_json(serde_json::json!({
4622 "fields": {"parent": {"key": "EPIC-1"}}
4623 })))
4624 .respond_with(wiremock::ResponseTemplate::new(204))
4625 .expect(1)
4626 .mount(&server)
4627 .await;
4628 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4629 assert!(client.set_issue_parent("PROJ-2", "EPIC-1").await.is_ok());
4630 }
4631
4632 #[tokio::test]
4633 async fn set_issue_parent_api_error() {
4634 let server = wiremock::MockServer::start().await;
4635 wiremock::Mock::given(wiremock::matchers::method("PUT"))
4636 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
4637 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Not allowed"))
4638 .expect(1)
4639 .mount(&server)
4640 .await;
4641 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4642 let err = client
4643 .set_issue_parent("PROJ-2", "NOPE-1")
4644 .await
4645 .unwrap_err();
4646 assert!(err.to_string().contains("400"));
4647 }
4648
4649 #[tokio::test]
4650 async fn modify_issue_labels_sends_update_verb() {
4651 let server = wiremock::MockServer::start().await;
4652 wiremock::Mock::given(wiremock::matchers::method("PUT"))
4653 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4654 .and(wiremock::matchers::body_json(serde_json::json!({
4655 "update": {"labels": [{"add": "backend"}, {"add": "urgent"}, {"remove": "stale"}]}
4656 })))
4657 .respond_with(wiremock::ResponseTemplate::new(204))
4658 .expect(1)
4659 .mount(&server)
4660 .await;
4661
4662 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4663 let result = client
4664 .modify_issue_labels(
4665 "PROJ-1",
4666 &["backend".to_string(), "urgent".to_string()],
4667 &["stale".to_string()],
4668 )
4669 .await;
4670 assert!(result.is_ok());
4671 }
4672
4673 #[tokio::test]
4674 async fn modify_issue_labels_api_error() {
4675 let server = wiremock::MockServer::start().await;
4676 wiremock::Mock::given(wiremock::matchers::method("PUT"))
4677 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4678 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4679 .mount(&server)
4680 .await;
4681
4682 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4683 let err = client
4684 .modify_issue_labels("PROJ-1", &["x".to_string()], &[])
4685 .await
4686 .unwrap_err();
4687 assert!(err.to_string().contains("403"));
4688 }
4689
4690 #[tokio::test]
4691 async fn get_bytes_success() {
4692 let server = wiremock::MockServer::start().await;
4693 wiremock::Mock::given(wiremock::matchers::method("GET"))
4694 .and(wiremock::matchers::path("/file.bin"))
4695 .and(wiremock::matchers::header("Accept", "*/*"))
4696 .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"binary content"))
4697 .expect(1)
4698 .mount(&server)
4699 .await;
4700
4701 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4702 let data = client
4703 .get_bytes(&format!("{}/file.bin", server.uri()))
4704 .await
4705 .unwrap();
4706 assert_eq!(&data[..], b"binary content");
4707 }
4708
4709 #[tokio::test]
4710 async fn get_bytes_api_error() {
4711 let server = wiremock::MockServer::start().await;
4712 wiremock::Mock::given(wiremock::matchers::method("GET"))
4713 .and(wiremock::matchers::path("/missing.bin"))
4714 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4715 .expect(1)
4716 .mount(&server)
4717 .await;
4718
4719 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4720 let err = client
4721 .get_bytes(&format!("{}/missing.bin", server.uri()))
4722 .await
4723 .unwrap_err();
4724 assert!(err.to_string().contains("404"));
4725 }
4726
4727 #[tokio::test]
4728 async fn get_attachments_success() {
4729 let server = wiremock::MockServer::start().await;
4730 wiremock::Mock::given(wiremock::matchers::method("GET"))
4731 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4732 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4733 serde_json::json!({
4734 "fields": {
4735 "attachment": [
4736 {"id": "1", "filename": "screenshot.png", "mimeType": "image/png", "size": 12345, "content": "https://org.atlassian.net/attachment/1"},
4737 {"id": "2", "filename": "report.pdf", "mimeType": "application/pdf", "size": 99999, "content": "https://org.atlassian.net/attachment/2"}
4738 ]
4739 }
4740 }),
4741 ))
4742 .expect(1)
4743 .mount(&server)
4744 .await;
4745
4746 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4747 let attachments = client.get_attachments("PROJ-1").await.unwrap();
4748
4749 assert_eq!(attachments.len(), 2);
4750 assert_eq!(attachments[0].filename, "screenshot.png");
4751 assert_eq!(attachments[0].mime_type, "image/png");
4752 assert_eq!(attachments[0].size, 12345);
4753 assert_eq!(attachments[1].filename, "report.pdf");
4754 }
4755
4756 #[tokio::test]
4757 async fn get_attachments_empty() {
4758 let server = wiremock::MockServer::start().await;
4759 wiremock::Mock::given(wiremock::matchers::method("GET"))
4760 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4761 .respond_with(
4762 wiremock::ResponseTemplate::new(200)
4763 .set_body_json(serde_json::json!({"fields": {"attachment": []}})),
4764 )
4765 .expect(1)
4766 .mount(&server)
4767 .await;
4768
4769 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4770 let attachments = client.get_attachments("PROJ-1").await.unwrap();
4771 assert!(attachments.is_empty());
4772 }
4773
4774 #[tokio::test]
4775 async fn get_attachments_api_error() {
4776 let server = wiremock::MockServer::start().await;
4777 wiremock::Mock::given(wiremock::matchers::method("GET"))
4778 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
4779 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4780 .expect(1)
4781 .mount(&server)
4782 .await;
4783
4784 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4785 let err = client.get_attachments("NOPE-1").await.unwrap_err();
4786 assert!(err.to_string().contains("404"));
4787 }
4788
4789 #[tokio::test]
4790 async fn upload_attachments_success() {
4791 let server = wiremock::MockServer::start().await;
4792 wiremock::Mock::given(wiremock::matchers::method("POST"))
4793 .and(wiremock::matchers::path(
4794 "/rest/api/3/issue/PROJ-1/attachments",
4795 ))
4796 .and(wiremock::matchers::header("X-Atlassian-Token", "no-check"))
4797 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
4798 {"id": "10001", "filename": "log.txt", "mimeType": "text/plain", "size": 5, "content": "https://org.atlassian.net/attachment/10001"},
4799 {"id": "10002", "filename": "shot.png", "mimeType": "image/png", "size": 4, "content": "https://org.atlassian.net/attachment/10002"}
4800 ])))
4801 .expect(1)
4802 .mount(&server)
4803 .await;
4804
4805 let dir = tempfile::tempdir().unwrap();
4806 let a = dir.path().join("log.txt");
4807 let b = dir.path().join("shot.png");
4808 std::fs::write(&a, b"hello").unwrap();
4809 std::fs::write(&b, b"\x89PNG").unwrap();
4810
4811 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4812 let created = client.upload_attachments("PROJ-1", &[a, b]).await.unwrap();
4813
4814 assert_eq!(created.len(), 2);
4815 assert_eq!(created[0].id, "10001");
4816 assert_eq!(created[0].filename, "log.txt");
4817 assert_eq!(created[1].mime_type, "image/png");
4818 }
4819
4820 #[tokio::test]
4821 async fn upload_attachments_api_error() {
4822 let server = wiremock::MockServer::start().await;
4823 wiremock::Mock::given(wiremock::matchers::method("POST"))
4824 .and(wiremock::matchers::path(
4825 "/rest/api/3/issue/PROJ-1/attachments",
4826 ))
4827 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4828 .expect(1)
4829 .mount(&server)
4830 .await;
4831
4832 let dir = tempfile::tempdir().unwrap();
4833 let f = dir.path().join("log.txt");
4834 std::fs::write(&f, b"hello").unwrap();
4835
4836 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4837 let err = client.upload_attachments("PROJ-1", &[f]).await.unwrap_err();
4838 assert!(err.to_string().contains("403"));
4839 }
4840
4841 #[tokio::test]
4842 async fn upload_attachments_rejects_path_without_filename() {
4843 let dir = tempfile::tempdir().unwrap();
4847 let no_name = dir.path().join("..");
4848 let client =
4849 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
4850 let err = client
4851 .upload_attachments("PROJ-1", &[no_name])
4852 .await
4853 .unwrap_err();
4854 assert!(err.to_string().contains("no filename component"));
4855 }
4856
4857 #[tokio::test]
4858 async fn delete_attachment_success() {
4859 let server = wiremock::MockServer::start().await;
4860 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4861 .and(wiremock::matchers::path("/rest/api/3/attachment/10042"))
4862 .respond_with(wiremock::ResponseTemplate::new(204))
4863 .expect(1)
4864 .mount(&server)
4865 .await;
4866
4867 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4868 client.delete_attachment("10042").await.unwrap();
4869 }
4870
4871 #[tokio::test]
4872 async fn delete_attachment_api_error() {
4873 let server = wiremock::MockServer::start().await;
4874 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4875 .and(wiremock::matchers::path("/rest/api/3/attachment/nope"))
4876 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4877 .expect(1)
4878 .mount(&server)
4879 .await;
4880
4881 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4882 let err = client.delete_attachment("nope").await.unwrap_err();
4883 assert!(err.to_string().contains("404"));
4884 }
4885
4886 #[tokio::test]
4887 async fn get_changelog_success() {
4888 let server = wiremock::MockServer::start().await;
4889
4890 wiremock::Mock::given(wiremock::matchers::method("GET"))
4891 .and(wiremock::matchers::path(
4892 "/rest/api/3/issue/PROJ-1/changelog",
4893 ))
4894 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4895 serde_json::json!({
4896 "values": [
4897 {
4898 "id": "100",
4899 "author": {"displayName": "Alice"},
4900 "created": "2026-04-01T10:00:00.000+0000",
4901 "items": [
4902 {"field": "status", "fromString": "Open", "toString": "In Progress"},
4903 {"field": "assignee", "fromString": null, "toString": "Bob"}
4904 ]
4905 },
4906 {
4907 "id": "101",
4908 "author": null,
4909 "created": "2026-04-02T14:00:00.000+0000",
4910 "items": [{"field": "priority", "fromString": "Medium", "toString": "High"}]
4911 }
4912 ],
4913 "isLast": true
4914 }),
4915 ))
4916 .expect(1)
4917 .mount(&server)
4918 .await;
4919
4920 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4921 let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
4922
4923 assert_eq!(entries.len(), 2);
4924 assert_eq!(entries[0].id, "100");
4925 assert_eq!(entries[0].author, "Alice");
4926 assert_eq!(entries[0].items.len(), 2);
4927 assert_eq!(entries[0].items[0].field, "status");
4928 assert_eq!(entries[0].items[0].from_string.as_deref(), Some("Open"));
4929 assert_eq!(
4930 entries[0].items[0].to_string.as_deref(),
4931 Some("In Progress")
4932 );
4933 assert_eq!(entries[0].items[1].from_string, None);
4934 assert_eq!(entries[1].author, "");
4935 }
4936
4937 #[tokio::test]
4938 async fn get_changelog_empty() {
4939 let server = wiremock::MockServer::start().await;
4940
4941 wiremock::Mock::given(wiremock::matchers::method("GET"))
4942 .and(wiremock::matchers::path(
4943 "/rest/api/3/issue/PROJ-1/changelog",
4944 ))
4945 .respond_with(
4946 wiremock::ResponseTemplate::new(200)
4947 .set_body_json(serde_json::json!({"values": []})),
4948 )
4949 .expect(1)
4950 .mount(&server)
4951 .await;
4952
4953 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4954 let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
4955 assert!(entries.is_empty());
4956 }
4957
4958 #[tokio::test]
4959 async fn get_changelog_api_error() {
4960 let server = wiremock::MockServer::start().await;
4961
4962 wiremock::Mock::given(wiremock::matchers::method("GET"))
4963 .and(wiremock::matchers::path(
4964 "/rest/api/3/issue/NOPE-1/changelog",
4965 ))
4966 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4967 .expect(1)
4968 .mount(&server)
4969 .await;
4970
4971 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4972 let err = client.get_changelog("NOPE-1", 50).await.unwrap_err();
4973 assert!(err.to_string().contains("404"));
4974 }
4975
4976 #[tokio::test]
4977 async fn get_fields_success() {
4978 let server = wiremock::MockServer::start().await;
4979
4980 wiremock::Mock::given(wiremock::matchers::method("GET"))
4981 .and(wiremock::matchers::path("/rest/api/3/field"))
4982 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4983 serde_json::json!([
4984 {"id": "summary", "name": "Summary", "custom": false, "schema": {"type": "string"}},
4985 {"id": "customfield_10001", "name": "Story Points", "custom": true, "schema": {"type": "number"}},
4986 {"id": "labels", "name": "Labels", "custom": false},
4987 {
4988 "id": "customfield_10088",
4989 "name": "Acceptance Criteria",
4990 "custom": true,
4991 "schema": {
4992 "type": "string",
4993 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:textarea"
4994 }
4995 }
4996 ]),
4997 ))
4998 .expect(1)
4999 .mount(&server)
5000 .await;
5001
5002 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5003 let fields = client.get_fields().await.unwrap();
5004
5005 assert_eq!(fields.len(), 4);
5006 assert_eq!(fields[0].id, "summary");
5007 assert_eq!(fields[0].name, "Summary");
5008 assert!(!fields[0].custom);
5009 assert_eq!(fields[0].schema_type.as_deref(), Some("string"));
5010 assert!(fields[0].schema_custom.is_none());
5011 assert_eq!(fields[1].id, "customfield_10001");
5012 assert!(fields[1].custom);
5013 assert_eq!(fields[1].schema_type.as_deref(), Some("number"));
5014 assert!(fields[1].schema_custom.is_none());
5015 assert!(fields[2].schema_type.is_none());
5016 assert!(fields[2].schema_custom.is_none());
5017 assert_eq!(fields[3].id, "customfield_10088");
5018 assert!(fields[3].custom);
5019 assert_eq!(fields[3].schema_type.as_deref(), Some("richtext"));
5020 assert_eq!(
5021 fields[3].schema_custom.as_deref(),
5022 Some("com.atlassian.jira.plugin.system.customfieldtypes:textarea")
5023 );
5024 }
5025
5026 #[tokio::test]
5027 async fn get_fields_api_error() {
5028 let server = wiremock::MockServer::start().await;
5029
5030 wiremock::Mock::given(wiremock::matchers::method("GET"))
5031 .and(wiremock::matchers::path("/rest/api/3/field"))
5032 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
5033 .expect(1)
5034 .mount(&server)
5035 .await;
5036
5037 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5038 let err = client.get_fields().await.unwrap_err();
5039 assert!(err.to_string().contains("401"));
5040 }
5041
5042 #[tokio::test]
5043 async fn get_field_contexts_success() {
5044 let server = wiremock::MockServer::start().await;
5045
5046 wiremock::Mock::given(wiremock::matchers::method("GET"))
5047 .and(wiremock::matchers::path(
5048 "/rest/api/3/field/customfield_10001/context",
5049 ))
5050 .respond_with(
5051 wiremock::ResponseTemplate::new(200).set_body_json(
5052 serde_json::json!({"values": [{"id": "12345"}, {"id": "67890"}]}),
5053 ),
5054 )
5055 .expect(1)
5056 .mount(&server)
5057 .await;
5058
5059 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5060 let contexts = client
5061 .get_field_contexts("customfield_10001")
5062 .await
5063 .unwrap();
5064
5065 assert_eq!(contexts.len(), 2);
5066 assert_eq!(contexts[0], "12345");
5067 }
5068
5069 #[tokio::test]
5070 async fn get_field_contexts_api_error() {
5071 let server = wiremock::MockServer::start().await;
5072
5073 wiremock::Mock::given(wiremock::matchers::method("GET"))
5074 .and(wiremock::matchers::path(
5075 "/rest/api/3/field/nonexistent/context",
5076 ))
5077 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
5078 .expect(1)
5079 .mount(&server)
5080 .await;
5081
5082 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5083 let err = client.get_field_contexts("nonexistent").await.unwrap_err();
5084 assert!(err.to_string().contains("404"));
5085 }
5086
5087 #[tokio::test]
5088 async fn get_field_contexts_empty() {
5089 let server = wiremock::MockServer::start().await;
5090
5091 wiremock::Mock::given(wiremock::matchers::method("GET"))
5092 .and(wiremock::matchers::path(
5093 "/rest/api/3/field/customfield_99999/context",
5094 ))
5095 .respond_with(
5096 wiremock::ResponseTemplate::new(200)
5097 .set_body_json(serde_json::json!({"values": []})),
5098 )
5099 .expect(1)
5100 .mount(&server)
5101 .await;
5102
5103 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5104 let contexts = client
5105 .get_field_contexts("customfield_99999")
5106 .await
5107 .unwrap();
5108 assert!(contexts.is_empty());
5109 }
5110
5111 #[tokio::test]
5112 async fn get_field_options_auto_discovers_context() {
5113 let server = wiremock::MockServer::start().await;
5114
5115 wiremock::Mock::given(wiremock::matchers::method("GET"))
5117 .and(wiremock::matchers::path(
5118 "/rest/api/3/field/customfield_10001/context",
5119 ))
5120 .respond_with(
5121 wiremock::ResponseTemplate::new(200)
5122 .set_body_json(serde_json::json!({"values": [{"id": "12345"}]})),
5123 )
5124 .expect(1)
5125 .mount(&server)
5126 .await;
5127
5128 wiremock::Mock::given(wiremock::matchers::method("GET"))
5130 .and(wiremock::matchers::path(
5131 "/rest/api/3/field/customfield_10001/context/12345/option",
5132 ))
5133 .respond_with(
5134 wiremock::ResponseTemplate::new(200)
5135 .set_body_json(serde_json::json!({"values": [{"id": "1", "value": "High"}]})),
5136 )
5137 .expect(1)
5138 .mount(&server)
5139 .await;
5140
5141 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5142 let options = client
5143 .get_field_options("customfield_10001", None)
5144 .await
5145 .unwrap();
5146
5147 assert_eq!(options.len(), 1);
5148 assert_eq!(options[0].value, "High");
5149 }
5150
5151 #[tokio::test]
5152 async fn get_field_options_no_context_errors() {
5153 let server = wiremock::MockServer::start().await;
5154
5155 wiremock::Mock::given(wiremock::matchers::method("GET"))
5156 .and(wiremock::matchers::path(
5157 "/rest/api/3/field/customfield_99999/context",
5158 ))
5159 .respond_with(
5160 wiremock::ResponseTemplate::new(200)
5161 .set_body_json(serde_json::json!({"values": []})),
5162 )
5163 .expect(1)
5164 .mount(&server)
5165 .await;
5166
5167 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5168 let err = client
5169 .get_field_options("customfield_99999", None)
5170 .await
5171 .unwrap_err();
5172 assert!(err.to_string().contains("No contexts found"));
5173 }
5174
5175 #[tokio::test]
5176 async fn get_field_options_with_explicit_context() {
5177 let server = wiremock::MockServer::start().await;
5178
5179 wiremock::Mock::given(wiremock::matchers::method("GET"))
5180 .and(wiremock::matchers::path(
5181 "/rest/api/3/field/customfield_10001/context/12345/option",
5182 ))
5183 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
5184 serde_json::json!({"values": [
5185 {"id": "1", "value": "High"},
5186 {"id": "2", "value": "Medium"},
5187 {"id": "3", "value": "Low"}
5188 ]}),
5189 ))
5190 .expect(1)
5191 .mount(&server)
5192 .await;
5193
5194 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5195 let options = client
5196 .get_field_options("customfield_10001", Some("12345"))
5197 .await
5198 .unwrap();
5199
5200 assert_eq!(options.len(), 3);
5201 assert_eq!(options[0].id, "1");
5202 assert_eq!(options[0].value, "High");
5203 }
5204
5205 #[tokio::test]
5206 async fn get_field_options_with_context() {
5207 let server = wiremock::MockServer::start().await;
5208
5209 wiremock::Mock::given(wiremock::matchers::method("GET"))
5210 .and(wiremock::matchers::path(
5211 "/rest/api/3/field/customfield_10001/context/12345/option",
5212 ))
5213 .respond_with(
5214 wiremock::ResponseTemplate::new(200).set_body_json(
5215 serde_json::json!({"values": [{"id": "1", "value": "Option A"}]}),
5216 ),
5217 )
5218 .expect(1)
5219 .mount(&server)
5220 .await;
5221
5222 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5223 let options = client
5224 .get_field_options("customfield_10001", Some("12345"))
5225 .await
5226 .unwrap();
5227
5228 assert_eq!(options.len(), 1);
5229 assert_eq!(options[0].value, "Option A");
5230 }
5231
5232 #[tokio::test]
5233 async fn get_field_options_api_error() {
5234 let server = wiremock::MockServer::start().await;
5235
5236 wiremock::Mock::given(wiremock::matchers::method("GET"))
5237 .and(wiremock::matchers::path(
5238 "/rest/api/3/field/nonexistent/context/99999/option",
5239 ))
5240 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
5241 .expect(1)
5242 .mount(&server)
5243 .await;
5244
5245 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5246 let err = client
5247 .get_field_options("nonexistent", Some("99999"))
5248 .await
5249 .unwrap_err();
5250 assert!(err.to_string().contains("404"));
5251 }
5252
5253 #[tokio::test]
5254 async fn get_projects_success() {
5255 let server = wiremock::MockServer::start().await;
5256
5257 wiremock::Mock::given(wiremock::matchers::method("GET"))
5258 .and(wiremock::matchers::path("/rest/api/3/project/search"))
5259 .respond_with(
5260 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5261 "values": [
5262 {
5263 "id": "10001",
5264 "key": "PROJ",
5265 "name": "My Project",
5266 "projectTypeKey": "software",
5267 "lead": {"displayName": "Alice"}
5268 },
5269 {
5270 "id": "10002",
5271 "key": "OPS",
5272 "name": "Operations",
5273 "projectTypeKey": "business",
5274 "lead": null
5275 }
5276 ],
5277 "total": 2, "isLast": true
5278 })),
5279 )
5280 .expect(1)
5281 .mount(&server)
5282 .await;
5283
5284 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5285 let result = client.get_projects(50).await.unwrap();
5286
5287 assert_eq!(result.total, 2);
5288 assert_eq!(result.projects.len(), 2);
5289 assert_eq!(result.projects[0].key, "PROJ");
5290 assert_eq!(result.projects[0].name, "My Project");
5291 assert_eq!(result.projects[0].project_type.as_deref(), Some("software"));
5292 assert_eq!(result.projects[0].lead.as_deref(), Some("Alice"));
5293 assert_eq!(result.projects[1].key, "OPS");
5294 assert!(result.projects[1].lead.is_none());
5295 }
5296
5297 #[tokio::test]
5298 async fn get_projects_empty() {
5299 let server = wiremock::MockServer::start().await;
5300
5301 wiremock::Mock::given(wiremock::matchers::method("GET"))
5302 .and(wiremock::matchers::path("/rest/api/3/project/search"))
5303 .respond_with(
5304 wiremock::ResponseTemplate::new(200)
5305 .set_body_json(serde_json::json!({"values": [], "total": 0})),
5306 )
5307 .expect(1)
5308 .mount(&server)
5309 .await;
5310
5311 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5312 let result = client.get_projects(50).await.unwrap();
5313 assert_eq!(result.total, 0);
5314 assert!(result.projects.is_empty());
5315 }
5316
5317 #[tokio::test]
5318 async fn get_projects_api_error() {
5319 let server = wiremock::MockServer::start().await;
5320
5321 wiremock::Mock::given(wiremock::matchers::method("GET"))
5322 .and(wiremock::matchers::path("/rest/api/3/project/search"))
5323 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
5324 .expect(1)
5325 .mount(&server)
5326 .await;
5327
5328 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5329 let err = client.get_projects(50).await.unwrap_err();
5330 assert!(err.to_string().contains("403"));
5331 }
5332
5333 #[tokio::test]
5334 async fn delete_issue_success() {
5335 let server = wiremock::MockServer::start().await;
5336
5337 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
5338 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
5339 .respond_with(wiremock::ResponseTemplate::new(204))
5340 .expect(1)
5341 .mount(&server)
5342 .await;
5343
5344 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5345 let result = client.delete_issue("PROJ-42").await;
5346 assert!(result.is_ok());
5347 }
5348
5349 #[tokio::test]
5350 async fn delete_issue_not_found() {
5351 let server = wiremock::MockServer::start().await;
5352
5353 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
5354 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
5355 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
5356 .expect(1)
5357 .mount(&server)
5358 .await;
5359
5360 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5361 let err = client.delete_issue("NOPE-1").await.unwrap_err();
5362 assert!(err.to_string().contains("404"));
5363 }
5364
5365 #[tokio::test]
5366 async fn delete_issue_forbidden() {
5367 let server = wiremock::MockServer::start().await;
5368
5369 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
5370 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
5371 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
5372 .expect(1)
5373 .mount(&server)
5374 .await;
5375
5376 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5377 let err = client.delete_issue("PROJ-1").await.unwrap_err();
5378 assert!(err.to_string().contains("403"));
5379 }
5380
5381 #[tokio::test]
5384 async fn get_watchers_success() {
5385 let server = wiremock::MockServer::start().await;
5386
5387 wiremock::Mock::given(wiremock::matchers::method("GET"))
5388 .and(wiremock::matchers::path(
5389 "/rest/api/3/issue/PROJ-1/watchers",
5390 ))
5391 .respond_with(
5392 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5393 "watchCount": 2,
5394 "watchers": [
5395 {
5396 "accountId": "abc123",
5397 "displayName": "Alice",
5398 "emailAddress": "alice@example.com"
5399 },
5400 {
5401 "accountId": "def456",
5402 "displayName": "Bob"
5403 }
5404 ]
5405 })),
5406 )
5407 .expect(1)
5408 .mount(&server)
5409 .await;
5410
5411 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5412 let result = client.get_watchers("PROJ-1").await.unwrap();
5413
5414 assert_eq!(result.watch_count, 2);
5415 assert_eq!(result.watchers.len(), 2);
5416 assert_eq!(result.watchers[0].display_name, "Alice");
5417 assert_eq!(result.watchers[0].account_id, "abc123");
5418 assert_eq!(
5419 result.watchers[0].email_address.as_deref(),
5420 Some("alice@example.com")
5421 );
5422 assert_eq!(result.watchers[1].display_name, "Bob");
5423 assert!(result.watchers[1].email_address.is_none());
5424 }
5425
5426 #[tokio::test]
5427 async fn get_watchers_empty() {
5428 let server = wiremock::MockServer::start().await;
5429
5430 wiremock::Mock::given(wiremock::matchers::method("GET"))
5431 .and(wiremock::matchers::path(
5432 "/rest/api/3/issue/PROJ-1/watchers",
5433 ))
5434 .respond_with(
5435 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5436 "watchCount": 0,
5437 "watchers": []
5438 })),
5439 )
5440 .expect(1)
5441 .mount(&server)
5442 .await;
5443
5444 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5445 let result = client.get_watchers("PROJ-1").await.unwrap();
5446
5447 assert_eq!(result.watch_count, 0);
5448 assert!(result.watchers.is_empty());
5449 }
5450
5451 #[tokio::test]
5452 async fn get_watchers_api_error() {
5453 let server = wiremock::MockServer::start().await;
5454
5455 wiremock::Mock::given(wiremock::matchers::method("GET"))
5456 .and(wiremock::matchers::path(
5457 "/rest/api/3/issue/NOPE-1/watchers",
5458 ))
5459 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
5460 .expect(1)
5461 .mount(&server)
5462 .await;
5463
5464 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5465 let err = client.get_watchers("NOPE-1").await.unwrap_err();
5466 assert!(err.to_string().contains("404"));
5467 }
5468
5469 #[tokio::test]
5472 async fn add_watcher_success() {
5473 let server = wiremock::MockServer::start().await;
5474
5475 wiremock::Mock::given(wiremock::matchers::method("POST"))
5476 .and(wiremock::matchers::path(
5477 "/rest/api/3/issue/PROJ-1/watchers",
5478 ))
5479 .and(wiremock::matchers::body_json(serde_json::json!("abc123")))
5480 .respond_with(wiremock::ResponseTemplate::new(204))
5481 .expect(1)
5482 .mount(&server)
5483 .await;
5484
5485 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5486 let result = client.add_watcher("PROJ-1", "abc123").await;
5487 assert!(result.is_ok());
5488 }
5489
5490 #[tokio::test]
5491 async fn add_watcher_api_error() {
5492 let server = wiremock::MockServer::start().await;
5493
5494 wiremock::Mock::given(wiremock::matchers::method("POST"))
5495 .and(wiremock::matchers::path(
5496 "/rest/api/3/issue/PROJ-1/watchers",
5497 ))
5498 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
5499 .expect(1)
5500 .mount(&server)
5501 .await;
5502
5503 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5504 let err = client.add_watcher("PROJ-1", "abc123").await.unwrap_err();
5505 assert!(err.to_string().contains("403"));
5506 }
5507
5508 #[tokio::test]
5511 async fn remove_watcher_success() {
5512 let server = wiremock::MockServer::start().await;
5513
5514 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
5515 .and(wiremock::matchers::path(
5516 "/rest/api/3/issue/PROJ-1/watchers",
5517 ))
5518 .and(wiremock::matchers::query_param("accountId", "abc123"))
5519 .respond_with(wiremock::ResponseTemplate::new(204))
5520 .expect(1)
5521 .mount(&server)
5522 .await;
5523
5524 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5525 let result = client.remove_watcher("PROJ-1", "abc123").await;
5526 assert!(result.is_ok());
5527 }
5528
5529 #[tokio::test]
5530 async fn remove_watcher_api_error() {
5531 let server = wiremock::MockServer::start().await;
5532
5533 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
5534 .and(wiremock::matchers::path(
5535 "/rest/api/3/issue/PROJ-1/watchers",
5536 ))
5537 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
5538 .expect(1)
5539 .mount(&server)
5540 .await;
5541
5542 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5543 let err = client.remove_watcher("PROJ-1", "abc123").await.unwrap_err();
5544 assert!(err.to_string().contains("404"));
5545 }
5546
5547 #[tokio::test]
5548 async fn get_myself_success() {
5549 let server = wiremock::MockServer::start().await;
5550
5551 wiremock::Mock::given(wiremock::matchers::method("GET"))
5552 .and(wiremock::matchers::path("/rest/api/3/myself"))
5553 .respond_with(
5554 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5555 "displayName": "Alice Smith",
5556 "emailAddress": "alice@example.com",
5557 "accountId": "abc123"
5558 })),
5559 )
5560 .expect(1)
5561 .mount(&server)
5562 .await;
5563
5564 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5565 let user = client.get_myself().await.unwrap();
5566 assert_eq!(user.display_name, "Alice Smith");
5567 assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
5568 assert_eq!(user.account_id, "abc123");
5569 }
5570
5571 #[tokio::test]
5572 async fn get_myself_api_error() {
5573 let server = wiremock::MockServer::start().await;
5574
5575 wiremock::Mock::given(wiremock::matchers::method("GET"))
5576 .and(wiremock::matchers::path("/rest/api/3/myself"))
5577 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
5578 .expect(1)
5579 .mount(&server)
5580 .await;
5581
5582 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5583 let err = client.get_myself().await.unwrap_err();
5584 assert!(err.to_string().contains("401"));
5585 }
5586
5587 #[tokio::test]
5590 async fn get_issue_id_success() {
5591 let server = wiremock::MockServer::start().await;
5592
5593 wiremock::Mock::given(wiremock::matchers::method("GET"))
5594 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
5595 .respond_with(
5596 wiremock::ResponseTemplate::new(200).set_body_json(
5597 serde_json::json!({"id": "12345", "key": "PROJ-1", "fields": {}}),
5598 ),
5599 )
5600 .expect(1)
5601 .mount(&server)
5602 .await;
5603
5604 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5605 let id = client.get_issue_id("PROJ-1").await.unwrap();
5606 assert_eq!(id, "12345");
5607 }
5608
5609 #[tokio::test]
5610 async fn get_issue_id_api_error() {
5611 let server = wiremock::MockServer::start().await;
5612
5613 wiremock::Mock::given(wiremock::matchers::method("GET"))
5614 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
5615 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
5616 .expect(1)
5617 .mount(&server)
5618 .await;
5619
5620 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5621 let err = client.get_issue_id("NOPE-1").await.unwrap_err();
5622 assert!(err.to_string().contains("404"));
5623 }
5624
5625 #[tokio::test]
5628 async fn get_dev_status_summary_success() {
5629 let server = wiremock::MockServer::start().await;
5630
5631 wiremock::Mock::given(wiremock::matchers::method("GET"))
5633 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
5634 .respond_with(
5635 wiremock::ResponseTemplate::new(200).set_body_json(
5636 serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
5637 ),
5638 )
5639 .mount(&server)
5640 .await;
5641
5642 wiremock::Mock::given(wiremock::matchers::method("GET"))
5644 .and(wiremock::matchers::path(
5645 "/rest/dev-status/1.0/issue/summary",
5646 ))
5647 .respond_with(
5648 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5649 "summary": {
5650 "pullrequest": {
5651 "overall": {"count": 2},
5652 "byInstanceType": {"GitHub": {"count": 2, "name": "GitHub"}}
5653 },
5654 "branch": {
5655 "overall": {"count": 1},
5656 "byInstanceType": {"GitHub": {"count": 1, "name": "GitHub"}}
5657 },
5658 "repository": {
5659 "overall": {"count": 1},
5660 "byInstanceType": {}
5661 }
5662 }
5663 })),
5664 )
5665 .expect(1)
5666 .mount(&server)
5667 .await;
5668
5669 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5670 let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
5671 assert_eq!(summary.pullrequest.count, 2);
5672 assert_eq!(
5673 summary.pullrequest.providers,
5674 vec![JiraDevProvider {
5675 instance_type: "GitHub".to_string(),
5676 name: "GitHub".to_string(),
5677 }]
5678 );
5679 assert_eq!(summary.branch.count, 1);
5680 assert_eq!(summary.repository.count, 1);
5681 assert!(summary.repository.providers.is_empty());
5682 }
5683
5684 #[tokio::test]
5685 async fn get_dev_status_summary_api_error() {
5686 let server = wiremock::MockServer::start().await;
5687
5688 wiremock::Mock::given(wiremock::matchers::method("GET"))
5689 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
5690 .respond_with(
5691 wiremock::ResponseTemplate::new(200).set_body_json(
5692 serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
5693 ),
5694 )
5695 .mount(&server)
5696 .await;
5697
5698 wiremock::Mock::given(wiremock::matchers::method("GET"))
5699 .and(wiremock::matchers::path(
5700 "/rest/dev-status/1.0/issue/summary",
5701 ))
5702 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
5703 .expect(1)
5704 .mount(&server)
5705 .await;
5706
5707 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5708 let err = client.get_dev_status_summary("PROJ-1").await.unwrap_err();
5709 assert!(err.to_string().contains("403"));
5710 }
5711
5712 async fn mount_issue_id_mock(server: &wiremock::MockServer) {
5716 wiremock::Mock::given(wiremock::matchers::method("GET"))
5717 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
5718 .respond_with(
5719 wiremock::ResponseTemplate::new(200).set_body_json(
5720 serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
5721 ),
5722 )
5723 .mount(server)
5724 .await;
5725 }
5726
5727 async fn mount_summary_mock(server: &wiremock::MockServer) {
5729 wiremock::Mock::given(wiremock::matchers::method("GET"))
5730 .and(wiremock::matchers::path(
5731 "/rest/dev-status/1.0/issue/summary",
5732 ))
5733 .respond_with(
5734 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5735 "summary": {
5736 "pullrequest": {
5737 "overall": {"count": 1},
5738 "byInstanceType": {"GitHub": {"count": 1, "name": "GitHub"}}
5739 },
5740 "branch": {
5741 "overall": {"count": 0},
5742 "byInstanceType": {}
5743 },
5744 "repository": {
5745 "overall": {"count": 0},
5746 "byInstanceType": {}
5747 }
5748 }
5749 })),
5750 )
5751 .mount(server)
5752 .await;
5753 }
5754
5755 fn dev_status_detail_response() -> serde_json::Value {
5756 serde_json::json!({
5757 "detail": [{
5758 "pullRequests": [{
5759 "id": "#42",
5760 "name": "Fix login bug",
5761 "status": "MERGED",
5762 "url": "https://github.com/org/repo/pull/42",
5763 "repositoryName": "org/repo",
5764 "source": {"branch": "fix-login"},
5765 "destination": {"branch": "main"},
5766 "author": {"name": "Alice"},
5767 "reviewers": [{"name": "Bob"}],
5768 "commentCount": 3,
5769 "lastUpdate": "2024-01-15T10:30:00.000+0000"
5770 }],
5771 "branches": [{
5772 "name": "fix-login",
5773 "url": "https://github.com/org/repo/tree/fix-login",
5774 "repositoryName": "org/repo",
5775 "createPullRequestUrl": "https://github.com/org/repo/compare/fix-login",
5776 "lastCommit": {
5777 "id": "abc123def456",
5778 "displayId": "abc123d",
5779 "message": "Fix the login",
5780 "author": {"name": "Alice"},
5781 "authorTimestamp": "2024-01-14T08:00:00.000+0000",
5782 "url": "https://github.com/org/repo/commit/abc123d",
5783 "fileCount": 2,
5784 "merge": false
5785 }
5786 }],
5787 "repositories": [{
5788 "name": "org/repo",
5789 "url": "https://github.com/org/repo",
5790 "commits": [{
5791 "id": "abc123def456",
5792 "displayId": "abc123d",
5793 "message": "Fix the login",
5794 "author": {"name": "Alice"},
5795 "authorTimestamp": "2024-01-14T08:00:00.000+0000",
5796 "url": "https://github.com/org/repo/commit/abc123d",
5797 "fileCount": 2,
5798 "merge": false
5799 }]
5800 }],
5801 "_instance": {"name": "GitHub", "type": "GitHub"}
5802 }]
5803 })
5804 }
5805
5806 #[tokio::test]
5807 async fn get_dev_status_pullrequest_fields() {
5808 let server = wiremock::MockServer::start().await;
5809 mount_issue_id_mock(&server).await;
5810
5811 wiremock::Mock::given(wiremock::matchers::method("GET"))
5812 .and(wiremock::matchers::path(
5813 "/rest/dev-status/1.0/issue/detail",
5814 ))
5815 .and(wiremock::matchers::query_param("dataType", "pullrequest"))
5816 .respond_with(
5817 wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5818 )
5819 .mount(&server)
5820 .await;
5821
5822 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5823 let status = client
5824 .get_dev_status("PROJ-1", Some("pullrequest"), Some("GitHub"))
5825 .await
5826 .unwrap();
5827
5828 assert_eq!(status.pull_requests.len(), 1);
5829 let pr = &status.pull_requests[0];
5830 assert_eq!(pr.id, "#42");
5831 assert_eq!(pr.status, "MERGED");
5832 assert_eq!(pr.author.as_deref(), Some("Alice"));
5833 assert_eq!(pr.reviewers, vec!["Bob"]);
5834 assert_eq!(pr.comment_count, Some(3));
5835 assert!(pr.last_update.is_some());
5836 assert_eq!(pr.source_branch, "fix-login");
5837 assert_eq!(pr.destination_branch, "main");
5838 }
5839
5840 #[tokio::test]
5841 async fn get_dev_status_branch_fields() {
5842 let server = wiremock::MockServer::start().await;
5843 mount_issue_id_mock(&server).await;
5844
5845 wiremock::Mock::given(wiremock::matchers::method("GET"))
5846 .and(wiremock::matchers::path(
5847 "/rest/dev-status/1.0/issue/detail",
5848 ))
5849 .and(wiremock::matchers::query_param("dataType", "branch"))
5850 .respond_with(
5851 wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5852 )
5853 .mount(&server)
5854 .await;
5855
5856 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5857 let status = client
5858 .get_dev_status("PROJ-1", Some("branch"), Some("GitHub"))
5859 .await
5860 .unwrap();
5861
5862 assert_eq!(status.branches.len(), 1);
5863 let branch = &status.branches[0];
5864 assert_eq!(branch.name, "fix-login");
5865 assert!(branch.create_pr_url.is_some());
5866 let commit = branch.last_commit.as_ref().unwrap();
5867 assert_eq!(commit.display_id, "abc123d");
5868 assert_eq!(commit.file_count, 2);
5869 assert!(!commit.merge);
5870 }
5871
5872 #[tokio::test]
5873 async fn get_dev_status_repository_with_commits() {
5874 let server = wiremock::MockServer::start().await;
5875 mount_issue_id_mock(&server).await;
5876
5877 wiremock::Mock::given(wiremock::matchers::method("GET"))
5878 .and(wiremock::matchers::path(
5879 "/rest/dev-status/1.0/issue/detail",
5880 ))
5881 .and(wiremock::matchers::query_param("dataType", "repository"))
5882 .respond_with(
5883 wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5884 )
5885 .mount(&server)
5886 .await;
5887
5888 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5889 let status = client
5890 .get_dev_status("PROJ-1", Some("repository"), Some("GitHub"))
5891 .await
5892 .unwrap();
5893
5894 assert_eq!(status.repositories.len(), 1);
5895 assert_eq!(status.repositories[0].commits.len(), 1);
5896 assert_eq!(status.repositories[0].commits[0].display_id, "abc123d");
5897 assert_eq!(
5898 status.repositories[0].commits[0].author.as_deref(),
5899 Some("Alice")
5900 );
5901 }
5902
5903 #[tokio::test]
5904 async fn get_dev_status_auto_discovers_providers() {
5905 let server = wiremock::MockServer::start().await;
5906 mount_issue_id_mock(&server).await;
5907 mount_summary_mock(&server).await;
5908
5909 wiremock::Mock::given(wiremock::matchers::method("GET"))
5910 .and(wiremock::matchers::path(
5911 "/rest/dev-status/1.0/issue/detail",
5912 ))
5913 .respond_with(
5914 wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5915 )
5916 .mount(&server)
5917 .await;
5918
5919 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5920 let status = client
5921 .get_dev_status("PROJ-1", Some("pullrequest"), None)
5922 .await
5923 .unwrap();
5924
5925 assert_eq!(status.pull_requests.len(), 1);
5926 assert_eq!(status.pull_requests[0].name, "Fix login bug");
5927 }
5928
5929 #[tokio::test]
5935 async fn get_dev_status_auto_discovers_bitbucket_server() {
5936 let server = wiremock::MockServer::start().await;
5937 mount_issue_id_mock(&server).await;
5938
5939 wiremock::Mock::given(wiremock::matchers::method("GET"))
5940 .and(wiremock::matchers::path(
5941 "/rest/dev-status/1.0/issue/summary",
5942 ))
5943 .respond_with(
5944 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5945 "summary": {
5946 "pullrequest": {
5947 "overall": {"count": 1},
5948 "byInstanceType": {"stash": {"count": 1, "name": "Bitbucket Server"}}
5949 },
5950 "branch": {"overall": {"count": 0}, "byInstanceType": {}},
5951 "repository": {
5952 "overall": {"count": 1},
5953 "byInstanceType": {"stash": {"count": 1, "name": "Bitbucket Server"}}
5954 }
5955 }
5956 })),
5957 )
5958 .mount(&server)
5959 .await;
5960
5961 wiremock::Mock::given(wiremock::matchers::method("GET"))
5965 .and(wiremock::matchers::path(
5966 "/rest/dev-status/1.0/issue/detail",
5967 ))
5968 .and(wiremock::matchers::query_param("applicationType", "stash"))
5969 .respond_with(
5970 wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5971 )
5972 .mount(&server)
5973 .await;
5974
5975 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5976 let status = client
5977 .get_dev_status("PROJ-1", Some("pullrequest"), None)
5978 .await
5979 .unwrap();
5980
5981 assert_eq!(status.pull_requests.len(), 1);
5982 assert_eq!(status.pull_requests[0].name, "Fix login bug");
5983 }
5984
5985 #[tokio::test]
5990 async fn get_dev_status_summary_keeps_key_and_name() {
5991 let server = wiremock::MockServer::start().await;
5992 mount_issue_id_mock(&server).await;
5993
5994 wiremock::Mock::given(wiremock::matchers::method("GET"))
5995 .and(wiremock::matchers::path(
5996 "/rest/dev-status/1.0/issue/summary",
5997 ))
5998 .respond_with(
5999 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6000 "summary": {
6001 "pullrequest": {
6002 "overall": {"count": 1},
6003 "byInstanceType": {"stash": {"count": 1, "name": "Bitbucket Server"}}
6004 },
6005 "branch": {"overall": {"count": 0}, "byInstanceType": {}},
6006 "repository": {"overall": {"count": 0}, "byInstanceType": {}}
6007 }
6008 })),
6009 )
6010 .mount(&server)
6011 .await;
6012
6013 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6014 let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
6015
6016 assert_eq!(
6017 summary.pullrequest.providers,
6018 vec![JiraDevProvider {
6019 instance_type: "stash".to_string(),
6020 name: "Bitbucket Server".to_string(),
6021 }]
6022 );
6023 }
6024
6025 #[tokio::test]
6026 async fn get_dev_status_empty_response() {
6027 let server = wiremock::MockServer::start().await;
6028 mount_issue_id_mock(&server).await;
6029
6030 wiremock::Mock::given(wiremock::matchers::method("GET"))
6031 .and(wiremock::matchers::path(
6032 "/rest/dev-status/1.0/issue/detail",
6033 ))
6034 .respond_with(
6035 wiremock::ResponseTemplate::new(200)
6036 .set_body_json(serde_json::json!({"detail": []})),
6037 )
6038 .mount(&server)
6039 .await;
6040
6041 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6042 let status = client
6043 .get_dev_status("PROJ-1", None, Some("GitHub"))
6044 .await
6045 .unwrap();
6046
6047 assert!(status.pull_requests.is_empty());
6048 assert!(status.branches.is_empty());
6049 assert!(status.repositories.is_empty());
6050 }
6051
6052 #[tokio::test]
6053 async fn get_dev_status_detail_api_error() {
6054 let server = wiremock::MockServer::start().await;
6055 mount_issue_id_mock(&server).await;
6056
6057 wiremock::Mock::given(wiremock::matchers::method("GET"))
6058 .and(wiremock::matchers::path(
6059 "/rest/dev-status/1.0/issue/detail",
6060 ))
6061 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("Server Error"))
6062 .mount(&server)
6063 .await;
6064
6065 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6066 let err = client
6067 .get_dev_status("PROJ-1", Some("pullrequest"), Some("GitHub"))
6068 .await
6069 .unwrap_err();
6070 assert!(err.to_string().contains("500"));
6071 }
6072
6073 #[tokio::test]
6074 async fn get_dev_status_with_data_type_filter() {
6075 let server = wiremock::MockServer::start().await;
6076 mount_issue_id_mock(&server).await;
6077
6078 wiremock::Mock::given(wiremock::matchers::method("GET"))
6080 .and(wiremock::matchers::path(
6081 "/rest/dev-status/1.0/issue/detail",
6082 ))
6083 .and(wiremock::matchers::query_param("dataType", "branch"))
6084 .respond_with(
6085 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6086 "detail": [{
6087 "pullRequests": [],
6088 "branches": [{
6089 "name": "feature-x",
6090 "url": "https://github.com/org/repo/tree/feature-x",
6091 "repositoryName": "org/repo"
6092 }],
6093 "repositories": []
6094 }]
6095 })),
6096 )
6097 .mount(&server)
6098 .await;
6099
6100 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6101 let status = client
6102 .get_dev_status("PROJ-1", Some("branch"), Some("GitHub"))
6103 .await
6104 .unwrap();
6105
6106 assert!(status.pull_requests.is_empty());
6107 assert_eq!(status.branches.len(), 1);
6108 assert_eq!(status.branches[0].name, "feature-x");
6109 assert!(status.branches[0].last_commit.is_none());
6110 assert!(status.branches[0].create_pr_url.is_none());
6111 assert!(status.repositories.is_empty());
6112 }
6113
6114 #[tokio::test]
6115 async fn get_dev_status_summary_empty() {
6116 let server = wiremock::MockServer::start().await;
6117 mount_issue_id_mock(&server).await;
6118
6119 wiremock::Mock::given(wiremock::matchers::method("GET"))
6120 .and(wiremock::matchers::path(
6121 "/rest/dev-status/1.0/issue/summary",
6122 ))
6123 .respond_with(
6124 wiremock::ResponseTemplate::new(200)
6125 .set_body_json(serde_json::json!({"summary": {}})),
6126 )
6127 .expect(1)
6128 .mount(&server)
6129 .await;
6130
6131 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6132 let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
6133 assert_eq!(summary.pullrequest.count, 0);
6134 assert_eq!(summary.branch.count, 0);
6135 assert_eq!(summary.repository.count, 0);
6136 }
6137
6138 #[tokio::test]
6139 async fn convert_commit_maps_all_fields() {
6140 let internal = DevStatusCommit {
6141 id: "abc123".to_string(),
6142 display_id: "abc".to_string(),
6143 message: "Test commit".to_string(),
6144 author: Some(DevStatusAuthor {
6145 name: "Alice".to_string(),
6146 }),
6147 author_timestamp: Some("2024-01-01T00:00:00.000+0000".to_string()),
6148 url: "https://example.com/commit/abc".to_string(),
6149 file_count: 5,
6150 merge: true,
6151 };
6152 let public = AtlassianClient::convert_commit(internal);
6153 assert_eq!(public.id, "abc123");
6154 assert_eq!(public.display_id, "abc");
6155 assert_eq!(public.message, "Test commit");
6156 assert_eq!(public.author.as_deref(), Some("Alice"));
6157 assert!(public.timestamp.is_some());
6158 assert_eq!(public.file_count, 5);
6159 assert!(public.merge);
6160 }
6161
6162 #[tokio::test]
6163 async fn convert_commit_no_author() {
6164 let internal = DevStatusCommit {
6165 id: "def456".to_string(),
6166 display_id: "def".to_string(),
6167 message: "Anonymous".to_string(),
6168 author: None,
6169 author_timestamp: None,
6170 url: "https://example.com/commit/def".to_string(),
6171 file_count: 0,
6172 merge: false,
6173 };
6174 let public = AtlassianClient::convert_commit(internal);
6175 assert!(public.author.is_none());
6176 assert!(public.timestamp.is_none());
6177 }
6178
6179 #[test]
6182 fn extract_worklog_comment_none() {
6183 assert_eq!(AtlassianClient::extract_worklog_comment(None), None);
6184 }
6185
6186 #[test]
6187 fn extract_worklog_comment_valid_adf() {
6188 let adf = serde_json::json!({
6189 "version": 1,
6190 "type": "doc",
6191 "content": [{
6192 "type": "paragraph",
6193 "content": [{"type": "text", "text": "Fixed the login bug"}]
6194 }]
6195 });
6196 let result = AtlassianClient::extract_worklog_comment(Some(&adf));
6197 assert_eq!(result.as_deref(), Some("Fixed the login bug"));
6198 }
6199
6200 #[test]
6201 fn extract_worklog_comment_empty_adf() {
6202 let adf = serde_json::json!({
6203 "version": 1,
6204 "type": "doc",
6205 "content": []
6206 });
6207 let result = AtlassianClient::extract_worklog_comment(Some(&adf));
6208 assert_eq!(result, None);
6209 }
6210
6211 #[test]
6212 fn extract_worklog_comment_invalid_json() {
6213 let invalid = serde_json::json!({"not": "adf"});
6214 let result = AtlassianClient::extract_worklog_comment(Some(&invalid));
6215 assert_eq!(result, None);
6216 }
6217
6218 #[test]
6221 fn worklog_response_deserializes() {
6222 let json = r#"{
6223 "worklogs": [
6224 {
6225 "id": "100",
6226 "author": {"displayName": "Alice"},
6227 "timeSpent": "2h",
6228 "timeSpentSeconds": 7200,
6229 "started": "2026-04-16T09:00:00.000+0000",
6230 "comment": {
6231 "version": 1,
6232 "type": "doc",
6233 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Debugging"}]}]
6234 }
6235 },
6236 {
6237 "id": "101",
6238 "author": {"displayName": "Bob"},
6239 "timeSpent": "1d",
6240 "timeSpentSeconds": 28800,
6241 "started": "2026-04-15T10:00:00.000+0000"
6242 }
6243 ],
6244 "total": 2
6245 }"#;
6246 let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
6247 assert_eq!(resp.total, 2);
6248 assert_eq!(resp.worklogs.len(), 2);
6249 assert_eq!(resp.worklogs[0].id, "100");
6250 assert_eq!(resp.worklogs[0].time_spent.as_deref(), Some("2h"));
6251 assert_eq!(resp.worklogs[0].time_spent_seconds, 7200);
6252 assert!(resp.worklogs[0].comment.is_some());
6253 assert!(resp.worklogs[1].comment.is_none());
6254 }
6255
6256 #[test]
6257 fn worklog_response_empty() {
6258 let json = r#"{"worklogs": [], "total": 0}"#;
6259 let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
6260 assert_eq!(resp.total, 0);
6261 assert!(resp.worklogs.is_empty());
6262 }
6263
6264 #[test]
6265 fn worklog_response_missing_optional_fields() {
6266 let json = r#"{
6267 "worklogs": [{
6268 "id": "200",
6269 "timeSpentSeconds": 3600
6270 }],
6271 "total": 1
6272 }"#;
6273 let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
6274 assert!(resp.worklogs[0].author.is_none());
6275 assert!(resp.worklogs[0].time_spent.is_none());
6276 assert!(resp.worklogs[0].started.is_none());
6277 }
6278
6279 #[tokio::test]
6282 async fn get_worklogs_success() {
6283 let server = wiremock::MockServer::start().await;
6284
6285 let worklog_json = serde_json::json!({
6286 "worklogs": [
6287 {
6288 "id": "100",
6289 "author": {"displayName": "Alice"},
6290 "timeSpent": "2h",
6291 "timeSpentSeconds": 7200,
6292 "started": "2026-04-16T09:00:00.000+0000",
6293 "comment": {
6294 "version": 1,
6295 "type": "doc",
6296 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Debugging login"}]}]
6297 }
6298 },
6299 {
6300 "id": "101",
6301 "author": {"displayName": "Bob"},
6302 "timeSpent": "1d",
6303 "timeSpentSeconds": 28800,
6304 "started": "2026-04-15T10:00:00.000+0000"
6305 }
6306 ],
6307 "total": 2
6308 });
6309
6310 wiremock::Mock::given(wiremock::matchers::method("GET"))
6311 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
6312 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(worklog_json))
6313 .expect(1)
6314 .mount(&server)
6315 .await;
6316
6317 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6318 let result = client.get_worklogs("PROJ-1", 50).await.unwrap();
6319
6320 assert_eq!(result.total, 2);
6321 assert_eq!(result.worklogs.len(), 2);
6322 assert_eq!(result.worklogs[0].author, "Alice");
6323 assert_eq!(result.worklogs[0].time_spent, "2h");
6324 assert_eq!(result.worklogs[0].time_spent_seconds, 7200);
6325 assert_eq!(
6326 result.worklogs[0].comment.as_deref(),
6327 Some("Debugging login")
6328 );
6329 assert_eq!(result.worklogs[1].author, "Bob");
6330 assert_eq!(result.worklogs[1].comment, None);
6331 }
6332
6333 #[tokio::test]
6334 async fn get_worklogs_empty() {
6335 let server = wiremock::MockServer::start().await;
6336
6337 wiremock::Mock::given(wiremock::matchers::method("GET"))
6338 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
6339 .respond_with(
6340 wiremock::ResponseTemplate::new(200)
6341 .set_body_json(serde_json::json!({"worklogs": [], "total": 0})),
6342 )
6343 .expect(1)
6344 .mount(&server)
6345 .await;
6346
6347 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6348 let result = client.get_worklogs("PROJ-1", 50).await.unwrap();
6349
6350 assert_eq!(result.total, 0);
6351 assert!(result.worklogs.is_empty());
6352 }
6353
6354 #[tokio::test]
6355 async fn get_worklogs_api_error() {
6356 let server = wiremock::MockServer::start().await;
6357
6358 wiremock::Mock::given(wiremock::matchers::method("GET"))
6359 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
6360 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
6361 .expect(1)
6362 .mount(&server)
6363 .await;
6364
6365 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6366 let result = client.get_worklogs("PROJ-1", 50).await;
6367 assert!(result.is_err());
6368 }
6369
6370 #[tokio::test]
6371 async fn add_worklog_success() {
6372 let server = wiremock::MockServer::start().await;
6373
6374 wiremock::Mock::given(wiremock::matchers::method("POST"))
6375 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
6376 .respond_with(wiremock::ResponseTemplate::new(201))
6377 .expect(1)
6378 .mount(&server)
6379 .await;
6380
6381 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6382 let result = client.add_worklog("PROJ-1", "2h", None, None).await;
6383 assert!(result.is_ok());
6384 }
6385
6386 #[tokio::test]
6387 async fn add_worklog_with_all_fields() {
6388 let server = wiremock::MockServer::start().await;
6389
6390 wiremock::Mock::given(wiremock::matchers::method("POST"))
6391 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
6392 .respond_with(wiremock::ResponseTemplate::new(201))
6393 .expect(1)
6394 .mount(&server)
6395 .await;
6396
6397 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6398 let result = client
6399 .add_worklog(
6400 "PROJ-1",
6401 "2h 30m",
6402 Some("2026-04-16T09:00:00.000+0000"),
6403 Some("Fixed the bug"),
6404 )
6405 .await;
6406 assert!(result.is_ok());
6407 }
6408
6409 #[tokio::test]
6410 async fn add_worklog_api_error() {
6411 let server = wiremock::MockServer::start().await;
6412
6413 wiremock::Mock::given(wiremock::matchers::method("POST"))
6414 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
6415 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
6416 .expect(1)
6417 .mount(&server)
6418 .await;
6419
6420 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6421 let result = client.add_worklog("PROJ-1", "2h", None, None).await;
6422 assert!(result.is_err());
6423 }
6424
6425 #[tokio::test]
6426 async fn update_worklog_success() {
6427 let server = wiremock::MockServer::start().await;
6428
6429 wiremock::Mock::given(wiremock::matchers::method("PUT"))
6430 .and(wiremock::matchers::path(
6431 "/rest/api/3/issue/PROJ-1/worklog/10010",
6432 ))
6433 .respond_with(
6434 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6435 "id": "10010"
6436 })),
6437 )
6438 .expect(1)
6439 .mount(&server)
6440 .await;
6441
6442 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6443 let result = client
6444 .update_worklog(
6445 "PROJ-1",
6446 "10010",
6447 Some("3h"),
6448 Some("2026-04-16T09:00:00.000+0000"),
6449 Some("Corrected estimate"),
6450 )
6451 .await;
6452 assert!(result.is_ok());
6453 }
6454
6455 #[tokio::test]
6456 async fn update_worklog_api_error() {
6457 let server = wiremock::MockServer::start().await;
6458
6459 wiremock::Mock::given(wiremock::matchers::method("PUT"))
6460 .and(wiremock::matchers::path(
6461 "/rest/api/3/issue/PROJ-1/worklog/9999",
6462 ))
6463 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
6464 .mount(&server)
6465 .await;
6466
6467 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6468 let err = client
6469 .update_worklog("PROJ-1", "9999", Some("1h"), None, None)
6470 .await
6471 .unwrap_err();
6472 assert!(err.to_string().contains("404"));
6473 }
6474
6475 #[tokio::test]
6476 async fn delete_worklog_success() {
6477 let server = wiremock::MockServer::start().await;
6478
6479 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6480 .and(wiremock::matchers::path(
6481 "/rest/api/3/issue/PROJ-1/worklog/10010",
6482 ))
6483 .respond_with(wiremock::ResponseTemplate::new(204))
6484 .expect(1)
6485 .mount(&server)
6486 .await;
6487
6488 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6489 assert!(client.delete_worklog("PROJ-1", "10010").await.is_ok());
6490 }
6491
6492 #[tokio::test]
6493 async fn delete_worklog_api_error() {
6494 let server = wiremock::MockServer::start().await;
6495
6496 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6497 .and(wiremock::matchers::path(
6498 "/rest/api/3/issue/PROJ-1/worklog/9999",
6499 ))
6500 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
6501 .mount(&server)
6502 .await;
6503
6504 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6505 let err = client.delete_worklog("PROJ-1", "9999").await.unwrap_err();
6506 assert!(err.to_string().contains("403"));
6507 }
6508
6509 #[tokio::test]
6510 async fn get_worklogs_respects_limit() {
6511 let server = wiremock::MockServer::start().await;
6512
6513 let worklog_json = serde_json::json!({
6514 "worklogs": [
6515 {"id": "1", "author": {"displayName": "A"}, "timeSpent": "1h", "timeSpentSeconds": 3600, "started": "2026-04-16T09:00:00.000+0000"},
6516 {"id": "2", "author": {"displayName": "B"}, "timeSpent": "2h", "timeSpentSeconds": 7200, "started": "2026-04-16T10:00:00.000+0000"},
6517 {"id": "3", "author": {"displayName": "C"}, "timeSpent": "3h", "timeSpentSeconds": 10800, "started": "2026-04-16T11:00:00.000+0000"}
6518 ],
6519 "total": 3
6520 });
6521
6522 wiremock::Mock::given(wiremock::matchers::method("GET"))
6523 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
6524 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(worklog_json))
6525 .expect(1)
6526 .mount(&server)
6527 .await;
6528
6529 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
6530 let result = client.get_worklogs("PROJ-1", 2).await.unwrap();
6531
6532 assert_eq!(result.worklogs.len(), 2);
6533 assert_eq!(result.total, 3);
6534 }
6535}
6536
6537impl AtlassianClient {
6538 pub fn new(instance_url: &str, email: &str, api_token: &str) -> Result<Self> {
6542 Self::with_auth(
6543 instance_url,
6544 &AtlassianAuth::Basic {
6545 email: email.to_string(),
6546 api_token: api_token.into(),
6547 },
6548 )
6549 }
6550
6551 pub fn with_auth(instance_url: &str, auth: &AtlassianAuth) -> Result<Self> {
6553 auth.validate()?;
6554 let client = Client::builder()
6555 .connect_timeout(connect_timeout())
6556 .read_timeout(read_timeout())
6557 .build()
6558 .context("Failed to build HTTP client")?;
6559 let header = match auth {
6560 AtlassianAuth::Basic { email, api_token } => {
6561 let encoded = base64::engine::general_purpose::STANDARD
6562 .encode(format!("{email}:{}", api_token.expose_secret()));
6563 format!("Basic {encoded}")
6564 }
6565 AtlassianAuth::Bearer { token } => format!("Bearer {}", token.expose_secret()),
6566 };
6567 let mut auth_header =
6568 HeaderValue::from_str(&header).context("Invalid Atlassian authorization header")?;
6569 auth_header.set_sensitive(true);
6570 Ok(Self {
6571 client,
6572 instance_url: instance_url.trim_end_matches('/').to_string(),
6573 auth_header,
6574 auth_mode: auth.mode(),
6575 })
6576 }
6577
6578 pub fn from_credentials(creds: &crate::atlassian::auth::AtlassianCredentials) -> Result<Self> {
6580 Self::with_auth(&creds.instance_url, &creds.auth)
6581 }
6582
6583 #[must_use]
6585 pub fn instance_url(&self) -> &str {
6586 &self.instance_url
6587 }
6588
6589 fn log_request(
6592 &self,
6593 method: &str,
6594 url: &str,
6595 started: Instant,
6596 result: &reqwest::Result<reqwest::Response>,
6597 ) {
6598 let service = if url.contains("/wiki/") || url.ends_with("/rest/api/user/current") {
6599 "confluence"
6600 } else {
6601 "jira"
6602 };
6603 request_log::record_http_result(service, method, url, started, result);
6604 }
6605
6606 pub async fn get_json(&self, url: &str) -> Result<reqwest::Response> {
6611 retry_429(
6612 || {
6613 self.client
6614 .get(url)
6615 .header("Authorization", &self.auth_header)
6616 .header("Accept", "application/json")
6617 },
6618 |started, result| self.log_request("GET", url, started, result),
6619 )
6620 .await
6621 .context("Failed to send GET request to Atlassian API")
6622 }
6623
6624 pub async fn put_json<T: serde::Serialize + Sync + ?Sized>(
6629 &self,
6630 url: &str,
6631 body: &T,
6632 ) -> Result<reqwest::Response> {
6633 retry_429(
6634 || {
6635 self.client
6636 .put(url)
6637 .header("Authorization", &self.auth_header)
6638 .header("Content-Type", "application/json")
6639 .json(body)
6640 },
6641 |started, result| self.log_request("PUT", url, started, result),
6642 )
6643 .await
6644 .context("Failed to send PUT request to Atlassian API")
6645 }
6646
6647 pub async fn post_json<T: serde::Serialize + Sync + ?Sized>(
6649 &self,
6650 url: &str,
6651 body: &T,
6652 ) -> Result<reqwest::Response> {
6653 retry_429(
6654 || {
6655 self.client
6656 .post(url)
6657 .header("Authorization", &self.auth_header)
6658 .header("Content-Type", "application/json")
6659 .json(body)
6660 },
6661 |started, result| self.log_request("POST", url, started, result),
6662 )
6663 .await
6664 .context("Failed to send POST request to Atlassian API")
6665 }
6666
6667 pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
6669 let response = self.get_json_raw_accept(url, "*/*").await?;
6670
6671 let response = Self::ensure_success(response).await?;
6672
6673 let bytes = response
6674 .bytes()
6675 .await
6676 .context("Failed to read response bytes")?;
6677 Ok(bytes.to_vec())
6678 }
6679
6680 pub async fn delete(&self, url: &str) -> Result<reqwest::Response> {
6682 retry_429(
6683 || {
6684 self.client
6685 .delete(url)
6686 .header("Authorization", &self.auth_header)
6687 },
6688 |started, result| self.log_request("DELETE", url, started, result),
6689 )
6690 .await
6691 .context("Failed to send DELETE request to Atlassian API")
6692 }
6693
6694 pub async fn post_multipart(
6699 &self,
6700 url: &str,
6701 form: reqwest::multipart::Form,
6702 extra_headers: &[(&str, &str)],
6703 ) -> Result<reqwest::Response> {
6704 let mut req = self
6705 .client
6706 .post(url)
6707 .header("Authorization", &self.auth_header)
6708 .multipart(form);
6709 for (name, value) in extra_headers {
6710 req = req.header(*name, *value);
6711 }
6712 let started = Instant::now();
6713 let result = req.send().await;
6714 self.log_request("POST", url, started, &result);
6715 result.context("Failed to send multipart POST request to Atlassian API")
6716 }
6717
6718 async fn get_json_raw_accept(&self, url: &str, accept: &str) -> Result<reqwest::Response> {
6720 retry_429(
6721 || {
6722 self.client
6723 .get(url)
6724 .header("Authorization", &self.auth_header)
6725 .header("Accept", accept)
6726 },
6727 |started, result| self.log_request("GET", url, started, result),
6728 )
6729 .await
6730 .context("Failed to send GET request to Atlassian API")
6731 }
6732
6733 pub(crate) async fn ensure_success(response: reqwest::Response) -> Result<reqwest::Response> {
6741 if response.status().is_success() {
6742 return Ok(response);
6743 }
6744 let status = response.status().as_u16();
6745 let body = response.text().await.unwrap_or_default();
6746 Err(AtlassianError::ApiRequestFailed { status, body }.into())
6747 }
6748
6749 pub(crate) async fn parse_json<T: serde::de::DeserializeOwned>(
6757 response: reqwest::Response,
6758 context: &'static str,
6759 ) -> Result<T> {
6760 response.json().await.context(context)
6761 }
6762
6763 pub async fn get_issue(&self, key: &str) -> Result<JiraIssue> {
6769 self.get_issue_with_fields(key, FieldSelection::Standard)
6770 .await
6771 }
6772
6773 pub async fn get_issue_with_fields(
6780 &self,
6781 key: &str,
6782 selection: FieldSelection,
6783 ) -> Result<JiraIssue> {
6784 const STANDARD_FIELDS: &str =
6785 "summary,description,status,issuetype,assignee,priority,labels";
6786
6787 let fields_param = match &selection {
6788 FieldSelection::Standard => STANDARD_FIELDS.to_string(),
6789 FieldSelection::Named(names) => {
6790 let mut parts: Vec<&str> = STANDARD_FIELDS.split(',').collect();
6791 parts.extend(names.iter().map(String::as_str));
6792 parts.join(",")
6793 }
6794 FieldSelection::All => "*all".to_string(),
6795 };
6796
6797 let base = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
6798 let url = reqwest::Url::parse_with_params(
6799 &base,
6800 &[
6801 ("fields", fields_param.as_str()),
6802 ("expand", "names,schema"),
6803 ],
6804 )
6805 .context("Failed to build JIRA issue URL")?;
6806
6807 let response = self
6808 .client
6809 .get(url)
6810 .header("Authorization", &self.auth_header)
6811 .header("Accept", "application/json")
6812 .send()
6813 .await
6814 .context("Failed to send request to JIRA API")?;
6815
6816 let envelope: JiraIssueEnvelope = Self::parse_json(
6817 Self::ensure_success(response).await?,
6818 "Failed to parse JIRA issue response",
6819 )
6820 .await?;
6821
6822 Ok(envelope.into_issue(&selection))
6823 }
6824
6825 pub async fn update_issue(
6830 &self,
6831 key: &str,
6832 description_adf: &ValidatedAdfDocument,
6833 summary: Option<&str>,
6834 ) -> Result<()> {
6835 self.update_issue_with_custom_fields(
6836 key,
6837 Some(description_adf),
6838 summary,
6839 &std::collections::BTreeMap::new(),
6840 )
6841 .await
6842 }
6843
6844 pub async fn update_issue_with_custom_fields(
6854 &self,
6855 key: &str,
6856 description_adf: Option<&ValidatedAdfDocument>,
6857 summary: Option<&str>,
6858 custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
6859 ) -> Result<()> {
6860 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
6861
6862 let mut fields = serde_json::Map::new();
6863 if let Some(adf) = description_adf {
6864 fields.insert(
6865 "description".to_string(),
6866 serde_json::to_value(adf).context("Failed to serialize ADF document")?,
6867 );
6868 }
6869 if let Some(summary_text) = summary {
6870 fields.insert(
6871 "summary".to_string(),
6872 serde_json::Value::String(summary_text.to_string()),
6873 );
6874 }
6875 for (id, value) in custom_fields {
6876 fields.insert(id.clone(), value.clone());
6877 }
6878
6879 if fields.is_empty() {
6880 anyhow::bail!("update_issue_with_custom_fields: no fields to update");
6881 }
6882
6883 let body = serde_json::json!({ "fields": fields });
6884
6885 let response = self
6886 .client
6887 .put(&url)
6888 .header("Authorization", &self.auth_header)
6889 .header("Content-Type", "application/json")
6890 .json(&body)
6891 .send()
6892 .await
6893 .context("Failed to send update request to JIRA API")?;
6894
6895 if !response.status().is_success() {
6896 let status = response.status().as_u16();
6897 let body = response.text().await.unwrap_or_default();
6898 return Err(jira_write_error(status, body));
6899 }
6900
6901 Ok(())
6902 }
6903
6904 pub async fn get_editmeta(&self, key: &str) -> Result<EditMeta> {
6910 let url = format!("{}/rest/api/3/issue/{}/editmeta", self.instance_url, key);
6911
6912 let response = self
6913 .client
6914 .get(&url)
6915 .header("Authorization", &self.auth_header)
6916 .header("Accept", "application/json")
6917 .send()
6918 .await
6919 .context("Failed to send editmeta request to JIRA API")?;
6920
6921 let raw: JiraEditMetaResponse = Self::parse_json(
6922 Self::ensure_success(response).await?,
6923 "Failed to parse JIRA editmeta response",
6924 )
6925 .await?;
6926
6927 Ok(edit_meta_from_raw_fields(raw.fields))
6928 }
6929
6930 pub async fn create_issue(
6935 &self,
6936 project_key: &str,
6937 issue_type: &str,
6938 summary: &str,
6939 description_adf: Option<&ValidatedAdfDocument>,
6940 labels: &[String],
6941 ) -> Result<JiraCreatedIssue> {
6942 self.create_issue_with_custom_fields(
6943 project_key,
6944 issue_type,
6945 summary,
6946 description_adf,
6947 labels,
6948 &std::collections::BTreeMap::new(),
6949 )
6950 .await
6951 }
6952
6953 pub async fn create_issue_with_custom_fields(
6956 &self,
6957 project_key: &str,
6958 issue_type: &str,
6959 summary: &str,
6960 description_adf: Option<&ValidatedAdfDocument>,
6961 labels: &[String],
6962 custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
6963 ) -> Result<JiraCreatedIssue> {
6964 let url = format!("{}/rest/api/3/issue", self.instance_url);
6965
6966 let mut fields = serde_json::Map::new();
6967 fields.insert(
6968 "project".to_string(),
6969 serde_json::json!({ "key": project_key }),
6970 );
6971 fields.insert(
6972 "issuetype".to_string(),
6973 serde_json::json!({ "name": issue_type }),
6974 );
6975 fields.insert(
6976 "summary".to_string(),
6977 serde_json::Value::String(summary.to_string()),
6978 );
6979 if let Some(adf) = description_adf {
6980 fields.insert(
6981 "description".to_string(),
6982 serde_json::to_value(adf).context("Failed to serialize ADF document")?,
6983 );
6984 }
6985 if !labels.is_empty() {
6986 fields.insert("labels".to_string(), serde_json::to_value(labels)?);
6987 }
6988 for (id, value) in custom_fields {
6989 fields.insert(id.clone(), value.clone());
6990 }
6991
6992 let body = serde_json::json!({ "fields": fields });
6993
6994 let response = self
6995 .post_json(&url, &body)
6996 .await
6997 .context("Failed to send create request to JIRA API")?;
6998
6999 if !response.status().is_success() {
7000 let status = response.status().as_u16();
7001 let body = response.text().await.unwrap_or_default();
7002 return Err(jira_write_error(status, body));
7006 }
7007
7008 let create_response: JiraCreateResponse = response
7009 .json()
7010 .await
7011 .context("Failed to parse JIRA create response")?;
7012
7013 Ok(JiraCreatedIssue {
7014 key: create_response.key,
7015 id: create_response.id,
7016 self_url: create_response.self_url,
7017 })
7018 }
7019
7020 pub async fn get_createmeta(&self, project_key: &str, issue_type: &str) -> Result<EditMeta> {
7027 let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
7028 let url = reqwest::Url::parse_with_params(
7029 &base,
7030 &[
7031 ("projectKeys", project_key),
7032 ("issuetypeNames", issue_type),
7033 ("expand", "projects.issuetypes.fields"),
7034 ],
7035 )
7036 .context("Failed to build JIRA createmeta URL")?;
7037
7038 let response = self
7039 .client
7040 .get(url)
7041 .header("Authorization", &self.auth_header)
7042 .header("Accept", "application/json")
7043 .send()
7044 .await
7045 .context("Failed to send createmeta request to JIRA API")?;
7046
7047 let raw: JiraCreateMetaResponse = Self::parse_json(
7048 Self::ensure_success(response).await?,
7049 "Failed to parse JIRA createmeta response",
7050 )
7051 .await?;
7052
7053 let Some(project) = raw.projects.into_iter().next() else {
7054 return Ok(EditMeta::default());
7055 };
7056 let Some(issuetype) = project.issuetypes.into_iter().next() else {
7057 return Ok(EditMeta::default());
7058 };
7059
7060 Ok(edit_meta_from_raw_fields(issuetype.fields))
7061 }
7062
7063 pub async fn get_project_create_meta(
7070 &self,
7071 project_key: &str,
7072 issue_type: &str,
7073 ) -> Result<CreateMeta> {
7074 let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
7075 let url = reqwest::Url::parse_with_params(
7076 &base,
7077 &[
7078 ("projectKeys", project_key),
7079 ("issuetypeNames", issue_type),
7080 ("expand", "projects.issuetypes.fields"),
7081 ],
7082 )
7083 .context("Failed to build JIRA createmeta URL")?;
7084
7085 let response = self
7086 .client
7087 .get(url)
7088 .header("Authorization", &self.auth_header)
7089 .header("Accept", "application/json")
7090 .send()
7091 .await
7092 .context("Failed to send createmeta request to JIRA API")?;
7093
7094 let raw: JiraCreateMetaFullResponse = Self::parse_json(
7095 Self::ensure_success(response).await?,
7096 "Failed to parse JIRA createmeta response",
7097 )
7098 .await?;
7099
7100 let mut fields: Vec<CreateMetaField> = raw
7101 .projects
7102 .into_iter()
7103 .next()
7104 .and_then(|p| p.issuetypes.into_iter().next())
7105 .map(|it| {
7106 it.fields
7107 .into_iter()
7108 .map(|(field_id, field)| {
7109 let schema = field.schema.unwrap_or(JiraCreateMetaSchemaRaw {
7110 kind: None,
7111 items: None,
7112 custom: None,
7113 });
7114 CreateMetaField {
7115 field_id,
7116 name: field.name.unwrap_or_default(),
7117 required: field.required,
7118 schema_type: schema.kind.unwrap_or_default(),
7119 items: schema.items,
7120 custom: schema.custom,
7121 allowed_values: field
7122 .allowed_values
7123 .into_iter()
7124 .map(JiraAllowedValueRaw::into_allowed_value)
7125 .collect(),
7126 default_value: field.default_value,
7127 }
7128 })
7129 .collect()
7130 })
7131 .unwrap_or_default();
7132
7133 fields.sort_by(|a, b| {
7135 b.required
7136 .cmp(&a.required)
7137 .then_with(|| a.name.cmp(&b.name))
7138 });
7139
7140 Ok(CreateMeta {
7141 project: project_key.to_string(),
7142 issue_type: issue_type.to_string(),
7143 fields,
7144 })
7145 }
7146
7147 pub async fn get_comments(&self, key: &str, limit: u32) -> Result<Vec<JiraComment>> {
7151 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7152 let mut all_comments = Vec::new();
7153 let mut start_at: u32 = 0;
7154
7155 loop {
7156 let remaining = effective_limit.saturating_sub(all_comments.len() as u32);
7157 if remaining == 0 {
7158 break;
7159 }
7160 let page_size = remaining.min(PAGE_SIZE);
7161
7162 let url = format!(
7163 "{}/rest/api/3/issue/{}/comment?orderBy=created&maxResults={}&startAt={}",
7164 self.instance_url, key, page_size, start_at
7165 );
7166
7167 let response = self.get_json(&url).await?;
7168
7169 let resp: JiraCommentsResponse = Self::parse_json(
7170 Self::ensure_success(response).await?,
7171 "Failed to parse comments response",
7172 )
7173 .await?;
7174
7175 let page_count = resp.comments.len() as u32;
7176 for c in resp.comments {
7177 all_comments.push(JiraComment {
7178 id: c.id,
7179 author: c.author.and_then(|a| a.display_name).unwrap_or_default(),
7180 body_adf: c.body,
7181 created: c.created.unwrap_or_default(),
7182 updated: c.updated,
7183 });
7184 }
7185
7186 if page_count == 0 {
7187 break;
7188 }
7189
7190 let fetched = resp.start_at.saturating_add(page_count);
7191 if fetched >= resp.total {
7192 break;
7193 }
7194
7195 start_at += page_count;
7196 }
7197
7198 Ok(all_comments)
7199 }
7200
7201 pub async fn add_comment(&self, key: &str, body_adf: &ValidatedAdfDocument) -> Result<()> {
7203 let url = format!("{}/rest/api/3/issue/{}/comment", self.instance_url, key);
7204
7205 let body = serde_json::json!({
7206 "body": body_adf
7207 });
7208
7209 let response = self.post_json(&url, &body).await?;
7210
7211 Self::ensure_success(response).await?;
7212
7213 Ok(())
7214 }
7215
7216 pub async fn update_comment(
7223 &self,
7224 key: &str,
7225 comment_id: &str,
7226 body_adf: &ValidatedAdfDocument,
7227 visibility: Option<&JiraVisibility>,
7228 ) -> Result<JiraComment> {
7229 let url = format!(
7230 "{}/rest/api/3/issue/{}/comment/{}",
7231 self.instance_url, key, comment_id
7232 );
7233
7234 let mut body = serde_json::json!({ "body": body_adf });
7235 if let Some(v) = visibility {
7236 body["visibility"] =
7237 serde_json::to_value(v).context("Failed to serialize comment visibility")?;
7238 }
7239
7240 let response = self.put_json(&url, &body).await?;
7241
7242 let entry: JiraCommentEntry = Self::parse_json(
7243 Self::ensure_success(response).await?,
7244 "Failed to parse updated comment response",
7245 )
7246 .await?;
7247
7248 Ok(JiraComment {
7249 id: entry.id,
7250 author: entry
7251 .author
7252 .and_then(|a| a.display_name)
7253 .unwrap_or_default(),
7254 body_adf: entry.body,
7255 created: entry.created.unwrap_or_default(),
7256 updated: entry.updated,
7257 })
7258 }
7259
7260 pub async fn delete_comment(&self, key: &str, comment_id: &str) -> Result<()> {
7264 let url = format!(
7265 "{}/rest/api/3/issue/{}/comment/{}",
7266 self.instance_url, key, comment_id
7267 );
7268
7269 let response = self.delete(&url).await?;
7270
7271 Self::ensure_success(response).await?;
7272
7273 Ok(())
7274 }
7275
7276 pub async fn get_worklogs(&self, key: &str, limit: u32) -> Result<JiraWorklogList> {
7278 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7279 let url = format!(
7280 "{}/rest/api/3/issue/{}/worklog?maxResults={}",
7281 self.instance_url,
7282 key,
7283 effective_limit.min(5000)
7284 );
7285
7286 let response = self.get_json(&url).await?;
7287
7288 let resp: JiraWorklogResponse = Self::parse_json(
7289 Self::ensure_success(response).await?,
7290 "Failed to parse worklog response",
7291 )
7292 .await?;
7293
7294 let worklogs: Vec<JiraWorklog> = resp
7295 .worklogs
7296 .into_iter()
7297 .take(effective_limit as usize)
7298 .map(|w| JiraWorklog {
7299 id: w.id,
7300 author: w.author.and_then(|a| a.display_name).unwrap_or_default(),
7301 time_spent: w.time_spent.unwrap_or_default(),
7302 time_spent_seconds: w.time_spent_seconds,
7303 started: w.started.unwrap_or_default(),
7304 comment: Self::extract_worklog_comment(w.comment.as_ref()),
7305 })
7306 .collect();
7307
7308 Ok(JiraWorklogList {
7309 total: resp.total,
7310 worklogs,
7311 })
7312 }
7313
7314 pub async fn add_worklog(
7316 &self,
7317 key: &str,
7318 time_spent: &str,
7319 started: Option<&str>,
7320 comment: Option<&str>,
7321 ) -> Result<()> {
7322 let url = format!("{}/rest/api/3/issue/{}/worklog", self.instance_url, key);
7323
7324 let mut body = serde_json::json!({
7325 "timeSpent": time_spent,
7326 });
7327
7328 if let Some(started) = started {
7329 body["started"] = serde_json::Value::String(started.to_string());
7330 }
7331
7332 if let Some(comment_text) = comment {
7333 body["comment"] = serde_json::json!({
7334 "type": "doc",
7335 "version": 1,
7336 "content": [{
7337 "type": "paragraph",
7338 "content": [{
7339 "type": "text",
7340 "text": comment_text
7341 }]
7342 }]
7343 });
7344 }
7345
7346 let response = self.post_json(&url, &body).await?;
7347
7348 Self::ensure_success(response).await?;
7349
7350 Ok(())
7351 }
7352
7353 pub async fn update_worklog(
7359 &self,
7360 key: &str,
7361 worklog_id: &str,
7362 time_spent: Option<&str>,
7363 started: Option<&str>,
7364 comment: Option<&str>,
7365 ) -> Result<()> {
7366 let url = format!(
7367 "{}/rest/api/3/issue/{}/worklog/{}",
7368 self.instance_url, key, worklog_id
7369 );
7370
7371 let mut body = serde_json::Map::new();
7372 if let Some(time_spent) = time_spent {
7373 body.insert(
7374 "timeSpent".to_string(),
7375 serde_json::Value::String(time_spent.to_string()),
7376 );
7377 }
7378 if let Some(started) = started {
7379 body.insert(
7380 "started".to_string(),
7381 serde_json::Value::String(started.to_string()),
7382 );
7383 }
7384 if let Some(comment_text) = comment {
7385 body.insert(
7386 "comment".to_string(),
7387 serde_json::json!({
7388 "type": "doc",
7389 "version": 1,
7390 "content": [{
7391 "type": "paragraph",
7392 "content": [{
7393 "type": "text",
7394 "text": comment_text
7395 }]
7396 }]
7397 }),
7398 );
7399 }
7400
7401 let response = self
7402 .put_json(&url, &serde_json::Value::Object(body))
7403 .await?;
7404
7405 Self::ensure_success(response).await?;
7406
7407 Ok(())
7408 }
7409
7410 pub async fn delete_worklog(&self, key: &str, worklog_id: &str) -> Result<()> {
7414 let url = format!(
7415 "{}/rest/api/3/issue/{}/worklog/{}",
7416 self.instance_url, key, worklog_id
7417 );
7418
7419 let response = self.delete(&url).await?;
7420
7421 Self::ensure_success(response).await?;
7422
7423 Ok(())
7424 }
7425
7426 fn extract_worklog_comment(adf_value: Option<&serde_json::Value>) -> Option<String> {
7428 let adf_value = adf_value?;
7429 let adf: AdfDocument = serde_json::from_value(adf_value.clone()).ok()?;
7430 let md = adf_to_markdown(&adf).ok()?;
7431 let trimmed = md.trim();
7432 if trimmed.is_empty() {
7433 None
7434 } else {
7435 Some(trimmed.to_string())
7436 }
7437 }
7438
7439 pub async fn get_transitions(&self, key: &str) -> Result<Vec<JiraTransition>> {
7441 let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
7442
7443 let response = self.get_json(&url).await?;
7444
7445 let resp: JiraTransitionsResponse = Self::parse_json(
7446 Self::ensure_success(response).await?,
7447 "Failed to parse transitions response",
7448 )
7449 .await?;
7450
7451 Ok(resp
7452 .transitions
7453 .into_iter()
7454 .map(transition_from_entry)
7455 .collect())
7456 }
7457
7458 pub async fn get_transitions_with_fields(
7467 &self,
7468 key: &str,
7469 ) -> Result<(
7470 Vec<JiraTransition>,
7471 std::collections::BTreeMap<String, EditMeta>,
7472 )> {
7473 let url = format!(
7474 "{}/rest/api/3/issue/{}/transitions?expand=transitions.fields",
7475 self.instance_url, key
7476 );
7477
7478 let response = self.get_json(&url).await?;
7479
7480 let resp: JiraTransitionsResponse = Self::parse_json(
7481 Self::ensure_success(response).await?,
7482 "Failed to parse transitions response",
7483 )
7484 .await?;
7485
7486 let mut metas: std::collections::BTreeMap<String, EditMeta> =
7487 std::collections::BTreeMap::new();
7488 let mut transitions = Vec::with_capacity(resp.transitions.len());
7489 for mut t in resp.transitions {
7490 if !t.fields.is_empty() {
7491 let fields = std::mem::take(&mut t.fields);
7492 metas.insert(t.id.clone(), edit_meta_from_raw_fields(fields));
7493 }
7494 transitions.push(transition_from_entry(t));
7495 }
7496
7497 Ok((transitions, metas))
7498 }
7499
7500 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<()> {
7505 self.do_transition_with_fields(key, transition_id, &std::collections::BTreeMap::new(), None)
7506 .await
7507 }
7508
7509 pub async fn do_transition_with_fields(
7518 &self,
7519 key: &str,
7520 transition_id: &str,
7521 fields: &std::collections::BTreeMap<String, serde_json::Value>,
7522 comment: Option<&ValidatedAdfDocument>,
7523 ) -> Result<()> {
7524 let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
7525
7526 let mut body = serde_json::json!({
7527 "transition": { "id": transition_id }
7528 });
7529 if !fields.is_empty() {
7530 body["fields"] = serde_json::to_value(fields)
7531 .context("Failed to serialize transition fields payload")?;
7532 }
7533 if let Some(adf) = comment {
7534 body["update"] = serde_json::json!({
7535 "comment": [ { "add": { "body": adf } } ]
7536 });
7537 }
7538
7539 let response = self.post_json(&url, &body).await?;
7540
7541 Self::ensure_success(response).await?;
7542
7543 Ok(())
7544 }
7545
7546 pub async fn search_issues(&self, jql: &str, limit: u32) -> Result<JiraSearchResult> {
7550 let url = format!("{}/rest/api/3/search/jql", self.instance_url);
7551 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7552 let mut all_issues = Vec::new();
7553 let mut next_token: Option<String> = None;
7554
7555 loop {
7556 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
7557 if remaining == 0 {
7558 break;
7559 }
7560 let page_size = remaining.min(PAGE_SIZE);
7561
7562 let mut body = serde_json::json!({
7563 "jql": jql,
7564 "maxResults": page_size,
7565 "fields": ["summary", "status", "issuetype", "assignee", "priority"]
7566 });
7567 if let Some(ref token) = next_token {
7568 body["nextPageToken"] = serde_json::Value::String(token.clone());
7569 }
7570
7571 let response = self
7572 .post_json(&url, &body)
7573 .await
7574 .context("Failed to send search request to JIRA API")?;
7575
7576 let page: JiraSearchResponse = Self::parse_json(
7577 Self::ensure_success(response).await?,
7578 "Failed to parse JIRA search response",
7579 )
7580 .await?;
7581
7582 let page_count = page.issues.len();
7583 for r in page.issues {
7584 all_issues.push(JiraIssue {
7585 key: r.key,
7586 summary: r.fields.summary.unwrap_or_default(),
7587 description_adf: r.fields.description,
7588 status: r.fields.status.and_then(|s| s.name),
7589 issue_type: r.fields.issuetype.and_then(|t| t.name),
7590 assignee: r.fields.assignee.and_then(|a| a.display_name),
7591 priority: r.fields.priority.and_then(|p| p.name),
7592 labels: r.fields.labels,
7593 custom_fields: Vec::new(),
7594 });
7595 }
7596
7597 match page.next_page_token {
7598 Some(token) if page_count > 0 => next_token = Some(token),
7599 _ => break,
7600 }
7601 }
7602
7603 let total = all_issues.len() as u32;
7604 Ok(JiraSearchResult {
7605 issues: all_issues,
7606 total,
7607 })
7608 }
7609
7610 pub async fn search_confluence(
7612 &self,
7613 cql: &str,
7614 limit: u32,
7615 ) -> Result<ConfluenceSearchResults> {
7616 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7617 let mut all_results = Vec::new();
7618 let mut start: u32 = 0;
7619
7620 loop {
7621 let remaining = effective_limit.saturating_sub(all_results.len() as u32);
7622 if remaining == 0 {
7623 break;
7624 }
7625 let page_size = remaining.min(PAGE_SIZE);
7626
7627 let base = format!("{}/wiki/rest/api/content/search", self.instance_url);
7628 let url = reqwest::Url::parse_with_params(
7629 &base,
7630 &[
7631 ("cql", cql),
7632 ("limit", &page_size.to_string()),
7633 ("start", &start.to_string()),
7634 ("expand", "space"),
7635 ],
7636 )
7637 .context("Failed to build Confluence search URL")?;
7638
7639 let response = self.get_json(url.as_str()).await?;
7640
7641 let resp: ConfluenceContentSearchResponse = Self::parse_json(
7642 Self::ensure_success(response).await?,
7643 "Failed to parse Confluence search response",
7644 )
7645 .await?;
7646
7647 let page_count = resp.results.len() as u32;
7648 for r in resp.results {
7649 let space_key = r
7650 .expandable
7651 .and_then(|e| e.space)
7652 .and_then(|s| s.rsplit('/').next().map(String::from))
7653 .unwrap_or_default();
7654 all_results.push(ConfluenceSearchResult {
7655 id: r.id,
7656 title: r.title,
7657 space_key,
7658 });
7659 }
7660
7661 let has_next = resp.links.and_then(|l| l.next).is_some();
7662 if !has_next || page_count == 0 {
7663 break;
7664 }
7665 start += page_count;
7666 }
7667
7668 let total = all_results.len() as u32;
7669 Ok(ConfluenceSearchResults {
7670 results: all_results,
7671 total,
7672 })
7673 }
7674
7675 pub async fn search_jira_users(
7687 &self,
7688 query: &str,
7689 limit: u32,
7690 ) -> Result<JiraUserSearchResults> {
7691 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7692 let mut all_results: Vec<JiraUserSearchResult> = Vec::new();
7693 let mut start_at: u32 = 0;
7694
7695 loop {
7696 let remaining = effective_limit.saturating_sub(all_results.len() as u32);
7697 if remaining == 0 {
7698 break;
7699 }
7700 let page_size = remaining.min(PAGE_SIZE);
7701
7702 let base = format!("{}/rest/api/3/user/search", self.instance_url);
7703 let url = reqwest::Url::parse_with_params(
7704 &base,
7705 &[
7706 ("query", query),
7707 ("maxResults", &page_size.to_string()),
7708 ("startAt", &start_at.to_string()),
7709 ],
7710 )
7711 .context("Failed to build JIRA user search URL")?;
7712
7713 let response = self.get_json(url.as_str()).await?;
7714
7715 let page: Vec<JiraUserSearchEntry> = Self::parse_json(
7716 Self::ensure_success(response).await?,
7717 "Failed to parse JIRA user search response",
7718 )
7719 .await?;
7720
7721 let page_count = page.len() as u32;
7722 for entry in page {
7723 all_results.push(JiraUserSearchResult {
7724 account_id: entry.account_id,
7725 display_name: entry.display_name,
7726 email_address: entry.email_address,
7727 active: entry.active,
7728 account_type: entry.account_type,
7729 });
7730 }
7731
7732 if page_count < page_size {
7735 break;
7736 }
7737 start_at += page_count;
7738 }
7739
7740 let count = all_results.len() as u32;
7741 Ok(JiraUserSearchResults {
7742 users: all_results,
7743 count,
7744 })
7745 }
7746
7747 pub async fn search_confluence_users(
7749 &self,
7750 query: &str,
7751 limit: u32,
7752 ) -> Result<ConfluenceUserSearchResults> {
7753 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7754 let mut all_results = Vec::new();
7755 let mut start: u32 = 0;
7756
7757 let cql = format!("user.fullname~\"{query}\"");
7758
7759 loop {
7760 let remaining = effective_limit.saturating_sub(all_results.len() as u32);
7761 if remaining == 0 {
7762 break;
7763 }
7764 let page_size = remaining.min(PAGE_SIZE);
7765
7766 let base = format!("{}/wiki/rest/api/search/user", self.instance_url);
7767 let url = reqwest::Url::parse_with_params(
7768 &base,
7769 &[
7770 ("cql", cql.as_str()),
7771 ("limit", &page_size.to_string()),
7772 ("start", &start.to_string()),
7773 ],
7774 )
7775 .context("Failed to build Confluence user search URL")?;
7776
7777 let response = self.get_json(url.as_str()).await?;
7778
7779 let resp: ConfluenceUserSearchResponse = Self::parse_json(
7780 Self::ensure_success(response).await?,
7781 "Failed to parse Confluence user search response",
7782 )
7783 .await?;
7784
7785 let page_count = resp.results.len() as u32;
7786 for r in resp.results {
7787 let Some(user) = r.user else {
7788 continue;
7789 };
7790 let display_name = user.display_name.or(user.public_name).unwrap_or_default();
7791 all_results.push(ConfluenceUserSearchResult {
7792 account_id: user.account_id,
7793 display_name,
7794 email: user.email,
7795 });
7796 }
7797
7798 let has_next = resp.links.and_then(|l| l.next).is_some();
7799 if !has_next || page_count == 0 {
7800 break;
7801 }
7802 start += page_count;
7803 }
7804
7805 let total = all_results.len() as u32;
7806 Ok(ConfluenceUserSearchResults {
7807 users: all_results,
7808 total,
7809 })
7810 }
7811
7812 pub async fn get_jira_user(&self, account_id: &str) -> Result<JiraUserRecord> {
7821 let base = format!("{}/rest/api/3/user", self.instance_url);
7822 let url = reqwest::Url::parse_with_params(&base, &[("accountId", account_id)])
7823 .context("Failed to build JIRA user get URL")?;
7824
7825 let response = self.get_json(url.as_str()).await?;
7826 let status = response.status();
7827
7828 if status.is_success() {
7829 let entry: JiraUserSearchEntry = response
7830 .json()
7831 .await
7832 .context("Failed to parse JIRA user get response")?;
7833 return Ok(JiraUserRecord {
7834 account_id: entry.account_id,
7835 display_name: entry.display_name,
7836 email_address: entry.email_address,
7837 active: Some(entry.active),
7838 account_type: entry.account_type,
7839 error: None,
7840 });
7841 }
7842
7843 if status.as_u16() == 401 {
7844 let body = response.text().await.unwrap_or_default();
7845 return Err(AtlassianError::ApiRequestFailed { status: 401, body }.into());
7846 }
7847
7848 let code = status.as_u16();
7849 let body = response.text().await.unwrap_or_default();
7850 Ok(JiraUserRecord {
7851 account_id: account_id.to_string(),
7852 display_name: None,
7853 email_address: None,
7854 active: None,
7855 account_type: None,
7856 error: Some(user_lookup_error(code, &body)),
7857 })
7858 }
7859
7860 pub async fn get_jira_users(&self, account_ids: &[String]) -> Result<JiraUserGetResults> {
7866 let lookups = account_ids.iter().map(|id| self.get_jira_user(id));
7867 let users = futures::future::join_all(lookups)
7868 .await
7869 .into_iter()
7870 .collect::<Result<Vec<_>>>()?;
7871 Ok(JiraUserGetResults { users })
7872 }
7873
7874 pub async fn get_confluence_user(&self, account_id: &str) -> Result<ConfluenceUserRecord> {
7881 let base = format!("{}/wiki/rest/api/user", self.instance_url);
7882 let url = reqwest::Url::parse_with_params(&base, &[("accountId", account_id)])
7883 .context("Failed to build Confluence user get URL")?;
7884
7885 let response = self.get_json(url.as_str()).await?;
7886 let status = response.status();
7887
7888 if status.is_success() {
7889 let entry: ConfluenceUserGetEntry = response
7890 .json()
7891 .await
7892 .context("Failed to parse Confluence user get response")?;
7893 return Ok(ConfluenceUserRecord {
7894 account_id: entry.account_id.unwrap_or_else(|| account_id.to_string()),
7895 display_name: entry.display_name.or(entry.public_name),
7896 email: entry.email,
7897 account_type: entry.account_type,
7898 active: None,
7899 error: None,
7900 });
7901 }
7902
7903 if status.as_u16() == 401 {
7904 let body = response.text().await.unwrap_or_default();
7905 return Err(AtlassianError::ApiRequestFailed { status: 401, body }.into());
7906 }
7907
7908 let code = status.as_u16();
7909 let body = response.text().await.unwrap_or_default();
7910 Ok(ConfluenceUserRecord {
7911 account_id: account_id.to_string(),
7912 display_name: None,
7913 email: None,
7914 account_type: None,
7915 active: None,
7916 error: Some(user_lookup_error(code, &body)),
7917 })
7918 }
7919
7920 pub async fn get_confluence_users(
7925 &self,
7926 account_ids: &[String],
7927 ) -> Result<ConfluenceUserGetResults> {
7928 let lookups = account_ids.iter().map(|id| self.get_confluence_user(id));
7929 let users = futures::future::join_all(lookups)
7930 .await
7931 .into_iter()
7932 .collect::<Result<Vec<_>>>()?;
7933 Ok(ConfluenceUserGetResults { users })
7934 }
7935
7936 pub async fn get_boards(
7938 &self,
7939 project: Option<&str>,
7940 board_type: Option<&str>,
7941 limit: u32,
7942 ) -> Result<AgileBoardList> {
7943 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7944 let mut all_boards = Vec::new();
7945 let mut start_at: u32 = 0;
7946
7947 loop {
7948 let remaining = effective_limit.saturating_sub(all_boards.len() as u32);
7949 if remaining == 0 {
7950 break;
7951 }
7952 let page_size = remaining.min(PAGE_SIZE);
7953
7954 let mut url = format!(
7955 "{}/rest/agile/1.0/board?maxResults={}&startAt={}",
7956 self.instance_url, page_size, start_at
7957 );
7958 if let Some(proj) = project {
7959 url.push_str(&format!("&projectKeyOrId={proj}"));
7960 }
7961 if let Some(bt) = board_type {
7962 url.push_str(&format!("&type={bt}"));
7963 }
7964
7965 let response = self.get_json(&url).await?;
7966
7967 let resp: AgileBoardListResponse = Self::parse_json(
7968 Self::ensure_success(response).await?,
7969 "Failed to parse board list response",
7970 )
7971 .await?;
7972
7973 let page_count = resp.values.len() as u32;
7974 for b in resp.values {
7975 all_boards.push(AgileBoard {
7976 id: b.id,
7977 name: b.name,
7978 board_type: b.board_type,
7979 project_key: b.location.and_then(|l| l.project_key),
7980 });
7981 }
7982
7983 if resp.is_last || page_count == 0 {
7984 break;
7985 }
7986 start_at += page_count;
7987 }
7988
7989 let total = all_boards.len() as u32;
7990 Ok(AgileBoardList {
7991 boards: all_boards,
7992 total,
7993 })
7994 }
7995
7996 pub async fn get_board_issues(
7998 &self,
7999 board_id: u64,
8000 jql: Option<&str>,
8001 limit: u32,
8002 ) -> Result<JiraSearchResult> {
8003 let effective_limit = if limit == 0 { u32::MAX } else { limit };
8004 let mut all_issues = Vec::new();
8005 let mut start_at: u32 = 0;
8006
8007 loop {
8008 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
8009 if remaining == 0 {
8010 break;
8011 }
8012 let page_size = remaining.min(PAGE_SIZE);
8013
8014 let base = format!(
8015 "{}/rest/agile/1.0/board/{}/issue",
8016 self.instance_url, board_id
8017 );
8018 let mut params: Vec<(&str, String)> = vec![
8019 ("maxResults", page_size.to_string()),
8020 ("startAt", start_at.to_string()),
8021 ];
8022 if let Some(jql_str) = jql {
8023 params.push(("jql", jql_str.to_string()));
8024 }
8025 let url = reqwest::Url::parse_with_params(
8026 &base,
8027 params.iter().map(|(k, v)| (*k, v.as_str())),
8028 )
8029 .context("Failed to build board issues URL")?;
8030
8031 let response = self.get_json(url.as_str()).await?;
8032
8033 let resp: AgileIssueListResponse = Self::parse_json(
8034 Self::ensure_success(response).await?,
8035 "Failed to parse board issues response",
8036 )
8037 .await?;
8038
8039 let page_count = resp.issues.len() as u32;
8040 for r in resp.issues {
8041 all_issues.push(JiraIssue {
8042 key: r.key,
8043 summary: r.fields.summary.unwrap_or_default(),
8044 description_adf: r.fields.description,
8045 status: r.fields.status.and_then(|s| s.name),
8046 issue_type: r.fields.issuetype.and_then(|t| t.name),
8047 assignee: r.fields.assignee.and_then(|a| a.display_name),
8048 priority: r.fields.priority.and_then(|p| p.name),
8049 labels: r.fields.labels,
8050 custom_fields: Vec::new(),
8051 });
8052 }
8053
8054 if resp.is_last || page_count == 0 {
8055 break;
8056 }
8057 start_at += page_count;
8058 }
8059
8060 let total = all_issues.len() as u32;
8061 Ok(JiraSearchResult {
8062 issues: all_issues,
8063 total,
8064 })
8065 }
8066
8067 pub async fn get_sprints(
8069 &self,
8070 board_id: u64,
8071 state: Option<&str>,
8072 limit: u32,
8073 ) -> Result<AgileSprintList> {
8074 let effective_limit = if limit == 0 { u32::MAX } else { limit };
8075 let mut all_sprints = Vec::new();
8076 let mut start_at: u32 = 0;
8077
8078 loop {
8079 let remaining = effective_limit.saturating_sub(all_sprints.len() as u32);
8080 if remaining == 0 {
8081 break;
8082 }
8083 let page_size = remaining.min(PAGE_SIZE);
8084
8085 let mut url = format!(
8086 "{}/rest/agile/1.0/board/{}/sprint?maxResults={}&startAt={}",
8087 self.instance_url, board_id, page_size, start_at
8088 );
8089 if let Some(s) = state {
8090 url.push_str(&format!("&state={s}"));
8091 }
8092
8093 let response = self.get_json(&url).await?;
8094
8095 let resp: AgileSprintListResponse = Self::parse_json(
8096 Self::ensure_success(response).await?,
8097 "Failed to parse sprint list response",
8098 )
8099 .await?;
8100
8101 let page_count = resp.values.len() as u32;
8102 for s in resp.values {
8103 all_sprints.push(AgileSprint {
8104 id: s.id,
8105 name: s.name,
8106 state: s.state,
8107 start_date: s.start_date,
8108 end_date: s.end_date,
8109 goal: s.goal,
8110 });
8111 }
8112
8113 if resp.is_last || page_count == 0 {
8114 break;
8115 }
8116 start_at += page_count;
8117 }
8118
8119 let total = all_sprints.len() as u32;
8120 Ok(AgileSprintList {
8121 sprints: all_sprints,
8122 total,
8123 })
8124 }
8125
8126 pub async fn get_sprint_issues(
8128 &self,
8129 sprint_id: u64,
8130 jql: Option<&str>,
8131 limit: u32,
8132 ) -> Result<JiraSearchResult> {
8133 let effective_limit = if limit == 0 { u32::MAX } else { limit };
8134 let mut all_issues = Vec::new();
8135 let mut start_at: u32 = 0;
8136
8137 loop {
8138 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
8139 if remaining == 0 {
8140 break;
8141 }
8142 let page_size = remaining.min(PAGE_SIZE);
8143
8144 let base = format!(
8145 "{}/rest/agile/1.0/sprint/{}/issue",
8146 self.instance_url, sprint_id
8147 );
8148 let mut params: Vec<(&str, String)> = vec![
8149 ("maxResults", page_size.to_string()),
8150 ("startAt", start_at.to_string()),
8151 ];
8152 if let Some(jql_str) = jql {
8153 params.push(("jql", jql_str.to_string()));
8154 }
8155 let url = reqwest::Url::parse_with_params(
8156 &base,
8157 params.iter().map(|(k, v)| (*k, v.as_str())),
8158 )
8159 .context("Failed to build sprint issues URL")?;
8160
8161 let response = self.get_json(url.as_str()).await?;
8162
8163 let resp: AgileIssueListResponse = Self::parse_json(
8164 Self::ensure_success(response).await?,
8165 "Failed to parse sprint issues response",
8166 )
8167 .await?;
8168
8169 let page_count = resp.issues.len() as u32;
8170 for r in resp.issues {
8171 all_issues.push(JiraIssue {
8172 key: r.key,
8173 summary: r.fields.summary.unwrap_or_default(),
8174 description_adf: r.fields.description,
8175 status: r.fields.status.and_then(|s| s.name),
8176 issue_type: r.fields.issuetype.and_then(|t| t.name),
8177 assignee: r.fields.assignee.and_then(|a| a.display_name),
8178 priority: r.fields.priority.and_then(|p| p.name),
8179 labels: r.fields.labels,
8180 custom_fields: Vec::new(),
8181 });
8182 }
8183
8184 if resp.is_last || page_count == 0 {
8185 break;
8186 }
8187 start_at += page_count;
8188 }
8189
8190 let total = all_issues.len() as u32;
8191 Ok(JiraSearchResult {
8192 issues: all_issues,
8193 total,
8194 })
8195 }
8196
8197 pub async fn add_issues_to_sprint(&self, sprint_id: u64, issue_keys: &[&str]) -> Result<()> {
8199 let url = format!(
8200 "{}/rest/agile/1.0/sprint/{}/issue",
8201 self.instance_url, sprint_id
8202 );
8203
8204 let body = serde_json::json!({ "issues": issue_keys });
8205
8206 let response = self.post_json(&url, &body).await?;
8207
8208 Self::ensure_success(response).await?;
8209
8210 Ok(())
8211 }
8212
8213 pub async fn create_sprint(
8215 &self,
8216 board_id: u64,
8217 name: &str,
8218 start_date: Option<&str>,
8219 end_date: Option<&str>,
8220 goal: Option<&str>,
8221 ) -> Result<AgileSprint> {
8222 let url = format!("{}/rest/agile/1.0/sprint", self.instance_url);
8223
8224 let mut body = serde_json::json!({
8225 "originBoardId": board_id,
8226 "name": name
8227 });
8228 if let Some(sd) = start_date {
8229 body["startDate"] = serde_json::Value::String(sd.to_string());
8230 }
8231 if let Some(ed) = end_date {
8232 body["endDate"] = serde_json::Value::String(ed.to_string());
8233 }
8234 if let Some(g) = goal {
8235 body["goal"] = serde_json::Value::String(g.to_string());
8236 }
8237
8238 let response = self.post_json(&url, &body).await?;
8239
8240 let entry: AgileSprintEntry = Self::parse_json(
8241 Self::ensure_success(response).await?,
8242 "Failed to parse sprint create response",
8243 )
8244 .await?;
8245
8246 Ok(AgileSprint {
8247 id: entry.id,
8248 name: entry.name,
8249 state: entry.state,
8250 start_date: entry.start_date,
8251 end_date: entry.end_date,
8252 goal: entry.goal,
8253 })
8254 }
8255
8256 pub async fn update_sprint(
8258 &self,
8259 sprint_id: u64,
8260 name: Option<&str>,
8261 state: Option<&str>,
8262 start_date: Option<&str>,
8263 end_date: Option<&str>,
8264 goal: Option<&str>,
8265 ) -> Result<()> {
8266 let url = format!("{}/rest/agile/1.0/sprint/{}", self.instance_url, sprint_id);
8267
8268 let mut body = serde_json::Map::new();
8269 if let Some(n) = name {
8270 body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
8271 }
8272 if let Some(s) = state {
8273 body.insert(
8274 "state".to_string(),
8275 serde_json::Value::String(s.to_string()),
8276 );
8277 }
8278 if let Some(sd) = start_date {
8279 body.insert(
8280 "startDate".to_string(),
8281 serde_json::Value::String(sd.to_string()),
8282 );
8283 }
8284 if let Some(ed) = end_date {
8285 body.insert(
8286 "endDate".to_string(),
8287 serde_json::Value::String(ed.to_string()),
8288 );
8289 }
8290 if let Some(g) = goal {
8291 body.insert("goal".to_string(), serde_json::Value::String(g.to_string()));
8292 }
8293
8294 let response = self
8295 .put_json(&url, &serde_json::Value::Object(body))
8296 .await?;
8297
8298 Self::ensure_success(response).await?;
8299
8300 Ok(())
8301 }
8302
8303 pub async fn delete_sprint(&self, sprint_id: u64) -> Result<()> {
8307 let url = format!("{}/rest/agile/1.0/sprint/{}", self.instance_url, sprint_id);
8308 let response = self.delete(&url).await?;
8309 Self::ensure_success(response).await?;
8310 Ok(())
8311 }
8312
8313 pub async fn get_project_versions(
8319 &self,
8320 project_key: &str,
8321 released: Option<bool>,
8322 archived: Option<bool>,
8323 ) -> Result<JiraProjectVersionList> {
8324 let url = format!(
8325 "{}/rest/api/3/project/{}/versions",
8326 self.instance_url, project_key
8327 );
8328
8329 let response = self.get_json(&url).await?;
8330
8331 let entries: Vec<JiraProjectVersionEntry> = Self::parse_json(
8332 Self::ensure_success(response).await?,
8333 "Failed to parse project versions response",
8334 )
8335 .await?;
8336
8337 let versions: Vec<JiraProjectVersion> = entries
8338 .into_iter()
8339 .filter(|e| released.is_none_or(|r| e.released == r))
8340 .filter(|e| archived.is_none_or(|a| e.archived == a))
8341 .map(|e| JiraProjectVersion {
8342 id: e.id,
8343 name: e.name,
8344 description: e.description,
8345 project_key: project_key.to_string(),
8346 released: e.released,
8347 archived: e.archived,
8348 release_date: e.release_date,
8349 start_date: e.start_date,
8350 })
8351 .collect();
8352
8353 let total = versions.len() as u32;
8354 Ok(JiraProjectVersionList { versions, total })
8355 }
8356
8357 #[allow(clippy::too_many_arguments)]
8363 pub async fn create_project_version(
8364 &self,
8365 project_key: &str,
8366 name: &str,
8367 description: Option<&str>,
8368 release_date: Option<&str>,
8369 start_date: Option<&str>,
8370 released: bool,
8371 archived: bool,
8372 ) -> Result<JiraProjectVersion> {
8373 validate_iso_date(release_date, "release_date")?;
8374 validate_iso_date(start_date, "start_date")?;
8375
8376 let url = format!("{}/rest/api/3/version", self.instance_url);
8377
8378 let mut body = serde_json::json!({
8379 "project": project_key,
8380 "name": name,
8381 "released": released,
8382 "archived": archived,
8383 });
8384 if let Some(d) = description {
8385 body["description"] = serde_json::Value::String(d.to_string());
8386 }
8387 if let Some(rd) = release_date {
8388 body["releaseDate"] = serde_json::Value::String(rd.to_string());
8389 }
8390 if let Some(sd) = start_date {
8391 body["startDate"] = serde_json::Value::String(sd.to_string());
8392 }
8393
8394 let response = self.post_json(&url, &body).await?;
8395
8396 let entry: JiraProjectVersionEntry = Self::parse_json(
8397 Self::ensure_success(response).await?,
8398 "Failed to parse version create response",
8399 )
8400 .await?;
8401
8402 Ok(JiraProjectVersion {
8403 id: entry.id,
8404 name: entry.name,
8405 description: entry.description,
8406 project_key: project_key.to_string(),
8407 released: entry.released,
8408 archived: entry.archived,
8409 release_date: entry.release_date,
8410 start_date: entry.start_date,
8411 })
8412 }
8413
8414 #[allow(clippy::too_many_arguments)]
8422 pub async fn update_project_version(
8423 &self,
8424 version_id: &str,
8425 name: Option<&str>,
8426 description: Option<&str>,
8427 released: Option<bool>,
8428 release_date: Option<&str>,
8429 archived: Option<bool>,
8430 start_date: Option<&str>,
8431 ) -> Result<()> {
8432 validate_iso_date(release_date, "release_date")?;
8433 validate_iso_date(start_date, "start_date")?;
8434
8435 let url = format!("{}/rest/api/3/version/{}", self.instance_url, version_id);
8436
8437 let mut body = serde_json::Map::new();
8438 if let Some(n) = name {
8439 body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
8440 }
8441 if let Some(d) = description {
8442 body.insert(
8443 "description".to_string(),
8444 serde_json::Value::String(d.to_string()),
8445 );
8446 }
8447 if let Some(r) = released {
8448 body.insert("released".to_string(), serde_json::Value::Bool(r));
8449 }
8450 if let Some(rd) = release_date {
8451 body.insert(
8452 "releaseDate".to_string(),
8453 serde_json::Value::String(rd.to_string()),
8454 );
8455 }
8456 if let Some(a) = archived {
8457 body.insert("archived".to_string(), serde_json::Value::Bool(a));
8458 }
8459 if let Some(sd) = start_date {
8460 body.insert(
8461 "startDate".to_string(),
8462 serde_json::Value::String(sd.to_string()),
8463 );
8464 }
8465
8466 let response = self
8467 .put_json(&url, &serde_json::Value::Object(body))
8468 .await?;
8469
8470 Self::ensure_success(response).await?;
8471
8472 Ok(())
8473 }
8474
8475 pub async fn delete_project_version(
8482 &self,
8483 version_id: &str,
8484 move_fix_issues_to: Option<&str>,
8485 move_affected_issues_to: Option<&str>,
8486 ) -> Result<()> {
8487 let mut url = format!("{}/rest/api/3/version/{}", self.instance_url, version_id);
8488 let mut params = Vec::new();
8489 if let Some(v) = move_fix_issues_to {
8490 params.push(format!("moveFixIssuesTo={v}"));
8491 }
8492 if let Some(v) = move_affected_issues_to {
8493 params.push(format!("moveAffectedIssuesTo={v}"));
8494 }
8495 if !params.is_empty() {
8496 url.push('?');
8497 url.push_str(¶ms.join("&"));
8498 }
8499
8500 let response = self.delete(&url).await?;
8501
8502 Self::ensure_success(response).await?;
8503
8504 Ok(())
8505 }
8506
8507 pub async fn get_project_components(&self, project_key: &str) -> Result<Vec<JiraComponent>> {
8511 let url = format!(
8512 "{}/rest/api/3/project/{}/components",
8513 self.instance_url, project_key
8514 );
8515 let response = self.get_json(&url).await?;
8516 let entries: Vec<JiraComponentEntry> = Self::parse_json(
8517 Self::ensure_success(response).await?,
8518 "Failed to parse project components response",
8519 )
8520 .await?;
8521 Ok(entries
8522 .into_iter()
8523 .map(|e| JiraComponent {
8524 id: e.id,
8525 name: e.name,
8526 description: e.description,
8527 })
8528 .collect())
8529 }
8530
8531 pub async fn create_component(
8535 &self,
8536 project_key: &str,
8537 name: &str,
8538 description: Option<&str>,
8539 ) -> Result<JiraComponent> {
8540 let url = format!("{}/rest/api/3/component", self.instance_url);
8541 let mut body = serde_json::json!({ "project": project_key, "name": name });
8542 if let Some(d) = description {
8543 body["description"] = serde_json::Value::String(d.to_string());
8544 }
8545 let response = self.post_json(&url, &body).await?;
8546 let entry: JiraComponentEntry = Self::parse_json(
8547 Self::ensure_success(response).await?,
8548 "Failed to parse component create response",
8549 )
8550 .await?;
8551 Ok(JiraComponent {
8552 id: entry.id,
8553 name: entry.name,
8554 description: entry.description,
8555 })
8556 }
8557
8558 pub async fn update_component(
8562 &self,
8563 component_id: &str,
8564 name: Option<&str>,
8565 description: Option<&str>,
8566 ) -> Result<()> {
8567 let url = format!(
8568 "{}/rest/api/3/component/{}",
8569 self.instance_url, component_id
8570 );
8571 let mut body = serde_json::Map::new();
8572 if let Some(n) = name {
8573 body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
8574 }
8575 if let Some(d) = description {
8576 body.insert(
8577 "description".to_string(),
8578 serde_json::Value::String(d.to_string()),
8579 );
8580 }
8581 let response = self
8582 .put_json(&url, &serde_json::Value::Object(body))
8583 .await?;
8584 Self::ensure_success(response).await?;
8585 Ok(())
8586 }
8587
8588 pub async fn delete_component(
8593 &self,
8594 component_id: &str,
8595 move_issues_to: Option<&str>,
8596 ) -> Result<()> {
8597 let mut url = format!(
8598 "{}/rest/api/3/component/{}",
8599 self.instance_url, component_id
8600 );
8601 if let Some(target) = move_issues_to {
8602 url.push_str("?moveIssuesTo=");
8603 url.push_str(target);
8604 }
8605 let response = self.delete(&url).await?;
8606 Self::ensure_success(response).await?;
8607 Ok(())
8608 }
8609
8610 pub async fn get_issue_links(&self, key: &str) -> Result<Vec<JiraIssueLink>> {
8612 let url = format!(
8613 "{}/rest/api/3/issue/{}?fields=issuelinks",
8614 self.instance_url, key
8615 );
8616
8617 let response = self.get_json(&url).await?;
8618
8619 let resp: JiraIssueLinksResponse = Self::parse_json(
8620 Self::ensure_success(response).await?,
8621 "Failed to parse issue links response",
8622 )
8623 .await?;
8624
8625 let mut links = Vec::new();
8626 for entry in resp.fields.issuelinks {
8627 if let Some(inward) = entry.inward_issue {
8628 links.push(JiraIssueLink {
8629 id: entry.id.clone(),
8630 link_type: entry.link_type.name.clone(),
8631 direction: "inward".to_string(),
8632 linked_issue_key: inward.key,
8633 linked_issue_summary: inward.fields.and_then(|f| f.summary).unwrap_or_default(),
8634 });
8635 }
8636 if let Some(outward) = entry.outward_issue {
8637 links.push(JiraIssueLink {
8638 id: entry.id,
8639 link_type: entry.link_type.name,
8640 direction: "outward".to_string(),
8641 linked_issue_key: outward.key,
8642 linked_issue_summary: outward
8643 .fields
8644 .and_then(|f| f.summary)
8645 .unwrap_or_default(),
8646 });
8647 }
8648 }
8649
8650 Ok(links)
8651 }
8652
8653 pub async fn get_remote_issue_links(&self, key: &str) -> Result<Vec<JiraRemoteIssueLink>> {
8658 let url = format!("{}/rest/api/3/issue/{}/remotelink", self.instance_url, key);
8659
8660 let response = self.get_json(&url).await?;
8661
8662 let entries: Vec<JiraRemoteIssueLinkEntry> = Self::parse_json(
8663 Self::ensure_success(response).await?,
8664 "Failed to parse remote issue links response",
8665 )
8666 .await?;
8667
8668 let mut links = Vec::with_capacity(entries.len());
8669 for entry in entries {
8670 let id = match entry.id {
8673 serde_json::Value::String(s) => s,
8674 serde_json::Value::Number(n) => n.to_string(),
8675 other => {
8676 return Err(anyhow::anyhow!(
8677 "unexpected remote link id type in response: {other:?}"
8678 ));
8679 }
8680 };
8681 links.push(JiraRemoteIssueLink {
8682 id,
8683 global_id: entry.global_id,
8684 relationship: entry.relationship,
8685 object: JiraRemoteIssueLinkObject {
8686 url: entry.object.url,
8687 title: entry.object.title,
8688 summary: entry.object.summary,
8689 icon: entry.object.icon.map(|i| JiraRemoteIssueLinkIcon {
8690 url: i.url,
8691 title: i.title,
8692 }),
8693 },
8694 });
8695 }
8696 Ok(links)
8697 }
8698
8699 pub async fn create_remote_issue_link(
8705 &self,
8706 key: &str,
8707 url: &str,
8708 title: &str,
8709 summary: Option<&str>,
8710 relationship: Option<&str>,
8711 global_id: Option<&str>,
8712 ) -> Result<String> {
8713 let endpoint = format!("{}/rest/api/3/issue/{}/remotelink", self.instance_url, key);
8714
8715 let mut object = serde_json::Map::new();
8716 object.insert(
8717 "url".to_string(),
8718 serde_json::Value::String(url.to_string()),
8719 );
8720 object.insert(
8721 "title".to_string(),
8722 serde_json::Value::String(title.to_string()),
8723 );
8724 if let Some(s) = summary {
8725 object.insert(
8726 "summary".to_string(),
8727 serde_json::Value::String(s.to_string()),
8728 );
8729 }
8730
8731 let mut body = serde_json::Map::new();
8732 body.insert("object".to_string(), serde_json::Value::Object(object));
8733 if let Some(r) = relationship {
8734 body.insert(
8735 "relationship".to_string(),
8736 serde_json::Value::String(r.to_string()),
8737 );
8738 }
8739 if let Some(g) = global_id {
8740 body.insert(
8741 "globalId".to_string(),
8742 serde_json::Value::String(g.to_string()),
8743 );
8744 }
8745
8746 let response = self
8747 .post_json(&endpoint, &serde_json::Value::Object(body))
8748 .await?;
8749
8750 let created: JiraRemoteLinkCreateResponse = Self::parse_json(
8751 Self::ensure_success(response).await?,
8752 "Failed to parse remote link create response",
8753 )
8754 .await?;
8755
8756 Ok(match created.id {
8757 serde_json::Value::String(s) => s,
8758 serde_json::Value::Number(n) => n.to_string(),
8759 other => {
8760 return Err(anyhow::anyhow!(
8761 "unexpected remote link id type in create response: {other:?}"
8762 ));
8763 }
8764 })
8765 }
8766
8767 pub async fn delete_remote_issue_link(&self, key: &str, link_id: &str) -> Result<()> {
8771 let url = format!(
8772 "{}/rest/api/3/issue/{}/remotelink/{}",
8773 self.instance_url, key, link_id
8774 );
8775 let response = self.delete(&url).await?;
8776 Self::ensure_success(response).await?;
8777 Ok(())
8778 }
8779
8780 pub async fn get_link_types(&self) -> Result<Vec<JiraLinkType>> {
8782 let url = format!("{}/rest/api/3/issueLinkType", self.instance_url);
8783 let response = self.get_json(&url).await?;
8784 let resp: JiraLinkTypesResponse = Self::parse_json(
8785 Self::ensure_success(response).await?,
8786 "Failed to parse link types response",
8787 )
8788 .await?;
8789 Ok(resp
8790 .issue_link_types
8791 .into_iter()
8792 .map(|t| JiraLinkType {
8793 id: t.id,
8794 name: t.name,
8795 inward: t.inward,
8796 outward: t.outward,
8797 })
8798 .collect())
8799 }
8800
8801 pub async fn create_issue_link(
8803 &self,
8804 type_name: &str,
8805 inward_key: &str,
8806 outward_key: &str,
8807 ) -> Result<()> {
8808 let url = format!("{}/rest/api/3/issueLink", self.instance_url);
8809 let body = serde_json::json!({"type": {"name": type_name}, "inwardIssue": {"key": inward_key}, "outwardIssue": {"key": outward_key}});
8810 let response = self.post_json(&url, &body).await?;
8811 Self::ensure_success(response).await?;
8812 Ok(())
8813 }
8814
8815 pub async fn remove_issue_link(&self, link_id: &str) -> Result<()> {
8817 let url = format!("{}/rest/api/3/issueLink/{}", self.instance_url, link_id);
8818 let response = self.delete(&url).await?;
8819 Self::ensure_success(response).await?;
8820 Ok(())
8821 }
8822
8823 pub async fn set_issue_parent(&self, issue_key: &str, parent_key: &str) -> Result<()> {
8827 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, issue_key);
8828 let body = serde_json::json!({"fields": {"parent": {"key": parent_key}}});
8829 let response = self.put_json(&url, &body).await?;
8830 Self::ensure_success(response).await?;
8831 Ok(())
8832 }
8833
8834 pub async fn modify_issue_labels(
8841 &self,
8842 key: &str,
8843 add: &[String],
8844 remove: &[String],
8845 ) -> Result<()> {
8846 let mut ops: Vec<serde_json::Value> = Vec::with_capacity(add.len() + remove.len());
8847 for label in add {
8848 ops.push(serde_json::json!({ "add": label }));
8849 }
8850 for label in remove {
8851 ops.push(serde_json::json!({ "remove": label }));
8852 }
8853 let body = serde_json::json!({ "update": { "labels": ops } });
8854
8855 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
8856 let response = self.put_json(&url, &body).await?;
8857 Self::ensure_success(response).await?;
8858 Ok(())
8859 }
8860
8861 pub async fn get_issue_id(&self, key: &str) -> Result<String> {
8863 let url = format!("{}/rest/api/3/issue/{}?fields=", self.instance_url, key);
8864 let response = self.get_json(&url).await?;
8865 let resp: JiraIssueIdResponse = Self::parse_json(
8866 Self::ensure_success(response).await?,
8867 "Failed to parse issue ID response",
8868 )
8869 .await?;
8870 Ok(resp.id)
8871 }
8872
8873 pub async fn get_dev_status_summary(&self, key: &str) -> Result<JiraDevStatusSummary> {
8879 let issue_id = self.get_issue_id(key).await?;
8880 let url = format!(
8881 "{}/rest/dev-status/1.0/issue/summary?issueId={}",
8882 self.instance_url, issue_id
8883 );
8884 let response = self.get_json(&url).await?;
8885 let resp: DevStatusSummaryResponse = Self::parse_json(
8886 Self::ensure_success(response).await?,
8887 "Failed to parse DevStatus summary response",
8888 )
8889 .await?;
8890
8891 fn extract_count(cat: Option<DevStatusSummaryCategory>) -> JiraDevStatusCount {
8892 match cat {
8893 Some(c) => JiraDevStatusCount {
8894 count: c.overall.map_or(0, |o| o.count),
8895 providers: c
8903 .by_instance_type
8904 .into_iter()
8905 .filter(|(k, _)| !k.is_empty())
8906 .map(|(k, v)| JiraDevProvider {
8907 name: v
8908 .get("name")
8909 .and_then(|n| n.as_str())
8910 .filter(|s| !s.is_empty())
8911 .unwrap_or(&k)
8912 .to_string(),
8913 instance_type: k,
8914 })
8915 .collect(),
8916 },
8917 None => JiraDevStatusCount {
8918 count: 0,
8919 providers: Vec::new(),
8920 },
8921 }
8922 }
8923
8924 Ok(JiraDevStatusSummary {
8925 pullrequest: extract_count(resp.summary.pullrequest),
8926 branch: extract_count(resp.summary.branch),
8927 repository: extract_count(resp.summary.repository),
8928 })
8929 }
8930
8931 pub async fn get_dev_status(
8940 &self,
8941 key: &str,
8942 data_type: Option<&str>,
8943 application_type: Option<&str>,
8944 ) -> Result<JiraDevStatus> {
8945 let issue_id = self.get_issue_id(key).await?;
8946
8947 let app_types: Vec<String> = if let Some(app) = application_type {
8948 vec![app.to_string()]
8949 } else {
8950 let summary = self.get_dev_status_summary(key).await?;
8954 let mut providers: Vec<String> = Vec::new();
8955 for p in summary
8956 .pullrequest
8957 .providers
8958 .into_iter()
8959 .chain(summary.branch.providers)
8960 .chain(summary.repository.providers)
8961 {
8962 if !providers.contains(&p.instance_type) {
8963 providers.push(p.instance_type);
8964 }
8965 }
8966 if providers.is_empty() {
8967 providers.push("GitHub".to_string());
8968 }
8969 providers
8970 };
8971
8972 let data_types: Vec<&str> = match data_type {
8973 Some(dt) => vec![dt],
8974 None => vec!["pullrequest", "branch", "repository"],
8975 };
8976
8977 let mut status = JiraDevStatus {
8978 pull_requests: Vec::new(),
8979 branches: Vec::new(),
8980 repositories: Vec::new(),
8981 };
8982
8983 for app in &app_types {
8984 for dt in &data_types {
8985 let url = format!(
8986 "{}/rest/dev-status/1.0/issue/detail?issueId={}&applicationType={}&dataType={}",
8987 self.instance_url, issue_id, app, dt
8988 );
8989 let response = self.get_json(&url).await?;
8990 let resp: DevStatusResponse = Self::parse_json(
8991 Self::ensure_success(response).await?,
8992 "Failed to parse DevStatus response",
8993 )
8994 .await?;
8995
8996 for detail in resp.detail {
8997 for pr in detail.pull_requests {
8998 status.pull_requests.push(JiraDevPullRequest {
8999 id: pr.id,
9000 name: pr.name,
9001 status: pr.status,
9002 url: pr.url,
9003 repository_name: pr.repository_name,
9004 source_branch: pr.source.map(|s| s.branch).unwrap_or_default(),
9005 destination_branch: pr
9006 .destination
9007 .map(|d| d.branch)
9008 .unwrap_or_default(),
9009 author: pr.author.map(|a| a.name),
9010 reviewers: pr.reviewers.into_iter().map(|r| r.name).collect(),
9011 comment_count: pr.comment_count,
9012 last_update: pr.last_update,
9013 });
9014 }
9015 for branch in detail.branches {
9016 status.branches.push(JiraDevBranch {
9017 name: branch.name,
9018 url: branch.url,
9019 repository_name: branch.repository_name,
9020 create_pr_url: branch.create_pr_url,
9021 last_commit: branch.last_commit.map(Self::convert_commit),
9022 });
9023 }
9024 for repo in detail.repositories {
9025 status.repositories.push(JiraDevRepository {
9026 name: repo.name,
9027 url: repo.url,
9028 commits: repo.commits.into_iter().map(Self::convert_commit).collect(),
9029 });
9030 }
9031 }
9032 }
9033 }
9034
9035 Ok(status)
9036 }
9037
9038 fn convert_commit(c: DevStatusCommit) -> JiraDevCommit {
9040 JiraDevCommit {
9041 id: c.id,
9042 display_id: c.display_id,
9043 message: c.message,
9044 author: c.author.map(|a| a.name),
9045 timestamp: c.author_timestamp,
9046 url: c.url,
9047 file_count: c.file_count,
9048 merge: c.merge,
9049 }
9050 }
9051
9052 pub async fn get_attachments(&self, key: &str) -> Result<Vec<JiraAttachment>> {
9054 let url = format!(
9055 "{}/rest/api/3/issue/{}?fields=attachment",
9056 self.instance_url, key
9057 );
9058
9059 let response = self.get_json(&url).await?;
9060
9061 let resp: JiraAttachmentIssueResponse = Self::parse_json(
9062 Self::ensure_success(response).await?,
9063 "Failed to parse attachment response",
9064 )
9065 .await?;
9066
9067 Ok(resp
9068 .fields
9069 .attachment
9070 .into_iter()
9071 .map(JiraAttachment::from)
9072 .collect())
9073 }
9074
9075 pub async fn upload_attachments(
9085 &self,
9086 key: &str,
9087 files: &[PathBuf],
9088 ) -> Result<Vec<JiraAttachment>> {
9089 let mut form = reqwest::multipart::Form::new();
9090 for file in files {
9091 let metadata = tokio::fs::metadata(file)
9092 .await
9093 .with_context(|| format!("Failed to read file metadata for {}", file.display()))?;
9094 let size = metadata.len();
9095 let handle = tokio::fs::File::open(file)
9096 .await
9097 .with_context(|| format!("Failed to open {}", file.display()))?;
9098
9099 let filename = file
9100 .file_name()
9101 .map(|s| s.to_string_lossy().into_owned())
9102 .ok_or_else(|| {
9103 anyhow::anyhow!("File path has no filename component: {}", file.display())
9104 })?;
9105
9106 let mime = mime_guess::from_path(file).first_or_octet_stream();
9107 let body = reqwest::Body::wrap_stream(ReaderStream::new(handle));
9108 let part = reqwest::multipart::Part::stream_with_length(body, size)
9109 .file_name(filename)
9110 .mime_str(mime.essence_str())
9111 .with_context(|| format!("Invalid MIME type for {}", file.display()))?;
9112 form = form.part("file", part);
9113 }
9114
9115 let url = format!("{}/rest/api/3/issue/{}/attachments", self.instance_url, key);
9116
9117 let response = self
9118 .post_multipart(&url, form, &[("X-Atlassian-Token", "no-check")])
9119 .await?;
9120
9121 let entries: Vec<JiraAttachmentEntry> = Self::parse_json(
9122 Self::ensure_success(response).await?,
9123 "Failed to parse attachment upload response",
9124 )
9125 .await?;
9126
9127 Ok(entries.into_iter().map(JiraAttachment::from).collect())
9128 }
9129
9130 pub async fn delete_attachment(&self, attachment_id: &str) -> Result<()> {
9134 let url = format!(
9135 "{}/rest/api/3/attachment/{}",
9136 self.instance_url, attachment_id
9137 );
9138 let response = self.delete(&url).await?;
9139 Self::ensure_success(response).await?;
9140 Ok(())
9141 }
9142
9143 pub async fn get_changelog(&self, key: &str, limit: u32) -> Result<Vec<JiraChangelogEntry>> {
9145 let effective_limit = if limit == 0 { u32::MAX } else { limit };
9146 let mut all_entries = Vec::new();
9147 let mut start_at: u32 = 0;
9148
9149 loop {
9150 let remaining = effective_limit.saturating_sub(all_entries.len() as u32);
9151 if remaining == 0 {
9152 break;
9153 }
9154 let page_size = remaining.min(PAGE_SIZE);
9155
9156 let url = format!(
9157 "{}/rest/api/3/issue/{}/changelog?maxResults={}&startAt={}",
9158 self.instance_url, key, page_size, start_at
9159 );
9160
9161 let response = self.get_json(&url).await?;
9162
9163 let resp: JiraChangelogResponse = Self::parse_json(
9164 Self::ensure_success(response).await?,
9165 "Failed to parse changelog response",
9166 )
9167 .await?;
9168
9169 let page_count = resp.values.len() as u32;
9170 for e in resp.values {
9171 all_entries.push(JiraChangelogEntry {
9172 id: e.id,
9173 author: e.author.and_then(|a| a.display_name).unwrap_or_default(),
9174 created: e.created.unwrap_or_default(),
9175 items: e
9176 .items
9177 .into_iter()
9178 .map(|i| JiraChangelogItem {
9179 field: i.field,
9180 from_string: i.from_string,
9181 to_string: i.to_string,
9182 })
9183 .collect(),
9184 });
9185 }
9186
9187 if resp.is_last || page_count == 0 {
9188 break;
9189 }
9190 start_at += page_count;
9191 }
9192
9193 Ok(all_entries)
9194 }
9195
9196 pub async fn get_fields(&self) -> Result<Vec<JiraField>> {
9198 let url = format!("{}/rest/api/3/field", self.instance_url);
9199
9200 let response = self.get_json(&url).await?;
9201
9202 let entries: Vec<JiraFieldEntry> = Self::parse_json(
9203 Self::ensure_success(response).await?,
9204 "Failed to parse field list response",
9205 )
9206 .await?;
9207
9208 Ok(entries
9209 .into_iter()
9210 .map(|f| {
9211 let (raw_type, raw_custom) = match f.schema {
9212 Some(s) => (s.schema_type, s.custom),
9213 None => (None, None),
9214 };
9215 JiraField {
9216 id: f.id,
9217 name: f.name,
9218 custom: f.custom,
9219 schema_type: map_schema_type(raw_type, raw_custom.as_deref()),
9220 schema_custom: raw_custom,
9221 }
9222 })
9223 .collect())
9224 }
9225
9226 pub async fn get_field_contexts(&self, field_id: &str) -> Result<Vec<String>> {
9229 let url = format!(
9230 "{}/rest/api/3/field/{}/context",
9231 self.instance_url, field_id
9232 );
9233
9234 let response = self.get_json(&url).await?;
9235
9236 let resp: JiraFieldContextsResponse = Self::parse_json(
9237 Self::ensure_success(response).await?,
9238 "Failed to parse field contexts response",
9239 )
9240 .await?;
9241
9242 Ok(resp.values.into_iter().map(|c| c.id).collect())
9243 }
9244
9245 pub async fn get_field_options(
9249 &self,
9250 field_id: &str,
9251 context_id: Option<&str>,
9252 ) -> Result<Vec<JiraFieldOption>> {
9253 let ctx = if let Some(id) = context_id {
9254 id.to_string()
9255 } else {
9256 let contexts = self.get_field_contexts(field_id).await?;
9257 contexts.into_iter().next().ok_or_else(|| {
9258 anyhow::anyhow!(
9259 "No contexts found for field \"{field_id}\". \
9260 Use --context-id to specify one explicitly."
9261 )
9262 })?
9263 };
9264
9265 let url = format!(
9266 "{}/rest/api/3/field/{}/context/{}/option",
9267 self.instance_url, field_id, ctx
9268 );
9269
9270 let response = self.get_json(&url).await?;
9271
9272 let resp: JiraFieldOptionsResponse = Self::parse_json(
9273 Self::ensure_success(response).await?,
9274 "Failed to parse field options response",
9275 )
9276 .await?;
9277
9278 Ok(resp
9279 .values
9280 .into_iter()
9281 .map(|o| JiraFieldOption {
9282 id: o.id,
9283 value: o.value,
9284 })
9285 .collect())
9286 }
9287
9288 pub async fn get_projects(&self, limit: u32) -> Result<JiraProjectList> {
9290 let effective_limit = if limit == 0 { u32::MAX } else { limit };
9291 let mut all_projects = Vec::new();
9292 let mut start_at: u32 = 0;
9293
9294 loop {
9295 let remaining = effective_limit.saturating_sub(all_projects.len() as u32);
9296 if remaining == 0 {
9297 break;
9298 }
9299 let page_size = remaining.min(PAGE_SIZE);
9300
9301 let url = format!(
9302 "{}/rest/api/3/project/search?maxResults={}&startAt={}",
9303 self.instance_url, page_size, start_at
9304 );
9305
9306 let response = self.get_json(&url).await?;
9307
9308 let resp: JiraProjectSearchResponse = Self::parse_json(
9309 Self::ensure_success(response).await?,
9310 "Failed to parse project search response",
9311 )
9312 .await?;
9313
9314 let page_count = resp.values.len() as u32;
9315 for p in resp.values {
9316 all_projects.push(JiraProject {
9317 id: p.id,
9318 key: p.key,
9319 name: p.name,
9320 project_type: p.project_type_key,
9321 lead: p.lead.and_then(|l| l.display_name),
9322 });
9323 }
9324
9325 if resp.is_last || page_count == 0 {
9326 break;
9327 }
9328 start_at += page_count;
9329 }
9330
9331 let total = all_projects.len() as u32;
9332 Ok(JiraProjectList {
9333 projects: all_projects,
9334 total,
9335 })
9336 }
9337
9338 pub async fn delete_issue(&self, key: &str) -> Result<()> {
9340 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
9341
9342 let response = self.delete(&url).await?;
9343
9344 Self::ensure_success(response).await?;
9345
9346 Ok(())
9347 }
9348
9349 pub async fn get_watchers(&self, key: &str) -> Result<JiraWatcherList> {
9351 let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
9352
9353 let response = self.get_json(&url).await?;
9354
9355 let json: serde_json::Value = Self::parse_json(
9356 Self::ensure_success(response).await?,
9357 "Failed to parse watchers response",
9358 )
9359 .await?;
9360
9361 let watch_count = json["watchCount"].as_u64().unwrap_or(0) as u32;
9362
9363 let watchers = json["watchers"]
9364 .as_array()
9365 .map(|arr| {
9366 arr.iter()
9367 .filter_map(|v| serde_json::from_value::<JiraUser>(v.clone()).ok())
9368 .collect()
9369 })
9370 .unwrap_or_default();
9371
9372 Ok(JiraWatcherList {
9373 watchers,
9374 watch_count,
9375 })
9376 }
9377
9378 pub async fn add_watcher(&self, key: &str, account_id: &str) -> Result<()> {
9380 let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
9381
9382 let body = serde_json::json!(account_id);
9383
9384 let response = self.post_json(&url, &body).await?;
9385
9386 Self::ensure_success(response).await?;
9387
9388 Ok(())
9389 }
9390
9391 pub async fn remove_watcher(&self, key: &str, account_id: &str) -> Result<()> {
9393 let url = format!(
9394 "{}/rest/api/3/issue/{}/watchers?accountId={}",
9395 self.instance_url, key, account_id
9396 );
9397
9398 let response = self.delete(&url).await?;
9399
9400 Self::ensure_success(response).await?;
9401
9402 Ok(())
9403 }
9404
9405 pub async fn auth_identity(&self, service: AuthService) -> Result<AuthIdentity> {
9407 let path = match (self.auth_mode, service) {
9408 (AuthMode::Basic, AuthService::Jira) => "/rest/api/3/myself",
9409 (AuthMode::Bearer, AuthService::Jira) => "/rest/api/2/myself",
9410 (AuthMode::Basic, AuthService::Confluence) => "/wiki/rest/api/user/current",
9411 (AuthMode::Bearer, AuthService::Confluence) => "/rest/api/user/current",
9412 };
9413 let url = format!("{}{path}", self.instance_url);
9414 let response = self.get_json(&url).await?;
9415 if !response.status().is_success() {
9417 anyhow::bail!(
9418 "Atlassian authentication failed: HTTP {}",
9419 response.status().as_u16()
9420 );
9421 }
9422 let identity: AuthIdentity = response
9423 .json()
9424 .await
9425 .context("Failed to parse authenticated user")?;
9426 let has_id = [&identity.account_id, &identity.username, &identity.key]
9427 .iter()
9428 .any(|id| id.as_ref().is_some_and(|value| !value.trim().is_empty()));
9429 if identity.user_type.as_deref() == Some("anonymous") || !has_id {
9430 anyhow::bail!("Atlassian did not return an authenticated user");
9431 }
9432 Ok(identity)
9433 }
9434
9435 pub async fn get_myself(&self) -> Result<JiraUser> {
9437 let url = format!("{}/rest/api/3/myself", self.instance_url);
9438
9439 let response = self
9440 .client
9441 .get(&url)
9442 .header("Authorization", &self.auth_header)
9443 .header("Accept", "application/json")
9444 .send()
9445 .await
9446 .context("Failed to send request to JIRA API")?;
9447
9448 let response = Self::ensure_success(response).await?;
9449
9450 response
9451 .json()
9452 .await
9453 .context("Failed to parse user response")
9454 }
9455}
9456
9457#[cfg(test)]
9458#[allow(clippy::unwrap_used)]
9459mod pat_tests {
9460 use super::*;
9461 use wiremock::{
9462 matchers::{header, method, path},
9463 Mock, MockServer, ResponseTemplate,
9464 };
9465
9466 fn bearer(base: &str) -> AtlassianClient {
9467 AtlassianClient::with_auth(
9468 base,
9469 &AtlassianAuth::Bearer {
9470 token: "private-pat".into(),
9471 },
9472 )
9473 .unwrap()
9474 }
9475
9476 #[test]
9477 fn bearer_header_is_exact_sensitive_and_rejects_control_characters() {
9478 let client = bearer("https://self.example");
9479 assert_eq!(client.auth_header.to_str().unwrap(), "Bearer private-pat");
9480 assert!(client.auth_header.is_sensitive());
9481 assert!(!format!("{:?}", client.auth_header).contains("private-pat"));
9482 let err = AtlassianClient::with_auth(
9483 "https://self.example",
9484 &AtlassianAuth::Bearer {
9485 token: "private-pat\r\nInjected: yes".into(),
9486 },
9487 )
9488 .err()
9489 .unwrap();
9490 assert!(!format!("{err:#}").contains("private-pat"));
9491 }
9492
9493 #[tokio::test]
9494 async fn bearer_authenticates_shared_transports_and_multipart() {
9495 let server = MockServer::start().await;
9496 for verb in ["GET", "POST", "PUT", "DELETE"] {
9497 Mock::given(method(verb))
9498 .and(header("Authorization", "Bearer private-pat"))
9499 .respond_with(ResponseTemplate::new(200))
9500 .expect(if verb == "POST" { 2 } else { 1 })
9501 .mount(&server)
9502 .await;
9503 }
9504 let client = bearer(&server.uri());
9505 assert!(client
9506 .get_json(&server.uri())
9507 .await
9508 .unwrap()
9509 .status()
9510 .is_success());
9511 assert!(client
9512 .post_json(&server.uri(), &serde_json::json!({}))
9513 .await
9514 .unwrap()
9515 .status()
9516 .is_success());
9517 assert!(client
9518 .put_json(&server.uri(), &serde_json::json!({}))
9519 .await
9520 .unwrap()
9521 .status()
9522 .is_success());
9523 assert!(client
9524 .delete(&server.uri())
9525 .await
9526 .unwrap()
9527 .status()
9528 .is_success());
9529 assert!(client
9530 .post_multipart(&server.uri(), reqwest::multipart::Form::new(), &[])
9531 .await
9532 .unwrap()
9533 .status()
9534 .is_success());
9535 }
9536
9537 #[tokio::test]
9538 async fn self_hosted_probes_preserve_context_and_identity() {
9539 for (service, endpoint, identity) in [
9540 (
9541 AuthService::Jira,
9542 "/context/rest/api/2/myself",
9543 serde_json::json!({"displayName":"Alice", "name":"alice", "key":"JIRAUSER1"}),
9544 ),
9545 (
9546 AuthService::Confluence,
9547 "/context/rest/api/user/current",
9548 serde_json::json!({"displayName":"Alice", "username":"alice", "userKey":"user1", "type":"known"}),
9549 ),
9550 ] {
9551 let server = MockServer::start().await;
9552 Mock::given(method("GET"))
9553 .and(path(endpoint))
9554 .and(header("Authorization", "Bearer private-pat"))
9555 .respond_with(ResponseTemplate::new(200).set_body_json(identity))
9556 .expect(1)
9557 .mount(&server)
9558 .await;
9559 let client = bearer(&format!("{}/context/", server.uri()));
9560 let user = client.auth_identity(service).await.unwrap();
9561 assert_eq!(user.username.as_deref(), Some("alice"));
9562 assert!(user.account_id.is_none());
9563 assert!(user.key.is_some());
9564 }
9565 }
9566
9567 #[tokio::test]
9568 async fn cloud_confluence_probe_uses_basic_and_wiki_path() {
9569 let server = MockServer::start().await;
9570 let client = AtlassianClient::new(&server.uri(), "alice@example.com", "token").unwrap();
9571 Mock::given(method("GET"))
9572 .and(path("/wiki/rest/api/user/current"))
9573 .and(header(
9574 "Authorization",
9575 client.auth_header.to_str().unwrap(),
9576 ))
9577 .respond_with(ResponseTemplate::new(200).set_body_json(
9578 serde_json::json!({"displayName":"Alice", "accountId":"cloud-id", "type":"known"}),
9579 ))
9580 .expect(1)
9581 .mount(&server)
9582 .await;
9583 assert_eq!(
9584 client
9585 .auth_identity(AuthService::Confluence)
9586 .await
9587 .unwrap()
9588 .account_id
9589 .as_deref(),
9590 Some("cloud-id")
9591 );
9592 }
9593
9594 #[tokio::test]
9595 async fn probes_reject_anonymous_malformed_and_auth_errors_without_fallback() {
9596 for (status, body) in [
9597 (200, r#"{"displayName":"Anonymous","type":"anonymous"}"#),
9598 (
9599 200,
9600 r#"{"displayName":"Anonymous","username":"anonymous","type":"anonymous"}"#,
9601 ),
9602 (200, r#"{"displayName":"Alice"}"#),
9603 (200, "malformed"),
9604 (401, "private-pat"),
9605 (403, "private-pat"),
9606 ] {
9607 let server = MockServer::start().await;
9608 Mock::given(method("GET"))
9609 .and(path("/rest/api/user/current"))
9610 .and(header("Authorization", "Bearer private-pat"))
9611 .respond_with(ResponseTemplate::new(status).set_body_string(body))
9612 .expect(1)
9613 .mount(&server)
9614 .await;
9615 let err = bearer(&server.uri())
9616 .auth_identity(AuthService::Confluence)
9617 .await
9618 .unwrap_err();
9619 assert!(!format!("{err:#}").contains("private-pat"));
9620 assert_eq!(server.received_requests().await.unwrap().len(), 1);
9621 }
9622 }
9623}