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