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::{retry_429, REQUEST_TIMEOUT};
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 .timeout(REQUEST_TIMEOUT)
6506 .build()
6507 .context("Failed to build HTTP client")?;
6508
6509 let credentials = format!("{email}:{api_token}");
6510 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
6511 let auth_header = format!("Basic {encoded}");
6512
6513 Ok(Self {
6514 client,
6515 instance_url: instance_url.trim_end_matches('/').to_string(),
6516 auth_header,
6517 })
6518 }
6519
6520 pub fn from_credentials(creds: &crate::atlassian::auth::AtlassianCredentials) -> Result<Self> {
6522 Self::new(
6523 &creds.instance_url,
6524 &creds.email,
6525 creds.api_token.expose_secret(),
6526 )
6527 }
6528
6529 #[must_use]
6531 pub fn instance_url(&self) -> &str {
6532 &self.instance_url
6533 }
6534
6535 fn log_request(
6538 &self,
6539 method: &str,
6540 url: &str,
6541 started: Instant,
6542 result: &reqwest::Result<reqwest::Response>,
6543 ) {
6544 let service = if url.contains("/wiki/") {
6545 "confluence"
6546 } else {
6547 "jira"
6548 };
6549 request_log::record_http_result(service, method, url, started, result);
6550 }
6551
6552 pub async fn get_json(&self, url: &str) -> Result<reqwest::Response> {
6557 retry_429(
6558 || {
6559 self.client
6560 .get(url)
6561 .header("Authorization", &self.auth_header)
6562 .header("Accept", "application/json")
6563 },
6564 |started, result| self.log_request("GET", url, started, result),
6565 )
6566 .await
6567 .context("Failed to send GET request to Atlassian API")
6568 }
6569
6570 pub async fn put_json<T: serde::Serialize + Sync + ?Sized>(
6575 &self,
6576 url: &str,
6577 body: &T,
6578 ) -> Result<reqwest::Response> {
6579 retry_429(
6580 || {
6581 self.client
6582 .put(url)
6583 .header("Authorization", &self.auth_header)
6584 .header("Content-Type", "application/json")
6585 .json(body)
6586 },
6587 |started, result| self.log_request("PUT", url, started, result),
6588 )
6589 .await
6590 .context("Failed to send PUT request to Atlassian API")
6591 }
6592
6593 pub async fn post_json<T: serde::Serialize + Sync + ?Sized>(
6595 &self,
6596 url: &str,
6597 body: &T,
6598 ) -> Result<reqwest::Response> {
6599 retry_429(
6600 || {
6601 self.client
6602 .post(url)
6603 .header("Authorization", &self.auth_header)
6604 .header("Content-Type", "application/json")
6605 .json(body)
6606 },
6607 |started, result| self.log_request("POST", url, started, result),
6608 )
6609 .await
6610 .context("Failed to send POST request to Atlassian API")
6611 }
6612
6613 pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
6615 let response = self.get_json_raw_accept(url, "*/*").await?;
6616
6617 let response = Self::ensure_success(response).await?;
6618
6619 let bytes = response
6620 .bytes()
6621 .await
6622 .context("Failed to read response bytes")?;
6623 Ok(bytes.to_vec())
6624 }
6625
6626 pub async fn delete(&self, url: &str) -> Result<reqwest::Response> {
6628 retry_429(
6629 || {
6630 self.client
6631 .delete(url)
6632 .header("Authorization", &self.auth_header)
6633 },
6634 |started, result| self.log_request("DELETE", url, started, result),
6635 )
6636 .await
6637 .context("Failed to send DELETE request to Atlassian API")
6638 }
6639
6640 pub async fn post_multipart(
6645 &self,
6646 url: &str,
6647 form: reqwest::multipart::Form,
6648 extra_headers: &[(&str, &str)],
6649 ) -> Result<reqwest::Response> {
6650 let mut req = self
6651 .client
6652 .post(url)
6653 .header("Authorization", &self.auth_header)
6654 .multipart(form);
6655 for (name, value) in extra_headers {
6656 req = req.header(*name, *value);
6657 }
6658 let started = Instant::now();
6659 let result = req.send().await;
6660 self.log_request("POST", url, started, &result);
6661 result.context("Failed to send multipart POST request to Atlassian API")
6662 }
6663
6664 async fn get_json_raw_accept(&self, url: &str, accept: &str) -> Result<reqwest::Response> {
6666 retry_429(
6667 || {
6668 self.client
6669 .get(url)
6670 .header("Authorization", &self.auth_header)
6671 .header("Accept", accept)
6672 },
6673 |started, result| self.log_request("GET", url, started, result),
6674 )
6675 .await
6676 .context("Failed to send GET request to Atlassian API")
6677 }
6678
6679 pub(crate) async fn ensure_success(response: reqwest::Response) -> Result<reqwest::Response> {
6687 if response.status().is_success() {
6688 return Ok(response);
6689 }
6690 let status = response.status().as_u16();
6691 let body = response.text().await.unwrap_or_default();
6692 Err(AtlassianError::ApiRequestFailed { status, body }.into())
6693 }
6694
6695 pub(crate) async fn parse_json<T: serde::de::DeserializeOwned>(
6703 response: reqwest::Response,
6704 context: &'static str,
6705 ) -> Result<T> {
6706 response.json().await.context(context)
6707 }
6708
6709 pub async fn get_issue(&self, key: &str) -> Result<JiraIssue> {
6715 self.get_issue_with_fields(key, FieldSelection::Standard)
6716 .await
6717 }
6718
6719 pub async fn get_issue_with_fields(
6726 &self,
6727 key: &str,
6728 selection: FieldSelection,
6729 ) -> Result<JiraIssue> {
6730 const STANDARD_FIELDS: &str =
6731 "summary,description,status,issuetype,assignee,priority,labels";
6732
6733 let fields_param = match &selection {
6734 FieldSelection::Standard => STANDARD_FIELDS.to_string(),
6735 FieldSelection::Named(names) => {
6736 let mut parts: Vec<&str> = STANDARD_FIELDS.split(',').collect();
6737 parts.extend(names.iter().map(String::as_str));
6738 parts.join(",")
6739 }
6740 FieldSelection::All => "*all".to_string(),
6741 };
6742
6743 let base = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
6744 let url = reqwest::Url::parse_with_params(
6745 &base,
6746 &[
6747 ("fields", fields_param.as_str()),
6748 ("expand", "names,schema"),
6749 ],
6750 )
6751 .context("Failed to build JIRA issue URL")?;
6752
6753 let response = self
6754 .client
6755 .get(url)
6756 .header("Authorization", &self.auth_header)
6757 .header("Accept", "application/json")
6758 .send()
6759 .await
6760 .context("Failed to send request to JIRA API")?;
6761
6762 let envelope: JiraIssueEnvelope = Self::parse_json(
6763 Self::ensure_success(response).await?,
6764 "Failed to parse JIRA issue response",
6765 )
6766 .await?;
6767
6768 Ok(envelope.into_issue(&selection))
6769 }
6770
6771 pub async fn update_issue(
6776 &self,
6777 key: &str,
6778 description_adf: &ValidatedAdfDocument,
6779 summary: Option<&str>,
6780 ) -> Result<()> {
6781 self.update_issue_with_custom_fields(
6782 key,
6783 Some(description_adf),
6784 summary,
6785 &std::collections::BTreeMap::new(),
6786 )
6787 .await
6788 }
6789
6790 pub async fn update_issue_with_custom_fields(
6800 &self,
6801 key: &str,
6802 description_adf: Option<&ValidatedAdfDocument>,
6803 summary: Option<&str>,
6804 custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
6805 ) -> Result<()> {
6806 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
6807
6808 let mut fields = serde_json::Map::new();
6809 if let Some(adf) = description_adf {
6810 fields.insert(
6811 "description".to_string(),
6812 serde_json::to_value(adf).context("Failed to serialize ADF document")?,
6813 );
6814 }
6815 if let Some(summary_text) = summary {
6816 fields.insert(
6817 "summary".to_string(),
6818 serde_json::Value::String(summary_text.to_string()),
6819 );
6820 }
6821 for (id, value) in custom_fields {
6822 fields.insert(id.clone(), value.clone());
6823 }
6824
6825 if fields.is_empty() {
6826 anyhow::bail!("update_issue_with_custom_fields: no fields to update");
6827 }
6828
6829 let body = serde_json::json!({ "fields": fields });
6830
6831 let response = self
6832 .client
6833 .put(&url)
6834 .header("Authorization", &self.auth_header)
6835 .header("Content-Type", "application/json")
6836 .json(&body)
6837 .send()
6838 .await
6839 .context("Failed to send update request to JIRA API")?;
6840
6841 if !response.status().is_success() {
6842 let status = response.status().as_u16();
6843 let body = response.text().await.unwrap_or_default();
6844 return Err(jira_write_error(status, body));
6845 }
6846
6847 Ok(())
6848 }
6849
6850 pub async fn get_editmeta(&self, key: &str) -> Result<EditMeta> {
6856 let url = format!("{}/rest/api/3/issue/{}/editmeta", self.instance_url, key);
6857
6858 let response = self
6859 .client
6860 .get(&url)
6861 .header("Authorization", &self.auth_header)
6862 .header("Accept", "application/json")
6863 .send()
6864 .await
6865 .context("Failed to send editmeta request to JIRA API")?;
6866
6867 let raw: JiraEditMetaResponse = Self::parse_json(
6868 Self::ensure_success(response).await?,
6869 "Failed to parse JIRA editmeta response",
6870 )
6871 .await?;
6872
6873 Ok(edit_meta_from_raw_fields(raw.fields))
6874 }
6875
6876 pub async fn create_issue(
6881 &self,
6882 project_key: &str,
6883 issue_type: &str,
6884 summary: &str,
6885 description_adf: Option<&ValidatedAdfDocument>,
6886 labels: &[String],
6887 ) -> Result<JiraCreatedIssue> {
6888 self.create_issue_with_custom_fields(
6889 project_key,
6890 issue_type,
6891 summary,
6892 description_adf,
6893 labels,
6894 &std::collections::BTreeMap::new(),
6895 )
6896 .await
6897 }
6898
6899 pub async fn create_issue_with_custom_fields(
6902 &self,
6903 project_key: &str,
6904 issue_type: &str,
6905 summary: &str,
6906 description_adf: Option<&ValidatedAdfDocument>,
6907 labels: &[String],
6908 custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
6909 ) -> Result<JiraCreatedIssue> {
6910 let url = format!("{}/rest/api/3/issue", self.instance_url);
6911
6912 let mut fields = serde_json::Map::new();
6913 fields.insert(
6914 "project".to_string(),
6915 serde_json::json!({ "key": project_key }),
6916 );
6917 fields.insert(
6918 "issuetype".to_string(),
6919 serde_json::json!({ "name": issue_type }),
6920 );
6921 fields.insert(
6922 "summary".to_string(),
6923 serde_json::Value::String(summary.to_string()),
6924 );
6925 if let Some(adf) = description_adf {
6926 fields.insert(
6927 "description".to_string(),
6928 serde_json::to_value(adf).context("Failed to serialize ADF document")?,
6929 );
6930 }
6931 if !labels.is_empty() {
6932 fields.insert("labels".to_string(), serde_json::to_value(labels)?);
6933 }
6934 for (id, value) in custom_fields {
6935 fields.insert(id.clone(), value.clone());
6936 }
6937
6938 let body = serde_json::json!({ "fields": fields });
6939
6940 let response = self
6941 .post_json(&url, &body)
6942 .await
6943 .context("Failed to send create request to JIRA API")?;
6944
6945 if !response.status().is_success() {
6946 let status = response.status().as_u16();
6947 let body = response.text().await.unwrap_or_default();
6948 return Err(jira_write_error(status, body));
6952 }
6953
6954 let create_response: JiraCreateResponse = response
6955 .json()
6956 .await
6957 .context("Failed to parse JIRA create response")?;
6958
6959 Ok(JiraCreatedIssue {
6960 key: create_response.key,
6961 id: create_response.id,
6962 self_url: create_response.self_url,
6963 })
6964 }
6965
6966 pub async fn get_createmeta(&self, project_key: &str, issue_type: &str) -> Result<EditMeta> {
6973 let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
6974 let url = reqwest::Url::parse_with_params(
6975 &base,
6976 &[
6977 ("projectKeys", project_key),
6978 ("issuetypeNames", issue_type),
6979 ("expand", "projects.issuetypes.fields"),
6980 ],
6981 )
6982 .context("Failed to build JIRA createmeta URL")?;
6983
6984 let response = self
6985 .client
6986 .get(url)
6987 .header("Authorization", &self.auth_header)
6988 .header("Accept", "application/json")
6989 .send()
6990 .await
6991 .context("Failed to send createmeta request to JIRA API")?;
6992
6993 let raw: JiraCreateMetaResponse = Self::parse_json(
6994 Self::ensure_success(response).await?,
6995 "Failed to parse JIRA createmeta response",
6996 )
6997 .await?;
6998
6999 let Some(project) = raw.projects.into_iter().next() else {
7000 return Ok(EditMeta::default());
7001 };
7002 let Some(issuetype) = project.issuetypes.into_iter().next() else {
7003 return Ok(EditMeta::default());
7004 };
7005
7006 Ok(edit_meta_from_raw_fields(issuetype.fields))
7007 }
7008
7009 pub async fn get_project_create_meta(
7016 &self,
7017 project_key: &str,
7018 issue_type: &str,
7019 ) -> Result<CreateMeta> {
7020 let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
7021 let url = reqwest::Url::parse_with_params(
7022 &base,
7023 &[
7024 ("projectKeys", project_key),
7025 ("issuetypeNames", issue_type),
7026 ("expand", "projects.issuetypes.fields"),
7027 ],
7028 )
7029 .context("Failed to build JIRA createmeta URL")?;
7030
7031 let response = self
7032 .client
7033 .get(url)
7034 .header("Authorization", &self.auth_header)
7035 .header("Accept", "application/json")
7036 .send()
7037 .await
7038 .context("Failed to send createmeta request to JIRA API")?;
7039
7040 let raw: JiraCreateMetaFullResponse = Self::parse_json(
7041 Self::ensure_success(response).await?,
7042 "Failed to parse JIRA createmeta response",
7043 )
7044 .await?;
7045
7046 let mut fields: Vec<CreateMetaField> = raw
7047 .projects
7048 .into_iter()
7049 .next()
7050 .and_then(|p| p.issuetypes.into_iter().next())
7051 .map(|it| {
7052 it.fields
7053 .into_iter()
7054 .map(|(field_id, field)| {
7055 let schema = field.schema.unwrap_or(JiraCreateMetaSchemaRaw {
7056 kind: None,
7057 items: None,
7058 custom: None,
7059 });
7060 CreateMetaField {
7061 field_id,
7062 name: field.name.unwrap_or_default(),
7063 required: field.required,
7064 schema_type: schema.kind.unwrap_or_default(),
7065 items: schema.items,
7066 custom: schema.custom,
7067 allowed_values: field
7068 .allowed_values
7069 .into_iter()
7070 .map(JiraAllowedValueRaw::into_allowed_value)
7071 .collect(),
7072 default_value: field.default_value,
7073 }
7074 })
7075 .collect()
7076 })
7077 .unwrap_or_default();
7078
7079 fields.sort_by(|a, b| {
7081 b.required
7082 .cmp(&a.required)
7083 .then_with(|| a.name.cmp(&b.name))
7084 });
7085
7086 Ok(CreateMeta {
7087 project: project_key.to_string(),
7088 issue_type: issue_type.to_string(),
7089 fields,
7090 })
7091 }
7092
7093 pub async fn get_comments(&self, key: &str, limit: u32) -> Result<Vec<JiraComment>> {
7097 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7098 let mut all_comments = Vec::new();
7099 let mut start_at: u32 = 0;
7100
7101 loop {
7102 let remaining = effective_limit.saturating_sub(all_comments.len() as u32);
7103 if remaining == 0 {
7104 break;
7105 }
7106 let page_size = remaining.min(PAGE_SIZE);
7107
7108 let url = format!(
7109 "{}/rest/api/3/issue/{}/comment?orderBy=created&maxResults={}&startAt={}",
7110 self.instance_url, key, page_size, start_at
7111 );
7112
7113 let response = self.get_json(&url).await?;
7114
7115 let resp: JiraCommentsResponse = Self::parse_json(
7116 Self::ensure_success(response).await?,
7117 "Failed to parse comments response",
7118 )
7119 .await?;
7120
7121 let page_count = resp.comments.len() as u32;
7122 for c in resp.comments {
7123 all_comments.push(JiraComment {
7124 id: c.id,
7125 author: c.author.and_then(|a| a.display_name).unwrap_or_default(),
7126 body_adf: c.body,
7127 created: c.created.unwrap_or_default(),
7128 updated: c.updated,
7129 });
7130 }
7131
7132 if page_count == 0 {
7133 break;
7134 }
7135
7136 let fetched = resp.start_at.saturating_add(page_count);
7137 if fetched >= resp.total {
7138 break;
7139 }
7140
7141 start_at += page_count;
7142 }
7143
7144 Ok(all_comments)
7145 }
7146
7147 pub async fn add_comment(&self, key: &str, body_adf: &ValidatedAdfDocument) -> Result<()> {
7149 let url = format!("{}/rest/api/3/issue/{}/comment", self.instance_url, key);
7150
7151 let body = serde_json::json!({
7152 "body": body_adf
7153 });
7154
7155 let response = self.post_json(&url, &body).await?;
7156
7157 Self::ensure_success(response).await?;
7158
7159 Ok(())
7160 }
7161
7162 pub async fn update_comment(
7169 &self,
7170 key: &str,
7171 comment_id: &str,
7172 body_adf: &ValidatedAdfDocument,
7173 visibility: Option<&JiraVisibility>,
7174 ) -> Result<JiraComment> {
7175 let url = format!(
7176 "{}/rest/api/3/issue/{}/comment/{}",
7177 self.instance_url, key, comment_id
7178 );
7179
7180 let mut body = serde_json::json!({ "body": body_adf });
7181 if let Some(v) = visibility {
7182 body["visibility"] =
7183 serde_json::to_value(v).context("Failed to serialize comment visibility")?;
7184 }
7185
7186 let response = self.put_json(&url, &body).await?;
7187
7188 let entry: JiraCommentEntry = Self::parse_json(
7189 Self::ensure_success(response).await?,
7190 "Failed to parse updated comment response",
7191 )
7192 .await?;
7193
7194 Ok(JiraComment {
7195 id: entry.id,
7196 author: entry
7197 .author
7198 .and_then(|a| a.display_name)
7199 .unwrap_or_default(),
7200 body_adf: entry.body,
7201 created: entry.created.unwrap_or_default(),
7202 updated: entry.updated,
7203 })
7204 }
7205
7206 pub async fn delete_comment(&self, key: &str, comment_id: &str) -> Result<()> {
7210 let url = format!(
7211 "{}/rest/api/3/issue/{}/comment/{}",
7212 self.instance_url, key, comment_id
7213 );
7214
7215 let response = self.delete(&url).await?;
7216
7217 Self::ensure_success(response).await?;
7218
7219 Ok(())
7220 }
7221
7222 pub async fn get_worklogs(&self, key: &str, limit: u32) -> Result<JiraWorklogList> {
7224 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7225 let url = format!(
7226 "{}/rest/api/3/issue/{}/worklog?maxResults={}",
7227 self.instance_url,
7228 key,
7229 effective_limit.min(5000)
7230 );
7231
7232 let response = self.get_json(&url).await?;
7233
7234 let resp: JiraWorklogResponse = Self::parse_json(
7235 Self::ensure_success(response).await?,
7236 "Failed to parse worklog response",
7237 )
7238 .await?;
7239
7240 let worklogs: Vec<JiraWorklog> = resp
7241 .worklogs
7242 .into_iter()
7243 .take(effective_limit as usize)
7244 .map(|w| JiraWorklog {
7245 id: w.id,
7246 author: w.author.and_then(|a| a.display_name).unwrap_or_default(),
7247 time_spent: w.time_spent.unwrap_or_default(),
7248 time_spent_seconds: w.time_spent_seconds,
7249 started: w.started.unwrap_or_default(),
7250 comment: Self::extract_worklog_comment(w.comment.as_ref()),
7251 })
7252 .collect();
7253
7254 Ok(JiraWorklogList {
7255 total: resp.total,
7256 worklogs,
7257 })
7258 }
7259
7260 pub async fn add_worklog(
7262 &self,
7263 key: &str,
7264 time_spent: &str,
7265 started: Option<&str>,
7266 comment: Option<&str>,
7267 ) -> Result<()> {
7268 let url = format!("{}/rest/api/3/issue/{}/worklog", self.instance_url, key);
7269
7270 let mut body = serde_json::json!({
7271 "timeSpent": time_spent,
7272 });
7273
7274 if let Some(started) = started {
7275 body["started"] = serde_json::Value::String(started.to_string());
7276 }
7277
7278 if let Some(comment_text) = comment {
7279 body["comment"] = serde_json::json!({
7280 "type": "doc",
7281 "version": 1,
7282 "content": [{
7283 "type": "paragraph",
7284 "content": [{
7285 "type": "text",
7286 "text": comment_text
7287 }]
7288 }]
7289 });
7290 }
7291
7292 let response = self.post_json(&url, &body).await?;
7293
7294 Self::ensure_success(response).await?;
7295
7296 Ok(())
7297 }
7298
7299 pub async fn update_worklog(
7305 &self,
7306 key: &str,
7307 worklog_id: &str,
7308 time_spent: Option<&str>,
7309 started: Option<&str>,
7310 comment: Option<&str>,
7311 ) -> Result<()> {
7312 let url = format!(
7313 "{}/rest/api/3/issue/{}/worklog/{}",
7314 self.instance_url, key, worklog_id
7315 );
7316
7317 let mut body = serde_json::Map::new();
7318 if let Some(time_spent) = time_spent {
7319 body.insert(
7320 "timeSpent".to_string(),
7321 serde_json::Value::String(time_spent.to_string()),
7322 );
7323 }
7324 if let Some(started) = started {
7325 body.insert(
7326 "started".to_string(),
7327 serde_json::Value::String(started.to_string()),
7328 );
7329 }
7330 if let Some(comment_text) = comment {
7331 body.insert(
7332 "comment".to_string(),
7333 serde_json::json!({
7334 "type": "doc",
7335 "version": 1,
7336 "content": [{
7337 "type": "paragraph",
7338 "content": [{
7339 "type": "text",
7340 "text": comment_text
7341 }]
7342 }]
7343 }),
7344 );
7345 }
7346
7347 let response = self
7348 .put_json(&url, &serde_json::Value::Object(body))
7349 .await?;
7350
7351 Self::ensure_success(response).await?;
7352
7353 Ok(())
7354 }
7355
7356 pub async fn delete_worklog(&self, key: &str, worklog_id: &str) -> Result<()> {
7360 let url = format!(
7361 "{}/rest/api/3/issue/{}/worklog/{}",
7362 self.instance_url, key, worklog_id
7363 );
7364
7365 let response = self.delete(&url).await?;
7366
7367 Self::ensure_success(response).await?;
7368
7369 Ok(())
7370 }
7371
7372 fn extract_worklog_comment(adf_value: Option<&serde_json::Value>) -> Option<String> {
7374 let adf_value = adf_value?;
7375 let adf: AdfDocument = serde_json::from_value(adf_value.clone()).ok()?;
7376 let md = adf_to_markdown(&adf).ok()?;
7377 let trimmed = md.trim();
7378 if trimmed.is_empty() {
7379 None
7380 } else {
7381 Some(trimmed.to_string())
7382 }
7383 }
7384
7385 pub async fn get_transitions(&self, key: &str) -> Result<Vec<JiraTransition>> {
7387 let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
7388
7389 let response = self.get_json(&url).await?;
7390
7391 let resp: JiraTransitionsResponse = Self::parse_json(
7392 Self::ensure_success(response).await?,
7393 "Failed to parse transitions response",
7394 )
7395 .await?;
7396
7397 Ok(resp
7398 .transitions
7399 .into_iter()
7400 .map(transition_from_entry)
7401 .collect())
7402 }
7403
7404 pub async fn get_transitions_with_fields(
7413 &self,
7414 key: &str,
7415 ) -> Result<(
7416 Vec<JiraTransition>,
7417 std::collections::BTreeMap<String, EditMeta>,
7418 )> {
7419 let url = format!(
7420 "{}/rest/api/3/issue/{}/transitions?expand=transitions.fields",
7421 self.instance_url, key
7422 );
7423
7424 let response = self.get_json(&url).await?;
7425
7426 let resp: JiraTransitionsResponse = Self::parse_json(
7427 Self::ensure_success(response).await?,
7428 "Failed to parse transitions response",
7429 )
7430 .await?;
7431
7432 let mut metas: std::collections::BTreeMap<String, EditMeta> =
7433 std::collections::BTreeMap::new();
7434 let mut transitions = Vec::with_capacity(resp.transitions.len());
7435 for mut t in resp.transitions {
7436 if !t.fields.is_empty() {
7437 let fields = std::mem::take(&mut t.fields);
7438 metas.insert(t.id.clone(), edit_meta_from_raw_fields(fields));
7439 }
7440 transitions.push(transition_from_entry(t));
7441 }
7442
7443 Ok((transitions, metas))
7444 }
7445
7446 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<()> {
7451 self.do_transition_with_fields(key, transition_id, &std::collections::BTreeMap::new(), None)
7452 .await
7453 }
7454
7455 pub async fn do_transition_with_fields(
7464 &self,
7465 key: &str,
7466 transition_id: &str,
7467 fields: &std::collections::BTreeMap<String, serde_json::Value>,
7468 comment: Option<&ValidatedAdfDocument>,
7469 ) -> Result<()> {
7470 let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
7471
7472 let mut body = serde_json::json!({
7473 "transition": { "id": transition_id }
7474 });
7475 if !fields.is_empty() {
7476 body["fields"] = serde_json::to_value(fields)
7477 .context("Failed to serialize transition fields payload")?;
7478 }
7479 if let Some(adf) = comment {
7480 body["update"] = serde_json::json!({
7481 "comment": [ { "add": { "body": adf } } ]
7482 });
7483 }
7484
7485 let response = self.post_json(&url, &body).await?;
7486
7487 Self::ensure_success(response).await?;
7488
7489 Ok(())
7490 }
7491
7492 pub async fn search_issues(&self, jql: &str, limit: u32) -> Result<JiraSearchResult> {
7496 let url = format!("{}/rest/api/3/search/jql", self.instance_url);
7497 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7498 let mut all_issues = Vec::new();
7499 let mut next_token: Option<String> = None;
7500
7501 loop {
7502 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
7503 if remaining == 0 {
7504 break;
7505 }
7506 let page_size = remaining.min(PAGE_SIZE);
7507
7508 let mut body = serde_json::json!({
7509 "jql": jql,
7510 "maxResults": page_size,
7511 "fields": ["summary", "status", "issuetype", "assignee", "priority"]
7512 });
7513 if let Some(ref token) = next_token {
7514 body["nextPageToken"] = serde_json::Value::String(token.clone());
7515 }
7516
7517 let response = self
7518 .post_json(&url, &body)
7519 .await
7520 .context("Failed to send search request to JIRA API")?;
7521
7522 let page: JiraSearchResponse = Self::parse_json(
7523 Self::ensure_success(response).await?,
7524 "Failed to parse JIRA search response",
7525 )
7526 .await?;
7527
7528 let page_count = page.issues.len();
7529 for r in page.issues {
7530 all_issues.push(JiraIssue {
7531 key: r.key,
7532 summary: r.fields.summary.unwrap_or_default(),
7533 description_adf: r.fields.description,
7534 status: r.fields.status.and_then(|s| s.name),
7535 issue_type: r.fields.issuetype.and_then(|t| t.name),
7536 assignee: r.fields.assignee.and_then(|a| a.display_name),
7537 priority: r.fields.priority.and_then(|p| p.name),
7538 labels: r.fields.labels,
7539 custom_fields: Vec::new(),
7540 });
7541 }
7542
7543 match page.next_page_token {
7544 Some(token) if page_count > 0 => next_token = Some(token),
7545 _ => break,
7546 }
7547 }
7548
7549 let total = all_issues.len() as u32;
7550 Ok(JiraSearchResult {
7551 issues: all_issues,
7552 total,
7553 })
7554 }
7555
7556 pub async fn search_confluence(
7558 &self,
7559 cql: &str,
7560 limit: u32,
7561 ) -> Result<ConfluenceSearchResults> {
7562 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7563 let mut all_results = Vec::new();
7564 let mut start: u32 = 0;
7565
7566 loop {
7567 let remaining = effective_limit.saturating_sub(all_results.len() as u32);
7568 if remaining == 0 {
7569 break;
7570 }
7571 let page_size = remaining.min(PAGE_SIZE);
7572
7573 let base = format!("{}/wiki/rest/api/content/search", self.instance_url);
7574 let url = reqwest::Url::parse_with_params(
7575 &base,
7576 &[
7577 ("cql", cql),
7578 ("limit", &page_size.to_string()),
7579 ("start", &start.to_string()),
7580 ("expand", "space"),
7581 ],
7582 )
7583 .context("Failed to build Confluence search URL")?;
7584
7585 let response = self.get_json(url.as_str()).await?;
7586
7587 let resp: ConfluenceContentSearchResponse = Self::parse_json(
7588 Self::ensure_success(response).await?,
7589 "Failed to parse Confluence search response",
7590 )
7591 .await?;
7592
7593 let page_count = resp.results.len() as u32;
7594 for r in resp.results {
7595 let space_key = r
7596 .expandable
7597 .and_then(|e| e.space)
7598 .and_then(|s| s.rsplit('/').next().map(String::from))
7599 .unwrap_or_default();
7600 all_results.push(ConfluenceSearchResult {
7601 id: r.id,
7602 title: r.title,
7603 space_key,
7604 });
7605 }
7606
7607 let has_next = resp.links.and_then(|l| l.next).is_some();
7608 if !has_next || page_count == 0 {
7609 break;
7610 }
7611 start += page_count;
7612 }
7613
7614 let total = all_results.len() as u32;
7615 Ok(ConfluenceSearchResults {
7616 results: all_results,
7617 total,
7618 })
7619 }
7620
7621 pub async fn search_jira_users(
7633 &self,
7634 query: &str,
7635 limit: u32,
7636 ) -> Result<JiraUserSearchResults> {
7637 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7638 let mut all_results: Vec<JiraUserSearchResult> = Vec::new();
7639 let mut start_at: u32 = 0;
7640
7641 loop {
7642 let remaining = effective_limit.saturating_sub(all_results.len() as u32);
7643 if remaining == 0 {
7644 break;
7645 }
7646 let page_size = remaining.min(PAGE_SIZE);
7647
7648 let base = format!("{}/rest/api/3/user/search", self.instance_url);
7649 let url = reqwest::Url::parse_with_params(
7650 &base,
7651 &[
7652 ("query", query),
7653 ("maxResults", &page_size.to_string()),
7654 ("startAt", &start_at.to_string()),
7655 ],
7656 )
7657 .context("Failed to build JIRA user search URL")?;
7658
7659 let response = self.get_json(url.as_str()).await?;
7660
7661 let page: Vec<JiraUserSearchEntry> = Self::parse_json(
7662 Self::ensure_success(response).await?,
7663 "Failed to parse JIRA user search response",
7664 )
7665 .await?;
7666
7667 let page_count = page.len() as u32;
7668 for entry in page {
7669 all_results.push(JiraUserSearchResult {
7670 account_id: entry.account_id,
7671 display_name: entry.display_name,
7672 email_address: entry.email_address,
7673 active: entry.active,
7674 account_type: entry.account_type,
7675 });
7676 }
7677
7678 if page_count < page_size {
7681 break;
7682 }
7683 start_at += page_count;
7684 }
7685
7686 let count = all_results.len() as u32;
7687 Ok(JiraUserSearchResults {
7688 users: all_results,
7689 count,
7690 })
7691 }
7692
7693 pub async fn search_confluence_users(
7695 &self,
7696 query: &str,
7697 limit: u32,
7698 ) -> Result<ConfluenceUserSearchResults> {
7699 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7700 let mut all_results = Vec::new();
7701 let mut start: u32 = 0;
7702
7703 let cql = format!("user.fullname~\"{query}\"");
7704
7705 loop {
7706 let remaining = effective_limit.saturating_sub(all_results.len() as u32);
7707 if remaining == 0 {
7708 break;
7709 }
7710 let page_size = remaining.min(PAGE_SIZE);
7711
7712 let base = format!("{}/wiki/rest/api/search/user", self.instance_url);
7713 let url = reqwest::Url::parse_with_params(
7714 &base,
7715 &[
7716 ("cql", cql.as_str()),
7717 ("limit", &page_size.to_string()),
7718 ("start", &start.to_string()),
7719 ],
7720 )
7721 .context("Failed to build Confluence user search URL")?;
7722
7723 let response = self.get_json(url.as_str()).await?;
7724
7725 let resp: ConfluenceUserSearchResponse = Self::parse_json(
7726 Self::ensure_success(response).await?,
7727 "Failed to parse Confluence user search response",
7728 )
7729 .await?;
7730
7731 let page_count = resp.results.len() as u32;
7732 for r in resp.results {
7733 let Some(user) = r.user else {
7734 continue;
7735 };
7736 let display_name = user.display_name.or(user.public_name).unwrap_or_default();
7737 all_results.push(ConfluenceUserSearchResult {
7738 account_id: user.account_id,
7739 display_name,
7740 email: user.email,
7741 });
7742 }
7743
7744 let has_next = resp.links.and_then(|l| l.next).is_some();
7745 if !has_next || page_count == 0 {
7746 break;
7747 }
7748 start += page_count;
7749 }
7750
7751 let total = all_results.len() as u32;
7752 Ok(ConfluenceUserSearchResults {
7753 users: all_results,
7754 total,
7755 })
7756 }
7757
7758 pub async fn get_jira_user(&self, account_id: &str) -> Result<JiraUserRecord> {
7767 let base = format!("{}/rest/api/3/user", self.instance_url);
7768 let url = reqwest::Url::parse_with_params(&base, &[("accountId", account_id)])
7769 .context("Failed to build JIRA user get URL")?;
7770
7771 let response = self.get_json(url.as_str()).await?;
7772 let status = response.status();
7773
7774 if status.is_success() {
7775 let entry: JiraUserSearchEntry = response
7776 .json()
7777 .await
7778 .context("Failed to parse JIRA user get response")?;
7779 return Ok(JiraUserRecord {
7780 account_id: entry.account_id,
7781 display_name: entry.display_name,
7782 email_address: entry.email_address,
7783 active: Some(entry.active),
7784 account_type: entry.account_type,
7785 error: None,
7786 });
7787 }
7788
7789 if status.as_u16() == 401 {
7790 let body = response.text().await.unwrap_or_default();
7791 return Err(AtlassianError::ApiRequestFailed { status: 401, body }.into());
7792 }
7793
7794 let code = status.as_u16();
7795 let body = response.text().await.unwrap_or_default();
7796 Ok(JiraUserRecord {
7797 account_id: account_id.to_string(),
7798 display_name: None,
7799 email_address: None,
7800 active: None,
7801 account_type: None,
7802 error: Some(user_lookup_error(code, &body)),
7803 })
7804 }
7805
7806 pub async fn get_jira_users(&self, account_ids: &[String]) -> Result<JiraUserGetResults> {
7812 let lookups = account_ids.iter().map(|id| self.get_jira_user(id));
7813 let users = futures::future::join_all(lookups)
7814 .await
7815 .into_iter()
7816 .collect::<Result<Vec<_>>>()?;
7817 Ok(JiraUserGetResults { users })
7818 }
7819
7820 pub async fn get_confluence_user(&self, account_id: &str) -> Result<ConfluenceUserRecord> {
7827 let base = format!("{}/wiki/rest/api/user", self.instance_url);
7828 let url = reqwest::Url::parse_with_params(&base, &[("accountId", account_id)])
7829 .context("Failed to build Confluence user get URL")?;
7830
7831 let response = self.get_json(url.as_str()).await?;
7832 let status = response.status();
7833
7834 if status.is_success() {
7835 let entry: ConfluenceUserGetEntry = response
7836 .json()
7837 .await
7838 .context("Failed to parse Confluence user get response")?;
7839 return Ok(ConfluenceUserRecord {
7840 account_id: entry.account_id.unwrap_or_else(|| account_id.to_string()),
7841 display_name: entry.display_name.or(entry.public_name),
7842 email: entry.email,
7843 account_type: entry.account_type,
7844 active: None,
7845 error: None,
7846 });
7847 }
7848
7849 if status.as_u16() == 401 {
7850 let body = response.text().await.unwrap_or_default();
7851 return Err(AtlassianError::ApiRequestFailed { status: 401, body }.into());
7852 }
7853
7854 let code = status.as_u16();
7855 let body = response.text().await.unwrap_or_default();
7856 Ok(ConfluenceUserRecord {
7857 account_id: account_id.to_string(),
7858 display_name: None,
7859 email: None,
7860 account_type: None,
7861 active: None,
7862 error: Some(user_lookup_error(code, &body)),
7863 })
7864 }
7865
7866 pub async fn get_confluence_users(
7871 &self,
7872 account_ids: &[String],
7873 ) -> Result<ConfluenceUserGetResults> {
7874 let lookups = account_ids.iter().map(|id| self.get_confluence_user(id));
7875 let users = futures::future::join_all(lookups)
7876 .await
7877 .into_iter()
7878 .collect::<Result<Vec<_>>>()?;
7879 Ok(ConfluenceUserGetResults { users })
7880 }
7881
7882 pub async fn get_boards(
7884 &self,
7885 project: Option<&str>,
7886 board_type: Option<&str>,
7887 limit: u32,
7888 ) -> Result<AgileBoardList> {
7889 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7890 let mut all_boards = Vec::new();
7891 let mut start_at: u32 = 0;
7892
7893 loop {
7894 let remaining = effective_limit.saturating_sub(all_boards.len() as u32);
7895 if remaining == 0 {
7896 break;
7897 }
7898 let page_size = remaining.min(PAGE_SIZE);
7899
7900 let mut url = format!(
7901 "{}/rest/agile/1.0/board?maxResults={}&startAt={}",
7902 self.instance_url, page_size, start_at
7903 );
7904 if let Some(proj) = project {
7905 url.push_str(&format!("&projectKeyOrId={proj}"));
7906 }
7907 if let Some(bt) = board_type {
7908 url.push_str(&format!("&type={bt}"));
7909 }
7910
7911 let response = self.get_json(&url).await?;
7912
7913 let resp: AgileBoardListResponse = Self::parse_json(
7914 Self::ensure_success(response).await?,
7915 "Failed to parse board list response",
7916 )
7917 .await?;
7918
7919 let page_count = resp.values.len() as u32;
7920 for b in resp.values {
7921 all_boards.push(AgileBoard {
7922 id: b.id,
7923 name: b.name,
7924 board_type: b.board_type,
7925 project_key: b.location.and_then(|l| l.project_key),
7926 });
7927 }
7928
7929 if resp.is_last || page_count == 0 {
7930 break;
7931 }
7932 start_at += page_count;
7933 }
7934
7935 let total = all_boards.len() as u32;
7936 Ok(AgileBoardList {
7937 boards: all_boards,
7938 total,
7939 })
7940 }
7941
7942 pub async fn get_board_issues(
7944 &self,
7945 board_id: u64,
7946 jql: Option<&str>,
7947 limit: u32,
7948 ) -> Result<JiraSearchResult> {
7949 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7950 let mut all_issues = Vec::new();
7951 let mut start_at: u32 = 0;
7952
7953 loop {
7954 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
7955 if remaining == 0 {
7956 break;
7957 }
7958 let page_size = remaining.min(PAGE_SIZE);
7959
7960 let base = format!(
7961 "{}/rest/agile/1.0/board/{}/issue",
7962 self.instance_url, board_id
7963 );
7964 let mut params: Vec<(&str, String)> = vec![
7965 ("maxResults", page_size.to_string()),
7966 ("startAt", start_at.to_string()),
7967 ];
7968 if let Some(jql_str) = jql {
7969 params.push(("jql", jql_str.to_string()));
7970 }
7971 let url = reqwest::Url::parse_with_params(
7972 &base,
7973 params.iter().map(|(k, v)| (*k, v.as_str())),
7974 )
7975 .context("Failed to build board issues URL")?;
7976
7977 let response = self.get_json(url.as_str()).await?;
7978
7979 let resp: AgileIssueListResponse = Self::parse_json(
7980 Self::ensure_success(response).await?,
7981 "Failed to parse board issues response",
7982 )
7983 .await?;
7984
7985 let page_count = resp.issues.len() as u32;
7986 for r in resp.issues {
7987 all_issues.push(JiraIssue {
7988 key: r.key,
7989 summary: r.fields.summary.unwrap_or_default(),
7990 description_adf: r.fields.description,
7991 status: r.fields.status.and_then(|s| s.name),
7992 issue_type: r.fields.issuetype.and_then(|t| t.name),
7993 assignee: r.fields.assignee.and_then(|a| a.display_name),
7994 priority: r.fields.priority.and_then(|p| p.name),
7995 labels: r.fields.labels,
7996 custom_fields: Vec::new(),
7997 });
7998 }
7999
8000 if resp.is_last || page_count == 0 {
8001 break;
8002 }
8003 start_at += page_count;
8004 }
8005
8006 let total = all_issues.len() as u32;
8007 Ok(JiraSearchResult {
8008 issues: all_issues,
8009 total,
8010 })
8011 }
8012
8013 pub async fn get_sprints(
8015 &self,
8016 board_id: u64,
8017 state: Option<&str>,
8018 limit: u32,
8019 ) -> Result<AgileSprintList> {
8020 let effective_limit = if limit == 0 { u32::MAX } else { limit };
8021 let mut all_sprints = Vec::new();
8022 let mut start_at: u32 = 0;
8023
8024 loop {
8025 let remaining = effective_limit.saturating_sub(all_sprints.len() as u32);
8026 if remaining == 0 {
8027 break;
8028 }
8029 let page_size = remaining.min(PAGE_SIZE);
8030
8031 let mut url = format!(
8032 "{}/rest/agile/1.0/board/{}/sprint?maxResults={}&startAt={}",
8033 self.instance_url, board_id, page_size, start_at
8034 );
8035 if let Some(s) = state {
8036 url.push_str(&format!("&state={s}"));
8037 }
8038
8039 let response = self.get_json(&url).await?;
8040
8041 let resp: AgileSprintListResponse = Self::parse_json(
8042 Self::ensure_success(response).await?,
8043 "Failed to parse sprint list response",
8044 )
8045 .await?;
8046
8047 let page_count = resp.values.len() as u32;
8048 for s in resp.values {
8049 all_sprints.push(AgileSprint {
8050 id: s.id,
8051 name: s.name,
8052 state: s.state,
8053 start_date: s.start_date,
8054 end_date: s.end_date,
8055 goal: s.goal,
8056 });
8057 }
8058
8059 if resp.is_last || page_count == 0 {
8060 break;
8061 }
8062 start_at += page_count;
8063 }
8064
8065 let total = all_sprints.len() as u32;
8066 Ok(AgileSprintList {
8067 sprints: all_sprints,
8068 total,
8069 })
8070 }
8071
8072 pub async fn get_sprint_issues(
8074 &self,
8075 sprint_id: u64,
8076 jql: Option<&str>,
8077 limit: u32,
8078 ) -> Result<JiraSearchResult> {
8079 let effective_limit = if limit == 0 { u32::MAX } else { limit };
8080 let mut all_issues = Vec::new();
8081 let mut start_at: u32 = 0;
8082
8083 loop {
8084 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
8085 if remaining == 0 {
8086 break;
8087 }
8088 let page_size = remaining.min(PAGE_SIZE);
8089
8090 let base = format!(
8091 "{}/rest/agile/1.0/sprint/{}/issue",
8092 self.instance_url, sprint_id
8093 );
8094 let mut params: Vec<(&str, String)> = vec![
8095 ("maxResults", page_size.to_string()),
8096 ("startAt", start_at.to_string()),
8097 ];
8098 if let Some(jql_str) = jql {
8099 params.push(("jql", jql_str.to_string()));
8100 }
8101 let url = reqwest::Url::parse_with_params(
8102 &base,
8103 params.iter().map(|(k, v)| (*k, v.as_str())),
8104 )
8105 .context("Failed to build sprint issues URL")?;
8106
8107 let response = self.get_json(url.as_str()).await?;
8108
8109 let resp: AgileIssueListResponse = Self::parse_json(
8110 Self::ensure_success(response).await?,
8111 "Failed to parse sprint issues response",
8112 )
8113 .await?;
8114
8115 let page_count = resp.issues.len() as u32;
8116 for r in resp.issues {
8117 all_issues.push(JiraIssue {
8118 key: r.key,
8119 summary: r.fields.summary.unwrap_or_default(),
8120 description_adf: r.fields.description,
8121 status: r.fields.status.and_then(|s| s.name),
8122 issue_type: r.fields.issuetype.and_then(|t| t.name),
8123 assignee: r.fields.assignee.and_then(|a| a.display_name),
8124 priority: r.fields.priority.and_then(|p| p.name),
8125 labels: r.fields.labels,
8126 custom_fields: Vec::new(),
8127 });
8128 }
8129
8130 if resp.is_last || page_count == 0 {
8131 break;
8132 }
8133 start_at += page_count;
8134 }
8135
8136 let total = all_issues.len() as u32;
8137 Ok(JiraSearchResult {
8138 issues: all_issues,
8139 total,
8140 })
8141 }
8142
8143 pub async fn add_issues_to_sprint(&self, sprint_id: u64, issue_keys: &[&str]) -> Result<()> {
8145 let url = format!(
8146 "{}/rest/agile/1.0/sprint/{}/issue",
8147 self.instance_url, sprint_id
8148 );
8149
8150 let body = serde_json::json!({ "issues": issue_keys });
8151
8152 let response = self.post_json(&url, &body).await?;
8153
8154 Self::ensure_success(response).await?;
8155
8156 Ok(())
8157 }
8158
8159 pub async fn create_sprint(
8161 &self,
8162 board_id: u64,
8163 name: &str,
8164 start_date: Option<&str>,
8165 end_date: Option<&str>,
8166 goal: Option<&str>,
8167 ) -> Result<AgileSprint> {
8168 let url = format!("{}/rest/agile/1.0/sprint", self.instance_url);
8169
8170 let mut body = serde_json::json!({
8171 "originBoardId": board_id,
8172 "name": name
8173 });
8174 if let Some(sd) = start_date {
8175 body["startDate"] = serde_json::Value::String(sd.to_string());
8176 }
8177 if let Some(ed) = end_date {
8178 body["endDate"] = serde_json::Value::String(ed.to_string());
8179 }
8180 if let Some(g) = goal {
8181 body["goal"] = serde_json::Value::String(g.to_string());
8182 }
8183
8184 let response = self.post_json(&url, &body).await?;
8185
8186 let entry: AgileSprintEntry = Self::parse_json(
8187 Self::ensure_success(response).await?,
8188 "Failed to parse sprint create response",
8189 )
8190 .await?;
8191
8192 Ok(AgileSprint {
8193 id: entry.id,
8194 name: entry.name,
8195 state: entry.state,
8196 start_date: entry.start_date,
8197 end_date: entry.end_date,
8198 goal: entry.goal,
8199 })
8200 }
8201
8202 pub async fn update_sprint(
8204 &self,
8205 sprint_id: u64,
8206 name: Option<&str>,
8207 state: Option<&str>,
8208 start_date: Option<&str>,
8209 end_date: Option<&str>,
8210 goal: Option<&str>,
8211 ) -> Result<()> {
8212 let url = format!("{}/rest/agile/1.0/sprint/{}", self.instance_url, sprint_id);
8213
8214 let mut body = serde_json::Map::new();
8215 if let Some(n) = name {
8216 body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
8217 }
8218 if let Some(s) = state {
8219 body.insert(
8220 "state".to_string(),
8221 serde_json::Value::String(s.to_string()),
8222 );
8223 }
8224 if let Some(sd) = start_date {
8225 body.insert(
8226 "startDate".to_string(),
8227 serde_json::Value::String(sd.to_string()),
8228 );
8229 }
8230 if let Some(ed) = end_date {
8231 body.insert(
8232 "endDate".to_string(),
8233 serde_json::Value::String(ed.to_string()),
8234 );
8235 }
8236 if let Some(g) = goal {
8237 body.insert("goal".to_string(), serde_json::Value::String(g.to_string()));
8238 }
8239
8240 let response = self
8241 .put_json(&url, &serde_json::Value::Object(body))
8242 .await?;
8243
8244 Self::ensure_success(response).await?;
8245
8246 Ok(())
8247 }
8248
8249 pub async fn delete_sprint(&self, sprint_id: u64) -> Result<()> {
8253 let url = format!("{}/rest/agile/1.0/sprint/{}", self.instance_url, sprint_id);
8254 let response = self.delete(&url).await?;
8255 Self::ensure_success(response).await?;
8256 Ok(())
8257 }
8258
8259 pub async fn get_project_versions(
8265 &self,
8266 project_key: &str,
8267 released: Option<bool>,
8268 archived: Option<bool>,
8269 ) -> Result<JiraProjectVersionList> {
8270 let url = format!(
8271 "{}/rest/api/3/project/{}/versions",
8272 self.instance_url, project_key
8273 );
8274
8275 let response = self.get_json(&url).await?;
8276
8277 let entries: Vec<JiraProjectVersionEntry> = Self::parse_json(
8278 Self::ensure_success(response).await?,
8279 "Failed to parse project versions response",
8280 )
8281 .await?;
8282
8283 let versions: Vec<JiraProjectVersion> = entries
8284 .into_iter()
8285 .filter(|e| released.map_or(true, |r| e.released == r))
8286 .filter(|e| archived.map_or(true, |a| e.archived == a))
8287 .map(|e| JiraProjectVersion {
8288 id: e.id,
8289 name: e.name,
8290 description: e.description,
8291 project_key: project_key.to_string(),
8292 released: e.released,
8293 archived: e.archived,
8294 release_date: e.release_date,
8295 start_date: e.start_date,
8296 })
8297 .collect();
8298
8299 let total = versions.len() as u32;
8300 Ok(JiraProjectVersionList { versions, total })
8301 }
8302
8303 #[allow(clippy::too_many_arguments)]
8309 pub async fn create_project_version(
8310 &self,
8311 project_key: &str,
8312 name: &str,
8313 description: Option<&str>,
8314 release_date: Option<&str>,
8315 start_date: Option<&str>,
8316 released: bool,
8317 archived: bool,
8318 ) -> Result<JiraProjectVersion> {
8319 validate_iso_date(release_date, "release_date")?;
8320 validate_iso_date(start_date, "start_date")?;
8321
8322 let url = format!("{}/rest/api/3/version", self.instance_url);
8323
8324 let mut body = serde_json::json!({
8325 "project": project_key,
8326 "name": name,
8327 "released": released,
8328 "archived": archived,
8329 });
8330 if let Some(d) = description {
8331 body["description"] = serde_json::Value::String(d.to_string());
8332 }
8333 if let Some(rd) = release_date {
8334 body["releaseDate"] = serde_json::Value::String(rd.to_string());
8335 }
8336 if let Some(sd) = start_date {
8337 body["startDate"] = serde_json::Value::String(sd.to_string());
8338 }
8339
8340 let response = self.post_json(&url, &body).await?;
8341
8342 let entry: JiraProjectVersionEntry = Self::parse_json(
8343 Self::ensure_success(response).await?,
8344 "Failed to parse version create response",
8345 )
8346 .await?;
8347
8348 Ok(JiraProjectVersion {
8349 id: entry.id,
8350 name: entry.name,
8351 description: entry.description,
8352 project_key: project_key.to_string(),
8353 released: entry.released,
8354 archived: entry.archived,
8355 release_date: entry.release_date,
8356 start_date: entry.start_date,
8357 })
8358 }
8359
8360 #[allow(clippy::too_many_arguments)]
8368 pub async fn update_project_version(
8369 &self,
8370 version_id: &str,
8371 name: Option<&str>,
8372 description: Option<&str>,
8373 released: Option<bool>,
8374 release_date: Option<&str>,
8375 archived: Option<bool>,
8376 start_date: Option<&str>,
8377 ) -> Result<()> {
8378 validate_iso_date(release_date, "release_date")?;
8379 validate_iso_date(start_date, "start_date")?;
8380
8381 let url = format!("{}/rest/api/3/version/{}", self.instance_url, version_id);
8382
8383 let mut body = serde_json::Map::new();
8384 if let Some(n) = name {
8385 body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
8386 }
8387 if let Some(d) = description {
8388 body.insert(
8389 "description".to_string(),
8390 serde_json::Value::String(d.to_string()),
8391 );
8392 }
8393 if let Some(r) = released {
8394 body.insert("released".to_string(), serde_json::Value::Bool(r));
8395 }
8396 if let Some(rd) = release_date {
8397 body.insert(
8398 "releaseDate".to_string(),
8399 serde_json::Value::String(rd.to_string()),
8400 );
8401 }
8402 if let Some(a) = archived {
8403 body.insert("archived".to_string(), serde_json::Value::Bool(a));
8404 }
8405 if let Some(sd) = start_date {
8406 body.insert(
8407 "startDate".to_string(),
8408 serde_json::Value::String(sd.to_string()),
8409 );
8410 }
8411
8412 let response = self
8413 .put_json(&url, &serde_json::Value::Object(body))
8414 .await?;
8415
8416 Self::ensure_success(response).await?;
8417
8418 Ok(())
8419 }
8420
8421 pub async fn delete_project_version(
8428 &self,
8429 version_id: &str,
8430 move_fix_issues_to: Option<&str>,
8431 move_affected_issues_to: Option<&str>,
8432 ) -> Result<()> {
8433 let mut url = format!("{}/rest/api/3/version/{}", self.instance_url, version_id);
8434 let mut params = Vec::new();
8435 if let Some(v) = move_fix_issues_to {
8436 params.push(format!("moveFixIssuesTo={v}"));
8437 }
8438 if let Some(v) = move_affected_issues_to {
8439 params.push(format!("moveAffectedIssuesTo={v}"));
8440 }
8441 if !params.is_empty() {
8442 url.push('?');
8443 url.push_str(¶ms.join("&"));
8444 }
8445
8446 let response = self.delete(&url).await?;
8447
8448 Self::ensure_success(response).await?;
8449
8450 Ok(())
8451 }
8452
8453 pub async fn get_project_components(&self, project_key: &str) -> Result<Vec<JiraComponent>> {
8457 let url = format!(
8458 "{}/rest/api/3/project/{}/components",
8459 self.instance_url, project_key
8460 );
8461 let response = self.get_json(&url).await?;
8462 let entries: Vec<JiraComponentEntry> = Self::parse_json(
8463 Self::ensure_success(response).await?,
8464 "Failed to parse project components response",
8465 )
8466 .await?;
8467 Ok(entries
8468 .into_iter()
8469 .map(|e| JiraComponent {
8470 id: e.id,
8471 name: e.name,
8472 description: e.description,
8473 })
8474 .collect())
8475 }
8476
8477 pub async fn create_component(
8481 &self,
8482 project_key: &str,
8483 name: &str,
8484 description: Option<&str>,
8485 ) -> Result<JiraComponent> {
8486 let url = format!("{}/rest/api/3/component", self.instance_url);
8487 let mut body = serde_json::json!({ "project": project_key, "name": name });
8488 if let Some(d) = description {
8489 body["description"] = serde_json::Value::String(d.to_string());
8490 }
8491 let response = self.post_json(&url, &body).await?;
8492 let entry: JiraComponentEntry = Self::parse_json(
8493 Self::ensure_success(response).await?,
8494 "Failed to parse component create response",
8495 )
8496 .await?;
8497 Ok(JiraComponent {
8498 id: entry.id,
8499 name: entry.name,
8500 description: entry.description,
8501 })
8502 }
8503
8504 pub async fn update_component(
8508 &self,
8509 component_id: &str,
8510 name: Option<&str>,
8511 description: Option<&str>,
8512 ) -> Result<()> {
8513 let url = format!(
8514 "{}/rest/api/3/component/{}",
8515 self.instance_url, component_id
8516 );
8517 let mut body = serde_json::Map::new();
8518 if let Some(n) = name {
8519 body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
8520 }
8521 if let Some(d) = description {
8522 body.insert(
8523 "description".to_string(),
8524 serde_json::Value::String(d.to_string()),
8525 );
8526 }
8527 let response = self
8528 .put_json(&url, &serde_json::Value::Object(body))
8529 .await?;
8530 Self::ensure_success(response).await?;
8531 Ok(())
8532 }
8533
8534 pub async fn delete_component(
8539 &self,
8540 component_id: &str,
8541 move_issues_to: Option<&str>,
8542 ) -> Result<()> {
8543 let mut url = format!(
8544 "{}/rest/api/3/component/{}",
8545 self.instance_url, component_id
8546 );
8547 if let Some(target) = move_issues_to {
8548 url.push_str("?moveIssuesTo=");
8549 url.push_str(target);
8550 }
8551 let response = self.delete(&url).await?;
8552 Self::ensure_success(response).await?;
8553 Ok(())
8554 }
8555
8556 pub async fn get_issue_links(&self, key: &str) -> Result<Vec<JiraIssueLink>> {
8558 let url = format!(
8559 "{}/rest/api/3/issue/{}?fields=issuelinks",
8560 self.instance_url, key
8561 );
8562
8563 let response = self.get_json(&url).await?;
8564
8565 let resp: JiraIssueLinksResponse = Self::parse_json(
8566 Self::ensure_success(response).await?,
8567 "Failed to parse issue links response",
8568 )
8569 .await?;
8570
8571 let mut links = Vec::new();
8572 for entry in resp.fields.issuelinks {
8573 if let Some(inward) = entry.inward_issue {
8574 links.push(JiraIssueLink {
8575 id: entry.id.clone(),
8576 link_type: entry.link_type.name.clone(),
8577 direction: "inward".to_string(),
8578 linked_issue_key: inward.key,
8579 linked_issue_summary: inward.fields.and_then(|f| f.summary).unwrap_or_default(),
8580 });
8581 }
8582 if let Some(outward) = entry.outward_issue {
8583 links.push(JiraIssueLink {
8584 id: entry.id,
8585 link_type: entry.link_type.name,
8586 direction: "outward".to_string(),
8587 linked_issue_key: outward.key,
8588 linked_issue_summary: outward
8589 .fields
8590 .and_then(|f| f.summary)
8591 .unwrap_or_default(),
8592 });
8593 }
8594 }
8595
8596 Ok(links)
8597 }
8598
8599 pub async fn get_remote_issue_links(&self, key: &str) -> Result<Vec<JiraRemoteIssueLink>> {
8604 let url = format!("{}/rest/api/3/issue/{}/remotelink", self.instance_url, key);
8605
8606 let response = self.get_json(&url).await?;
8607
8608 let entries: Vec<JiraRemoteIssueLinkEntry> = Self::parse_json(
8609 Self::ensure_success(response).await?,
8610 "Failed to parse remote issue links response",
8611 )
8612 .await?;
8613
8614 let mut links = Vec::with_capacity(entries.len());
8615 for entry in entries {
8616 let id = match entry.id {
8619 serde_json::Value::String(s) => s,
8620 serde_json::Value::Number(n) => n.to_string(),
8621 other => {
8622 return Err(anyhow::anyhow!(
8623 "unexpected remote link id type in response: {other:?}"
8624 ));
8625 }
8626 };
8627 links.push(JiraRemoteIssueLink {
8628 id,
8629 global_id: entry.global_id,
8630 relationship: entry.relationship,
8631 object: JiraRemoteIssueLinkObject {
8632 url: entry.object.url,
8633 title: entry.object.title,
8634 summary: entry.object.summary,
8635 icon: entry.object.icon.map(|i| JiraRemoteIssueLinkIcon {
8636 url: i.url,
8637 title: i.title,
8638 }),
8639 },
8640 });
8641 }
8642 Ok(links)
8643 }
8644
8645 pub async fn create_remote_issue_link(
8651 &self,
8652 key: &str,
8653 url: &str,
8654 title: &str,
8655 summary: Option<&str>,
8656 relationship: Option<&str>,
8657 global_id: Option<&str>,
8658 ) -> Result<String> {
8659 let endpoint = format!("{}/rest/api/3/issue/{}/remotelink", self.instance_url, key);
8660
8661 let mut object = serde_json::Map::new();
8662 object.insert(
8663 "url".to_string(),
8664 serde_json::Value::String(url.to_string()),
8665 );
8666 object.insert(
8667 "title".to_string(),
8668 serde_json::Value::String(title.to_string()),
8669 );
8670 if let Some(s) = summary {
8671 object.insert(
8672 "summary".to_string(),
8673 serde_json::Value::String(s.to_string()),
8674 );
8675 }
8676
8677 let mut body = serde_json::Map::new();
8678 body.insert("object".to_string(), serde_json::Value::Object(object));
8679 if let Some(r) = relationship {
8680 body.insert(
8681 "relationship".to_string(),
8682 serde_json::Value::String(r.to_string()),
8683 );
8684 }
8685 if let Some(g) = global_id {
8686 body.insert(
8687 "globalId".to_string(),
8688 serde_json::Value::String(g.to_string()),
8689 );
8690 }
8691
8692 let response = self
8693 .post_json(&endpoint, &serde_json::Value::Object(body))
8694 .await?;
8695
8696 let created: JiraRemoteLinkCreateResponse = Self::parse_json(
8697 Self::ensure_success(response).await?,
8698 "Failed to parse remote link create response",
8699 )
8700 .await?;
8701
8702 Ok(match created.id {
8703 serde_json::Value::String(s) => s,
8704 serde_json::Value::Number(n) => n.to_string(),
8705 other => {
8706 return Err(anyhow::anyhow!(
8707 "unexpected remote link id type in create response: {other:?}"
8708 ));
8709 }
8710 })
8711 }
8712
8713 pub async fn delete_remote_issue_link(&self, key: &str, link_id: &str) -> Result<()> {
8717 let url = format!(
8718 "{}/rest/api/3/issue/{}/remotelink/{}",
8719 self.instance_url, key, link_id
8720 );
8721 let response = self.delete(&url).await?;
8722 Self::ensure_success(response).await?;
8723 Ok(())
8724 }
8725
8726 pub async fn get_link_types(&self) -> Result<Vec<JiraLinkType>> {
8728 let url = format!("{}/rest/api/3/issueLinkType", self.instance_url);
8729 let response = self.get_json(&url).await?;
8730 let resp: JiraLinkTypesResponse = Self::parse_json(
8731 Self::ensure_success(response).await?,
8732 "Failed to parse link types response",
8733 )
8734 .await?;
8735 Ok(resp
8736 .issue_link_types
8737 .into_iter()
8738 .map(|t| JiraLinkType {
8739 id: t.id,
8740 name: t.name,
8741 inward: t.inward,
8742 outward: t.outward,
8743 })
8744 .collect())
8745 }
8746
8747 pub async fn create_issue_link(
8749 &self,
8750 type_name: &str,
8751 inward_key: &str,
8752 outward_key: &str,
8753 ) -> Result<()> {
8754 let url = format!("{}/rest/api/3/issueLink", self.instance_url);
8755 let body = serde_json::json!({"type": {"name": type_name}, "inwardIssue": {"key": inward_key}, "outwardIssue": {"key": outward_key}});
8756 let response = self.post_json(&url, &body).await?;
8757 Self::ensure_success(response).await?;
8758 Ok(())
8759 }
8760
8761 pub async fn remove_issue_link(&self, link_id: &str) -> Result<()> {
8763 let url = format!("{}/rest/api/3/issueLink/{}", self.instance_url, link_id);
8764 let response = self.delete(&url).await?;
8765 Self::ensure_success(response).await?;
8766 Ok(())
8767 }
8768
8769 pub async fn set_issue_parent(&self, issue_key: &str, parent_key: &str) -> Result<()> {
8773 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, issue_key);
8774 let body = serde_json::json!({"fields": {"parent": {"key": parent_key}}});
8775 let response = self.put_json(&url, &body).await?;
8776 Self::ensure_success(response).await?;
8777 Ok(())
8778 }
8779
8780 pub async fn modify_issue_labels(
8787 &self,
8788 key: &str,
8789 add: &[String],
8790 remove: &[String],
8791 ) -> Result<()> {
8792 let mut ops: Vec<serde_json::Value> = Vec::with_capacity(add.len() + remove.len());
8793 for label in add {
8794 ops.push(serde_json::json!({ "add": label }));
8795 }
8796 for label in remove {
8797 ops.push(serde_json::json!({ "remove": label }));
8798 }
8799 let body = serde_json::json!({ "update": { "labels": ops } });
8800
8801 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
8802 let response = self.put_json(&url, &body).await?;
8803 Self::ensure_success(response).await?;
8804 Ok(())
8805 }
8806
8807 pub async fn get_issue_id(&self, key: &str) -> Result<String> {
8809 let url = format!("{}/rest/api/3/issue/{}?fields=", self.instance_url, key);
8810 let response = self.get_json(&url).await?;
8811 let resp: JiraIssueIdResponse = Self::parse_json(
8812 Self::ensure_success(response).await?,
8813 "Failed to parse issue ID response",
8814 )
8815 .await?;
8816 Ok(resp.id)
8817 }
8818
8819 pub async fn get_dev_status_summary(&self, key: &str) -> Result<JiraDevStatusSummary> {
8825 let issue_id = self.get_issue_id(key).await?;
8826 let url = format!(
8827 "{}/rest/dev-status/1.0/issue/summary?issueId={}",
8828 self.instance_url, issue_id
8829 );
8830 let response = self.get_json(&url).await?;
8831 let resp: DevStatusSummaryResponse = Self::parse_json(
8832 Self::ensure_success(response).await?,
8833 "Failed to parse DevStatus summary response",
8834 )
8835 .await?;
8836
8837 fn extract_count(cat: Option<DevStatusSummaryCategory>) -> JiraDevStatusCount {
8838 match cat {
8839 Some(c) => JiraDevStatusCount {
8840 count: c.overall.map_or(0, |o| o.count),
8841 providers: c
8849 .by_instance_type
8850 .into_iter()
8851 .filter(|(k, _)| !k.is_empty())
8852 .map(|(k, v)| JiraDevProvider {
8853 name: v
8854 .get("name")
8855 .and_then(|n| n.as_str())
8856 .filter(|s| !s.is_empty())
8857 .unwrap_or(&k)
8858 .to_string(),
8859 instance_type: k,
8860 })
8861 .collect(),
8862 },
8863 None => JiraDevStatusCount {
8864 count: 0,
8865 providers: Vec::new(),
8866 },
8867 }
8868 }
8869
8870 Ok(JiraDevStatusSummary {
8871 pullrequest: extract_count(resp.summary.pullrequest),
8872 branch: extract_count(resp.summary.branch),
8873 repository: extract_count(resp.summary.repository),
8874 })
8875 }
8876
8877 pub async fn get_dev_status(
8886 &self,
8887 key: &str,
8888 data_type: Option<&str>,
8889 application_type: Option<&str>,
8890 ) -> Result<JiraDevStatus> {
8891 let issue_id = self.get_issue_id(key).await?;
8892
8893 let app_types: Vec<String> = if let Some(app) = application_type {
8894 vec![app.to_string()]
8895 } else {
8896 let summary = self.get_dev_status_summary(key).await?;
8900 let mut providers: Vec<String> = Vec::new();
8901 for p in summary
8902 .pullrequest
8903 .providers
8904 .into_iter()
8905 .chain(summary.branch.providers)
8906 .chain(summary.repository.providers)
8907 {
8908 if !providers.contains(&p.instance_type) {
8909 providers.push(p.instance_type);
8910 }
8911 }
8912 if providers.is_empty() {
8913 providers.push("GitHub".to_string());
8914 }
8915 providers
8916 };
8917
8918 let data_types: Vec<&str> = match data_type {
8919 Some(dt) => vec![dt],
8920 None => vec!["pullrequest", "branch", "repository"],
8921 };
8922
8923 let mut status = JiraDevStatus {
8924 pull_requests: Vec::new(),
8925 branches: Vec::new(),
8926 repositories: Vec::new(),
8927 };
8928
8929 for app in &app_types {
8930 for dt in &data_types {
8931 let url = format!(
8932 "{}/rest/dev-status/1.0/issue/detail?issueId={}&applicationType={}&dataType={}",
8933 self.instance_url, issue_id, app, dt
8934 );
8935 let response = self.get_json(&url).await?;
8936 let resp: DevStatusResponse = Self::parse_json(
8937 Self::ensure_success(response).await?,
8938 "Failed to parse DevStatus response",
8939 )
8940 .await?;
8941
8942 for detail in resp.detail {
8943 for pr in detail.pull_requests {
8944 status.pull_requests.push(JiraDevPullRequest {
8945 id: pr.id,
8946 name: pr.name,
8947 status: pr.status,
8948 url: pr.url,
8949 repository_name: pr.repository_name,
8950 source_branch: pr.source.map(|s| s.branch).unwrap_or_default(),
8951 destination_branch: pr
8952 .destination
8953 .map(|d| d.branch)
8954 .unwrap_or_default(),
8955 author: pr.author.map(|a| a.name),
8956 reviewers: pr.reviewers.into_iter().map(|r| r.name).collect(),
8957 comment_count: pr.comment_count,
8958 last_update: pr.last_update,
8959 });
8960 }
8961 for branch in detail.branches {
8962 status.branches.push(JiraDevBranch {
8963 name: branch.name,
8964 url: branch.url,
8965 repository_name: branch.repository_name,
8966 create_pr_url: branch.create_pr_url,
8967 last_commit: branch.last_commit.map(Self::convert_commit),
8968 });
8969 }
8970 for repo in detail.repositories {
8971 status.repositories.push(JiraDevRepository {
8972 name: repo.name,
8973 url: repo.url,
8974 commits: repo.commits.into_iter().map(Self::convert_commit).collect(),
8975 });
8976 }
8977 }
8978 }
8979 }
8980
8981 Ok(status)
8982 }
8983
8984 fn convert_commit(c: DevStatusCommit) -> JiraDevCommit {
8986 JiraDevCommit {
8987 id: c.id,
8988 display_id: c.display_id,
8989 message: c.message,
8990 author: c.author.map(|a| a.name),
8991 timestamp: c.author_timestamp,
8992 url: c.url,
8993 file_count: c.file_count,
8994 merge: c.merge,
8995 }
8996 }
8997
8998 pub async fn get_attachments(&self, key: &str) -> Result<Vec<JiraAttachment>> {
9000 let url = format!(
9001 "{}/rest/api/3/issue/{}?fields=attachment",
9002 self.instance_url, key
9003 );
9004
9005 let response = self.get_json(&url).await?;
9006
9007 let resp: JiraAttachmentIssueResponse = Self::parse_json(
9008 Self::ensure_success(response).await?,
9009 "Failed to parse attachment response",
9010 )
9011 .await?;
9012
9013 Ok(resp
9014 .fields
9015 .attachment
9016 .into_iter()
9017 .map(JiraAttachment::from)
9018 .collect())
9019 }
9020
9021 pub async fn upload_attachments(
9031 &self,
9032 key: &str,
9033 files: &[PathBuf],
9034 ) -> Result<Vec<JiraAttachment>> {
9035 let mut form = reqwest::multipart::Form::new();
9036 for file in files {
9037 let metadata = tokio::fs::metadata(file)
9038 .await
9039 .with_context(|| format!("Failed to read file metadata for {}", file.display()))?;
9040 let size = metadata.len();
9041 let handle = tokio::fs::File::open(file)
9042 .await
9043 .with_context(|| format!("Failed to open {}", file.display()))?;
9044
9045 let filename = file
9046 .file_name()
9047 .map(|s| s.to_string_lossy().into_owned())
9048 .ok_or_else(|| {
9049 anyhow::anyhow!("File path has no filename component: {}", file.display())
9050 })?;
9051
9052 let mime = mime_guess::from_path(file).first_or_octet_stream();
9053 let body = reqwest::Body::wrap_stream(ReaderStream::new(handle));
9054 let part = reqwest::multipart::Part::stream_with_length(body, size)
9055 .file_name(filename)
9056 .mime_str(mime.essence_str())
9057 .with_context(|| format!("Invalid MIME type for {}", file.display()))?;
9058 form = form.part("file", part);
9059 }
9060
9061 let url = format!("{}/rest/api/3/issue/{}/attachments", self.instance_url, key);
9062
9063 let response = self
9064 .post_multipart(&url, form, &[("X-Atlassian-Token", "no-check")])
9065 .await?;
9066
9067 let entries: Vec<JiraAttachmentEntry> = Self::parse_json(
9068 Self::ensure_success(response).await?,
9069 "Failed to parse attachment upload response",
9070 )
9071 .await?;
9072
9073 Ok(entries.into_iter().map(JiraAttachment::from).collect())
9074 }
9075
9076 pub async fn delete_attachment(&self, attachment_id: &str) -> Result<()> {
9080 let url = format!(
9081 "{}/rest/api/3/attachment/{}",
9082 self.instance_url, attachment_id
9083 );
9084 let response = self.delete(&url).await?;
9085 Self::ensure_success(response).await?;
9086 Ok(())
9087 }
9088
9089 pub async fn get_changelog(&self, key: &str, limit: u32) -> Result<Vec<JiraChangelogEntry>> {
9091 let effective_limit = if limit == 0 { u32::MAX } else { limit };
9092 let mut all_entries = Vec::new();
9093 let mut start_at: u32 = 0;
9094
9095 loop {
9096 let remaining = effective_limit.saturating_sub(all_entries.len() as u32);
9097 if remaining == 0 {
9098 break;
9099 }
9100 let page_size = remaining.min(PAGE_SIZE);
9101
9102 let url = format!(
9103 "{}/rest/api/3/issue/{}/changelog?maxResults={}&startAt={}",
9104 self.instance_url, key, page_size, start_at
9105 );
9106
9107 let response = self.get_json(&url).await?;
9108
9109 let resp: JiraChangelogResponse = Self::parse_json(
9110 Self::ensure_success(response).await?,
9111 "Failed to parse changelog response",
9112 )
9113 .await?;
9114
9115 let page_count = resp.values.len() as u32;
9116 for e in resp.values {
9117 all_entries.push(JiraChangelogEntry {
9118 id: e.id,
9119 author: e.author.and_then(|a| a.display_name).unwrap_or_default(),
9120 created: e.created.unwrap_or_default(),
9121 items: e
9122 .items
9123 .into_iter()
9124 .map(|i| JiraChangelogItem {
9125 field: i.field,
9126 from_string: i.from_string,
9127 to_string: i.to_string,
9128 })
9129 .collect(),
9130 });
9131 }
9132
9133 if resp.is_last || page_count == 0 {
9134 break;
9135 }
9136 start_at += page_count;
9137 }
9138
9139 Ok(all_entries)
9140 }
9141
9142 pub async fn get_fields(&self) -> Result<Vec<JiraField>> {
9144 let url = format!("{}/rest/api/3/field", self.instance_url);
9145
9146 let response = self.get_json(&url).await?;
9147
9148 let entries: Vec<JiraFieldEntry> = Self::parse_json(
9149 Self::ensure_success(response).await?,
9150 "Failed to parse field list response",
9151 )
9152 .await?;
9153
9154 Ok(entries
9155 .into_iter()
9156 .map(|f| {
9157 let (raw_type, raw_custom) = match f.schema {
9158 Some(s) => (s.schema_type, s.custom),
9159 None => (None, None),
9160 };
9161 JiraField {
9162 id: f.id,
9163 name: f.name,
9164 custom: f.custom,
9165 schema_type: map_schema_type(raw_type, raw_custom.as_deref()),
9166 schema_custom: raw_custom,
9167 }
9168 })
9169 .collect())
9170 }
9171
9172 pub async fn get_field_contexts(&self, field_id: &str) -> Result<Vec<String>> {
9175 let url = format!(
9176 "{}/rest/api/3/field/{}/context",
9177 self.instance_url, field_id
9178 );
9179
9180 let response = self.get_json(&url).await?;
9181
9182 let resp: JiraFieldContextsResponse = Self::parse_json(
9183 Self::ensure_success(response).await?,
9184 "Failed to parse field contexts response",
9185 )
9186 .await?;
9187
9188 Ok(resp.values.into_iter().map(|c| c.id).collect())
9189 }
9190
9191 pub async fn get_field_options(
9195 &self,
9196 field_id: &str,
9197 context_id: Option<&str>,
9198 ) -> Result<Vec<JiraFieldOption>> {
9199 let ctx = if let Some(id) = context_id {
9200 id.to_string()
9201 } else {
9202 let contexts = self.get_field_contexts(field_id).await?;
9203 contexts.into_iter().next().ok_or_else(|| {
9204 anyhow::anyhow!(
9205 "No contexts found for field \"{field_id}\". \
9206 Use --context-id to specify one explicitly."
9207 )
9208 })?
9209 };
9210
9211 let url = format!(
9212 "{}/rest/api/3/field/{}/context/{}/option",
9213 self.instance_url, field_id, ctx
9214 );
9215
9216 let response = self.get_json(&url).await?;
9217
9218 let resp: JiraFieldOptionsResponse = Self::parse_json(
9219 Self::ensure_success(response).await?,
9220 "Failed to parse field options response",
9221 )
9222 .await?;
9223
9224 Ok(resp
9225 .values
9226 .into_iter()
9227 .map(|o| JiraFieldOption {
9228 id: o.id,
9229 value: o.value,
9230 })
9231 .collect())
9232 }
9233
9234 pub async fn get_projects(&self, limit: u32) -> Result<JiraProjectList> {
9236 let effective_limit = if limit == 0 { u32::MAX } else { limit };
9237 let mut all_projects = Vec::new();
9238 let mut start_at: u32 = 0;
9239
9240 loop {
9241 let remaining = effective_limit.saturating_sub(all_projects.len() as u32);
9242 if remaining == 0 {
9243 break;
9244 }
9245 let page_size = remaining.min(PAGE_SIZE);
9246
9247 let url = format!(
9248 "{}/rest/api/3/project/search?maxResults={}&startAt={}",
9249 self.instance_url, page_size, start_at
9250 );
9251
9252 let response = self.get_json(&url).await?;
9253
9254 let resp: JiraProjectSearchResponse = Self::parse_json(
9255 Self::ensure_success(response).await?,
9256 "Failed to parse project search response",
9257 )
9258 .await?;
9259
9260 let page_count = resp.values.len() as u32;
9261 for p in resp.values {
9262 all_projects.push(JiraProject {
9263 id: p.id,
9264 key: p.key,
9265 name: p.name,
9266 project_type: p.project_type_key,
9267 lead: p.lead.and_then(|l| l.display_name),
9268 });
9269 }
9270
9271 if resp.is_last || page_count == 0 {
9272 break;
9273 }
9274 start_at += page_count;
9275 }
9276
9277 let total = all_projects.len() as u32;
9278 Ok(JiraProjectList {
9279 projects: all_projects,
9280 total,
9281 })
9282 }
9283
9284 pub async fn delete_issue(&self, key: &str) -> Result<()> {
9286 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
9287
9288 let response = self.delete(&url).await?;
9289
9290 Self::ensure_success(response).await?;
9291
9292 Ok(())
9293 }
9294
9295 pub async fn get_watchers(&self, key: &str) -> Result<JiraWatcherList> {
9297 let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
9298
9299 let response = self.get_json(&url).await?;
9300
9301 let json: serde_json::Value = Self::parse_json(
9302 Self::ensure_success(response).await?,
9303 "Failed to parse watchers response",
9304 )
9305 .await?;
9306
9307 let watch_count = json["watchCount"].as_u64().unwrap_or(0) as u32;
9308
9309 let watchers = json["watchers"]
9310 .as_array()
9311 .map(|arr| {
9312 arr.iter()
9313 .filter_map(|v| serde_json::from_value::<JiraUser>(v.clone()).ok())
9314 .collect()
9315 })
9316 .unwrap_or_default();
9317
9318 Ok(JiraWatcherList {
9319 watchers,
9320 watch_count,
9321 })
9322 }
9323
9324 pub async fn add_watcher(&self, key: &str, account_id: &str) -> Result<()> {
9326 let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
9327
9328 let body = serde_json::json!(account_id);
9329
9330 let response = self.post_json(&url, &body).await?;
9331
9332 Self::ensure_success(response).await?;
9333
9334 Ok(())
9335 }
9336
9337 pub async fn remove_watcher(&self, key: &str, account_id: &str) -> Result<()> {
9339 let url = format!(
9340 "{}/rest/api/3/issue/{}/watchers?accountId={}",
9341 self.instance_url, key, account_id
9342 );
9343
9344 let response = self.delete(&url).await?;
9345
9346 Self::ensure_success(response).await?;
9347
9348 Ok(())
9349 }
9350
9351 pub async fn get_myself(&self) -> Result<JiraUser> {
9353 let url = format!("{}/rest/api/3/myself", self.instance_url);
9354
9355 let response = self
9356 .client
9357 .get(&url)
9358 .header("Authorization", &self.auth_header)
9359 .header("Accept", "application/json")
9360 .send()
9361 .await
9362 .context("Failed to send request to JIRA API")?;
9363
9364 let response = Self::ensure_success(response).await?;
9365
9366 response
9367 .json()
9368 .await
9369 .context("Failed to parse user response")
9370 }
9371}