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