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