1use base64::Engine;
2use base64::engine::general_purpose::STANDARD as BASE64;
3use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
4use serde::de::DeserializeOwned;
5use std::collections::BTreeMap;
6
7use super::ApiError;
8use super::AuthType;
9use super::types::*;
10
11pub struct JiraClient {
12 http: reqwest::Client,
13 base_url: String,
14 agile_base_url: String,
15 site_url: String,
16 host: String,
17 api_version: u8,
18}
19
20const SEARCH_FIELDS: [&str; 7] = [
21 "summary",
22 "status",
23 "assignee",
24 "priority",
25 "issuetype",
26 "created",
27 "updated",
28];
29const SEARCH_GET_JQL_LIMIT: usize = 1500;
30
31impl JiraClient {
32 pub fn new(
33 host: &str,
34 email: &str,
35 token: &str,
36 auth_type: AuthType,
37 api_version: u8,
38 ) -> Result<Self, ApiError> {
39 let (scheme, domain) = if host.starts_with("http://") {
42 (
43 "http",
44 host.trim_start_matches("http://").trim_end_matches('/'),
45 )
46 } else {
47 (
48 "https",
49 host.trim_start_matches("https://").trim_end_matches('/'),
50 )
51 };
52
53 if domain.is_empty() {
54 return Err(ApiError::Other("Host cannot be empty".into()));
55 }
56
57 let auth_value = match auth_type {
58 AuthType::Basic => {
59 let credentials = BASE64.encode(format!("{email}:{token}"));
60 format!("Basic {credentials}")
61 }
62 AuthType::Pat => format!("Bearer {token}"),
63 };
64
65 let mut headers = HeaderMap::new();
66 headers.insert(
67 AUTHORIZATION,
68 HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
69 );
70
71 let http = reqwest::Client::builder()
72 .default_headers(headers)
73 .timeout(std::time::Duration::from_secs(30))
74 .build()
75 .map_err(ApiError::Http)?;
76
77 let site_url = format!("{scheme}://{domain}");
78 let base_url = format!("{site_url}/rest/api/{api_version}");
79 let agile_base_url = format!("{site_url}/rest/agile/1.0");
80
81 Ok(Self {
82 http,
83 base_url,
84 agile_base_url,
85 site_url,
86 host: domain.to_string(),
87 api_version,
88 })
89 }
90
91 pub fn host(&self) -> &str {
92 &self.host
93 }
94
95 pub fn api_version(&self) -> u8 {
96 self.api_version
97 }
98
99 pub fn browse_base_url(&self) -> &str {
100 &self.site_url
101 }
102
103 pub fn browse_url(&self, issue_key: &str) -> String {
104 format!("{}/browse/{issue_key}", self.browse_base_url())
105 }
106
107 fn map_status(status: u16, body: String) -> ApiError {
108 let message = summarize_error_body(status, &body);
109 match status {
110 401 | 403 => ApiError::Auth(message),
111 404 => ApiError::NotFound(message),
112 429 => ApiError::RateLimit,
113 _ => ApiError::Api { status, message },
114 }
115 }
116
117 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
118 let url = format!("{}/{path}", self.base_url);
119 let resp = self.http.get(&url).send().await?;
120 let status = resp.status();
121 if !status.is_success() {
122 let body = resp.text().await.unwrap_or_default();
123 return Err(Self::map_status(status.as_u16(), body));
124 }
125 resp.json::<T>().await.map_err(ApiError::Http)
126 }
127
128 async fn agile_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
129 let url = format!("{}/{path}", self.agile_base_url);
130 let resp = self.http.get(&url).send().await?;
131 let status = resp.status();
132 if !status.is_success() {
133 let body = resp.text().await.unwrap_or_default();
134 return Err(Self::map_status(status.as_u16(), body));
135 }
136 resp.json::<T>().await.map_err(ApiError::Http)
137 }
138
139 async fn post<T: DeserializeOwned>(
140 &self,
141 path: &str,
142 body: &serde_json::Value,
143 ) -> Result<T, ApiError> {
144 let url = format!("{}/{path}", self.base_url);
145 let resp = self.http.post(&url).json(body).send().await?;
146 let status = resp.status();
147 if !status.is_success() {
148 let body_text = resp.text().await.unwrap_or_default();
149 return Err(Self::map_status(status.as_u16(), body_text));
150 }
151 resp.json::<T>().await.map_err(ApiError::Http)
152 }
153
154 async fn post_empty_response(
155 &self,
156 path: &str,
157 body: &serde_json::Value,
158 ) -> Result<(), ApiError> {
159 let url = format!("{}/{path}", self.base_url);
160 let resp = self.http.post(&url).json(body).send().await?;
161 let status = resp.status();
162 if !status.is_success() {
163 let body_text = resp.text().await.unwrap_or_default();
164 return Err(Self::map_status(status.as_u16(), body_text));
165 }
166 Ok(())
167 }
168
169 async fn put_empty_response(
170 &self,
171 path: &str,
172 body: &serde_json::Value,
173 ) -> Result<(), ApiError> {
174 let url = format!("{}/{path}", self.base_url);
175 let resp = self.http.put(&url).json(body).send().await?;
176 let status = resp.status();
177 if !status.is_success() {
178 let body_text = resp.text().await.unwrap_or_default();
179 return Err(Self::map_status(status.as_u16(), body_text));
180 }
181 Ok(())
182 }
183
184 pub async fn search(
188 &self,
189 jql: &str,
190 max_results: usize,
191 start_at: usize,
192 ) -> Result<SearchResponse, ApiError> {
193 let fields = SEARCH_FIELDS.join(",");
194 let encoded_jql = percent_encode(jql);
195 if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
196 let path = format!(
197 "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
198 );
199 self.get(&path).await
200 } else {
201 self.post(
202 "search",
203 &serde_json::json!({
204 "jql": jql,
205 "maxResults": max_results,
206 "startAt": start_at,
207 "fields": SEARCH_FIELDS,
208 }),
209 )
210 .await
211 }
212 }
213
214 pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
220 validate_issue_key(key)?;
221 let fields = "summary,status,assignee,reporter,priority,issuetype,description,labels,created,updated,comment,issuelinks";
222 let path = format!("issue/{key}?fields={fields}");
223 let mut issue: Issue = self.get(&path).await?;
224
225 if let Some(ref mut comment_list) = issue.fields.comment
227 && comment_list.total > comment_list.comments.len()
228 {
229 let mut start_at = comment_list.comments.len();
230 while comment_list.comments.len() < comment_list.total {
231 let page: CommentList = self
232 .get(&format!(
233 "issue/{key}/comment?startAt={start_at}&maxResults=100"
234 ))
235 .await?;
236 if page.comments.is_empty() {
237 break;
238 }
239 start_at += page.comments.len();
240 comment_list.comments.extend(page.comments);
241 }
242 }
243
244 Ok(issue)
245 }
246
247 #[allow(clippy::too_many_arguments)]
249 #[allow(clippy::too_many_arguments)]
250 pub async fn create_issue(
251 &self,
252 project_key: &str,
253 issue_type: &str,
254 summary: &str,
255 description: Option<&str>,
256 priority: Option<&str>,
257 labels: Option<&[&str]>,
258 assignee: Option<&str>,
259 parent: Option<&str>,
260 custom_fields: &[(String, serde_json::Value)],
261 ) -> Result<CreateIssueResponse, ApiError> {
262 let mut fields = serde_json::json!({
263 "project": { "key": project_key },
264 "issuetype": { "name": issue_type },
265 "summary": summary,
266 });
267
268 if let Some(desc) = description {
269 fields["description"] = self.make_body(desc);
270 }
271 if let Some(p) = priority {
272 fields["priority"] = serde_json::json!({ "name": p });
273 }
274 if let Some(lbls) = labels
275 && !lbls.is_empty()
276 {
277 fields["labels"] = serde_json::json!(lbls);
278 }
279 if let Some(id) = assignee {
280 fields["assignee"] = self.assignee_payload(id);
281 }
282 if let Some(parent_key) = parent {
283 fields["parent"] = serde_json::json!({ "key": parent_key });
284 }
285 for (key, value) in custom_fields {
286 fields[key] = value.clone();
287 }
288
289 self.post("issue", &serde_json::json!({ "fields": fields }))
290 .await
291 }
292
293 pub async fn log_work(
298 &self,
299 key: &str,
300 time_spent: &str,
301 comment: Option<&str>,
302 started: Option<&str>,
303 ) -> Result<WorklogEntry, ApiError> {
304 validate_issue_key(key)?;
305 let mut payload = serde_json::json!({ "timeSpent": time_spent });
306 if let Some(c) = comment {
307 payload["comment"] = self.make_body(c);
308 }
309 if let Some(s) = started {
310 payload["started"] = serde_json::Value::String(s.to_string());
311 }
312 self.post(&format!("issue/{key}/worklog"), &payload).await
313 }
314
315 pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
317 validate_issue_key(key)?;
318 let payload = serde_json::json!({ "body": self.make_body(body) });
319 self.post(&format!("issue/{key}/comment"), &payload).await
320 }
321
322 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
324 validate_issue_key(key)?;
325 let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
326 Ok(resp.transitions)
327 }
328
329 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
331 validate_issue_key(key)?;
332 let payload = serde_json::json!({ "transition": { "id": transition_id } });
333 self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
334 .await
335 }
336
337 pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
342 validate_issue_key(key)?;
343 let payload = match account_id {
344 Some(id) => self.assignee_payload(id),
345 None => {
346 if self.api_version >= 3 {
347 serde_json::json!({ "accountId": null })
348 } else {
349 serde_json::json!({ "name": null })
350 }
351 }
352 };
353 self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
354 .await
355 }
356
357 fn assignee_payload(&self, id: &str) -> serde_json::Value {
361 if self.api_version >= 3 {
362 serde_json::json!({ "accountId": id })
363 } else {
364 serde_json::json!({ "name": id })
365 }
366 }
367
368 pub async fn get_myself(&self) -> Result<Myself, ApiError> {
370 self.get("myself").await
371 }
372
373 pub async fn update_issue(
375 &self,
376 key: &str,
377 summary: Option<&str>,
378 description: Option<&str>,
379 priority: Option<&str>,
380 custom_fields: &[(String, serde_json::Value)],
381 ) -> Result<(), ApiError> {
382 validate_issue_key(key)?;
383 let mut fields = serde_json::Map::new();
384 if let Some(s) = summary {
385 fields.insert("summary".into(), serde_json::Value::String(s.into()));
386 }
387 if let Some(d) = description {
388 fields.insert("description".into(), self.make_body(d));
389 }
390 if let Some(p) = priority {
391 fields.insert("priority".into(), serde_json::json!({ "name": p }));
392 }
393 for (k, value) in custom_fields {
394 fields.insert(k.clone(), value.clone());
395 }
396 if fields.is_empty() {
397 return Err(ApiError::InvalidInput(
398 "At least one field (--summary, --description, --priority, or --field) is required"
399 .into(),
400 ));
401 }
402 self.put_empty_response(
403 &format!("issue/{key}"),
404 &serde_json::json!({ "fields": fields }),
405 )
406 .await
407 }
408
409 fn make_body(&self, text: &str) -> serde_json::Value {
414 if self.api_version >= 3 {
415 text_to_adf(text)
416 } else {
417 serde_json::Value::String(text.to_string())
418 }
419 }
420
421 pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
427 let encoded = percent_encode(query);
428 let param = if self.api_version >= 3 {
429 "query"
430 } else {
431 "username"
432 };
433 let path = format!("user/search?{param}={encoded}&maxResults=50");
434 self.get::<Vec<User>>(&path).await
435 }
436
437 pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
441 #[derive(serde::Deserialize)]
442 struct Wrapper {
443 #[serde(rename = "issueLinkTypes")]
444 types: Vec<IssueLinkType>,
445 }
446 let w: Wrapper = self.get("issueLinkType").await?;
447 Ok(w.types)
448 }
449
450 pub async fn link_issues(
456 &self,
457 from_key: &str,
458 to_key: &str,
459 link_type: &str,
460 ) -> Result<(), ApiError> {
461 validate_issue_key(from_key)?;
462 validate_issue_key(to_key)?;
463 let payload = serde_json::json!({
464 "type": { "name": link_type },
465 "inwardIssue": { "key": from_key },
466 "outwardIssue": { "key": to_key },
467 });
468 let url = format!("{}/issueLink", self.base_url);
469 let resp = self.http.post(&url).json(&payload).send().await?;
470 let status = resp.status();
471 if !status.is_success() {
472 let body = resp.text().await.unwrap_or_default();
473 return Err(Self::map_status(status.as_u16(), body));
474 }
475 Ok(())
476 }
477
478 pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
480 let url = format!("{}/issueLink/{link_id}", self.base_url);
481 let resp = self.http.delete(&url).send().await?;
482 let status = resp.status();
483 if !status.is_success() {
484 let body = resp.text().await.unwrap_or_default();
485 return Err(Self::map_status(status.as_u16(), body));
486 }
487 Ok(())
488 }
489
490 pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
494 let mut all = Vec::new();
495 let mut start_at = 0usize;
496 const PAGE: usize = 50;
497 loop {
498 let path = format!("board?startAt={start_at}&maxResults={PAGE}");
499 let page: BoardSearchResponse = self.agile_get(&path).await?;
500 let received = page.values.len();
501 all.extend(page.values);
502 if page.is_last || received == 0 {
503 break;
504 }
505 start_at += received;
506 }
507 Ok(all)
508 }
509
510 pub async fn list_sprints(
514 &self,
515 board_id: u64,
516 state: Option<&str>,
517 ) -> Result<Vec<Sprint>, ApiError> {
518 let mut all = Vec::new();
519 let mut start_at = 0usize;
520 const PAGE: usize = 50;
521 loop {
522 let state_param = state.map(|s| format!("&state={s}")).unwrap_or_default();
523 let path = format!(
524 "board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
525 );
526 let page: SprintSearchResponse = self.agile_get(&path).await?;
527 let received = page.values.len();
528 all.extend(page.values);
529 if page.is_last || received == 0 {
530 break;
531 }
532 start_at += received;
533 }
534 Ok(all)
535 }
536
537 pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
545 if self.api_version < 3 {
546 return self.get::<Vec<Project>>("project").await;
547 }
548
549 let mut all: Vec<Project> = Vec::new();
550 let mut start_at: usize = 0;
551 const PAGE: usize = 50;
552
553 loop {
554 let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
555 let page: ProjectSearchResponse = self.get(&path).await?;
556 let page_start = page.start_at;
557 let received = page.values.len();
558 let total = page.total;
559 all.extend(page.values);
560
561 if page.is_last || all.len() >= total {
562 break;
563 }
564
565 if received == 0 {
566 return Err(ApiError::Other(
567 "Project pagination returned an empty non-terminal page".into(),
568 ));
569 }
570
571 start_at = page_start.saturating_add(received);
572 }
573
574 Ok(all)
575 }
576
577 pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
579 self.get(&format!("project/{key}")).await
580 }
581
582 pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
586 self.get::<Vec<Field>>("field").await
587 }
588
589 pub async fn move_issue_to_sprint(
593 &self,
594 issue_key: &str,
595 sprint_id: u64,
596 ) -> Result<(), ApiError> {
597 validate_issue_key(issue_key)?;
598 let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
599 let payload = serde_json::json!({ "issues": [issue_key] });
600 let resp = self.http.post(&url).json(&payload).send().await?;
601 let status = resp.status();
602 if !status.is_success() {
603 let body = resp.text().await.unwrap_or_default();
604 return Err(Self::map_status(status.as_u16(), body));
605 }
606 Ok(())
607 }
608
609 pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
611 self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
612 .await
613 }
614
615 pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
622 if let Ok(id) = specifier.parse::<u64>() {
623 return self.get_sprint(id).await;
624 }
625
626 let boards = self.list_boards().await?;
627 if boards.is_empty() {
628 return Err(ApiError::NotFound("No boards found".into()));
629 }
630
631 let target_state = if specifier.eq_ignore_ascii_case("active") {
632 Some("active")
633 } else {
634 None
635 };
636
637 for board in &boards {
638 let sprints = self.list_sprints(board.id, target_state).await?;
639 for sprint in sprints {
640 if specifier.eq_ignore_ascii_case("active") {
641 if sprint.state == "active" {
642 return Ok(sprint);
643 }
644 } else if sprint
645 .name
646 .to_lowercase()
647 .contains(&specifier.to_lowercase())
648 {
649 return Ok(sprint);
650 }
651 }
652 }
653
654 Err(ApiError::NotFound(format!(
655 "No sprint found matching '{specifier}'"
656 )))
657 }
658
659 pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
663 if let Ok(id) = specifier.parse::<u64>() {
664 return Ok(id);
665 }
666 self.resolve_sprint(specifier).await.map(|s| s.id)
667 }
668}
669
670fn validate_issue_key(key: &str) -> Result<(), ApiError> {
676 let mut parts = key.splitn(2, '-');
677 let project = parts.next().unwrap_or("");
678 let number = parts.next().unwrap_or("");
679
680 let valid = !project.is_empty()
681 && !number.is_empty()
682 && project
683 .chars()
684 .next()
685 .is_some_and(|c| c.is_ascii_uppercase())
686 && project
687 .chars()
688 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
689 && number.chars().all(|c| c.is_ascii_digit());
690
691 if valid {
692 Ok(())
693 } else {
694 Err(ApiError::InvalidInput(format!(
695 "Invalid issue key '{key}'. Expected format: PROJECT-123"
696 )))
697 }
698}
699
700fn percent_encode(s: &str) -> String {
704 let mut encoded = String::with_capacity(s.len() * 2);
705 for byte in s.bytes() {
706 match byte {
707 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
708 encoded.push(byte as char)
709 }
710 b => encoded.push_str(&format!("%{b:02X}")),
711 }
712 }
713 encoded
714}
715
716fn truncate_error_body(body: &str) -> String {
718 const MAX: usize = 200;
719 if body.chars().count() <= MAX {
720 body.to_string()
721 } else {
722 let truncated: String = body.chars().take(MAX).collect();
723 format!("{truncated}… (truncated)")
724 }
725}
726
727fn summarize_error_body(status: u16, body: &str) -> String {
728 if should_include_raw_error_body() && !body.trim().is_empty() {
729 return truncate_error_body(body);
730 }
731
732 if let Some(message) = summarize_json_error_body(body) {
733 return message;
734 }
735
736 default_status_message(status)
737}
738
739fn summarize_json_error_body(body: &str) -> Option<String> {
740 let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
741 let mut parts = Vec::new();
742
743 if !parsed.error_messages.is_empty() {
744 parts.push(format!(
745 "{} Jira error message(s) returned",
746 parsed.error_messages.len()
747 ));
748 }
749
750 if !parsed.errors.is_empty() {
751 let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
752 parts.push(format!(
753 "validation errors for fields: {}",
754 fields.join(", ")
755 ));
756 }
757
758 if parts.is_empty() {
759 None
760 } else {
761 Some(parts.join("; "))
762 }
763}
764
765fn default_status_message(status: u16) -> String {
766 match status {
767 401 | 403 => "request unauthorized".into(),
768 404 => "resource not found".into(),
769 429 => "rate limited by Jira".into(),
770 400..=499 => format!("request failed with status {status}"),
771 _ => format!("Jira request failed with status {status}"),
772 }
773}
774
775fn should_include_raw_error_body() -> bool {
776 matches!(
777 std::env::var("JIRA_DEBUG_HTTP").ok().as_deref(),
778 Some("1" | "true" | "TRUE" | "yes" | "YES")
779 )
780}
781
782#[derive(Debug, serde::Deserialize)]
783#[serde(rename_all = "camelCase")]
784struct JiraErrorPayload {
785 #[serde(default)]
786 error_messages: Vec<String>,
787 #[serde(default)]
788 errors: BTreeMap<String, String>,
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794
795 #[test]
796 fn percent_encode_spaces_use_percent_20() {
797 assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
798 }
799
800 #[test]
801 fn percent_encode_complex_jql() {
802 let jql = r#"project = "MY PROJECT""#;
803 let encoded = percent_encode(jql);
804 assert!(encoded.contains("project"));
805 assert!(!encoded.contains('"'));
806 assert!(!encoded.contains(' '));
807 }
808
809 #[test]
810 fn validate_issue_key_valid() {
811 assert!(validate_issue_key("PROJ-123").is_ok());
812 assert!(validate_issue_key("ABC-1").is_ok());
813 assert!(validate_issue_key("MYPROJECT-9999").is_ok());
814 assert!(validate_issue_key("ABC2-123").is_ok());
816 assert!(validate_issue_key("P1-1").is_ok());
817 }
818
819 #[test]
820 fn validate_issue_key_invalid() {
821 assert!(validate_issue_key("proj-123").is_err()); assert!(validate_issue_key("PROJ123").is_err()); assert!(validate_issue_key("PROJ-abc").is_err()); assert!(validate_issue_key("../etc/passwd").is_err());
825 assert!(validate_issue_key("").is_err());
826 assert!(validate_issue_key("1PROJ-123").is_err()); }
828
829 #[test]
830 fn truncate_error_body_short() {
831 let body = "short error";
832 assert_eq!(truncate_error_body(body), body);
833 }
834
835 #[test]
836 fn truncate_error_body_long() {
837 let body = "x".repeat(300);
838 let result = truncate_error_body(&body);
839 assert!(result.len() < body.len());
840 assert!(result.ends_with("(truncated)"));
841 }
842
843 #[test]
844 fn summarize_json_error_body_redacts_values() {
845 let body = serde_json::json!({
846 "errorMessages": ["JQL validation failed"],
847 "errors": {
848 "summary": "Summary must not contain secret project name",
849 "description": "Description cannot include api token"
850 }
851 })
852 .to_string();
853
854 let message = summarize_error_body(400, &body);
855 assert!(message.contains("1 Jira error message(s) returned"));
856 assert!(message.contains("summary"));
857 assert!(message.contains("description"));
858 assert!(!message.contains("secret project name"));
859 assert!(!message.contains("api token"));
860 }
861
862 #[test]
863 fn browse_url_preserves_explicit_http_hosts() {
864 let client = JiraClient::new(
865 "http://localhost:8080",
866 "me@example.com",
867 "token",
868 AuthType::Basic,
869 3,
870 )
871 .unwrap();
872 assert_eq!(
873 client.browse_url("PROJ-1"),
874 "http://localhost:8080/browse/PROJ-1"
875 );
876 }
877
878 #[test]
879 fn new_with_pat_auth_does_not_require_email() {
880 let client = JiraClient::new(
881 "https://jira.example.com",
882 "",
883 "my-pat-token",
884 AuthType::Pat,
885 3,
886 );
887 assert!(client.is_ok());
888 }
889
890 #[test]
891 fn new_with_api_v2_uses_v2_base_url() {
892 let client = JiraClient::new(
893 "https://jira.example.com",
894 "me@example.com",
895 "token",
896 AuthType::Basic,
897 2,
898 )
899 .unwrap();
900 assert_eq!(client.api_version(), 2);
901 }
902}