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