jira_issue_api/
models.rs

1use crate::JiraClientError;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeMap;
6use std::{
7    collections::HashMap,
8    fmt::{Display, Error, Formatter},
9    sync::OnceLock,
10};
11
12#[derive(Deserialize, Serialize, Debug, Clone)]
13#[serde(rename_all = "camelCase")]
14pub struct User {
15    pub active: bool,
16    pub display_name: String,
17    pub deleted: Option<bool>,
18    pub name: String,
19}
20
21#[derive(Serialize, Debug, Clone)]
22pub struct PostAssignBody {
23    pub name: String,
24}
25
26impl From<User> for PostAssignBody {
27    fn from(value: User) -> Self {
28        PostAssignBody { name: value.name }
29    }
30}
31
32/// Define query parameters
33#[derive(Debug, Clone)]
34pub struct GetAssignableUserParams {
35    pub username: Option<String>,
36    pub project: Option<String>,
37    pub issue_key: Option<IssueKey>,
38    pub max_results: Option<u32>,
39}
40
41/// Comment related types
42#[derive(Serialize, Debug, Clone)]
43#[serde(rename_all = "camelCase")]
44pub struct PostCommentBody {
45    pub body: String,
46}
47
48/// Worklog related types
49#[derive(Serialize, Debug, Clone)]
50#[serde(rename_all = "camelCase")]
51pub struct PostWorklogBody {
52    pub comment: String,
53    pub started: String,
54    pub time_spent: Option<String>,
55    pub time_spent_seconds: Option<String>,
56}
57
58#[derive(Serialize, Debug, Clone)]
59#[serde(rename_all = "camelCase")]
60/// If duration unit is unspecififed, defaults to minutes.
61pub struct WorklogDuration(String);
62
63impl Display for WorklogDuration {
64    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
65        write!(f, "{}", self.0)
66    }
67}
68
69static WORKLOG_RE: OnceLock<Regex> = OnceLock::new();
70
71impl TryFrom<String> for WorklogDuration {
72    type Error = JiraClientError;
73    fn try_from(value: String) -> Result<Self, JiraClientError> {
74        let worklog_re = WORKLOG_RE.get_or_init(|| {
75            Regex::new(r"([0-9]+(?:\.[0-9]+)?)[WwDdHhMm]?").expect("Unable to compile WORKLOG_RE")
76        });
77
78        let mut worklog = match worklog_re.captures(&value) {
79            Some(c) => match c.get(0) {
80                Some(worklog_match) => Ok(worklog_match.as_str().to_lowercase()),
81                None => Err(JiraClientError::TryFromError(
82                    "First capture is none: WORKLOG_RE".to_string(),
83                )),
84            },
85            None => Err(JiraClientError::TryFromError(
86                "Malformed worklog duration".to_string(),
87            )),
88        }?;
89
90        let multiplier = match worklog.pop() {
91            Some('m') => 60,
92            Some('h') => 3600,
93            Some('d') => 3600 * 8,     // 8 Hours is default for cloud.
94            Some('w') => 3600 * 8 * 5, // 5 days of work in a week.
95            Some(maybe_digit) if maybe_digit.is_ascii_digit() => {
96                worklog.push(maybe_digit); // Unit was omitted
97                60
98            }
99            _ => 60, // Should never reach this due to the Regex Match, but try parsing input anyways.
100        };
101
102        let seconds = worklog.parse::<f64>().map_err(|_| {
103            JiraClientError::TryFromError("Unexpected worklog duration input".to_string())
104        })? * f64::from(multiplier);
105
106        Ok(WorklogDuration(format!("{seconds:.0}")))
107    }
108}
109
110/// Issue related types
111#[derive(Serialize, Debug, Clone)]
112#[serde(rename_all = "camelCase")]
113pub struct PostIssueQueryBody {
114    pub fields: Option<Vec<String>>,
115    pub jql: String,
116    pub max_results: u32,
117    pub start_at: u32,
118    /// Expects camelCase and the API is case-sensitive
119    pub expand: Option<Vec<String>>,
120}
121
122#[derive(Deserialize, Serialize, Debug, Clone)]
123#[serde(rename_all = "camelCase")]
124pub struct PostIssueQueryResponseBody {
125    /// https://docs.atlassian.com/software/jira/docs/api/REST/7.6.1/#api/2/search
126    pub expand: Option<String>,
127    pub issues: Option<Vec<Issue>>,
128    pub max_results: Option<u32>,
129    pub start_at: Option<u32>,
130    pub total: Option<u32>,
131    /// Some when expanding names on query_issues
132    pub names: Option<HashMap<String, String>>,
133}
134
135#[derive(Deserialize, Serialize, Debug, Clone)]
136#[serde(rename_all = "camelCase")]
137pub struct Issue {
138    pub expand: Option<String>,
139    pub fields: IssueFields,
140    pub id: Option<serde_json::Value>,
141    pub key: IssueKey,
142    #[serde(alias = "self")]
143    pub self_ref: String,
144    /// Some when expanding names on query_issue
145    pub names: Option<HashMap<String, String>>,
146
147    #[serde(flatten)]
148    pub remainder: BTreeMap<String, Value>,
149}
150
151/// All fields are optional as it's possible to define what fields you want in the request
152#[derive(Deserialize, Serialize, Debug, Clone, Default)]
153#[serde(rename_all = "camelCase")]
154pub struct IssueFields {
155    pub assignee: Option<User>,
156    pub components: Option<Vec<Component>>,
157    pub created: Option<String>,
158    pub creator: Option<User>,
159    pub description: Option<String>,
160    pub duedate: Option<String>,
161    pub labels: Option<Vec<String>>,
162    pub last_viewed: Option<String>,
163    pub reporter: Option<User>,
164    pub resolutiondate: Option<String>,
165    pub summary: Option<String>,
166    pub timeestimate: Option<u32>,
167    pub timeoriginalestimate: Option<u32>,
168    pub timespent: Option<u32>,
169    pub updated: Option<String>,
170    pub workratio: Option<i32>,
171
172    // pub project: Project,            //TODO
173    // pub issuetype: IssueType,        //TODO
174    pub status: Option<Status>,
175    // pub comment: CommentContainer,   //TODO
176    // pub resolution: Resolution,      //TODO
177    // pub priority: Priority,          //TODO
178    // pub progress: Progress,          //TODO
179    pub subtasks: Option<Vec<SubTask>>,
180    // pub issue_links: Vec<Value>,     //TODO
181    // pub votes: Votes,                //TODO
182    pub worklog: Option<WorkLog>,
183    // pub timetracking: TimeTracking,  //TODO
184    // pub watches: Watches,            //TODO
185    // pub fix_versions: Vec<Version>,  //TODO
186    // pub versions: Vec<Version>,      //TODO
187    // pub attachment: Vec<Attachment>, //TODO
188    #[serde(flatten)]
189    pub customfields: BTreeMap<String, Value>,
190}
191
192#[derive(Deserialize, Serialize, Debug, Clone)]
193#[serde(rename_all = "camelCase")]
194pub struct Field {
195    pub id: String,
196    pub name: String,
197    pub custom: bool,
198    pub orderable: bool,
199    pub navigable: bool,
200    pub searchable: bool,
201    pub clause_names: Vec<String>,
202    pub schema: Option<FieldSchema>,
203}
204
205#[derive(Deserialize, Serialize, Debug, Clone)]
206#[serde(rename_all = "camelCase")]
207pub struct FieldSchema {
208    pub custom: Option<FieldSchemaType>,
209    pub custom_id: Option<u32>,
210    pub items: Option<FieldSchemaType>,
211    pub system: Option<FieldSchemaType>,
212    #[serde(alias = "type")]
213    pub field_type: Option<String>,
214}
215
216#[derive(Deserialize, Serialize, Debug, Clone)]
217#[serde(rename_all = "kebab-case")]
218pub enum FieldSchemaType {
219    Any,
220    Array,
221    Attachment,
222    CommentsPage,
223    Component,
224    Date,
225    Datetime,
226    Issuelinks,
227    Issuetype,
228    Number,
229    Option,
230    Priority,
231    Progress,
232    Project,
233    Resolution,
234    Securitylevel,
235    Status,
236    String,
237    Timetracking,
238    User,
239    Version,
240    Votes,
241    Watches,
242    Worklog,
243    #[serde(untagged)]
244    Custom(String),
245}
246
247#[derive(Deserialize, Serialize, Debug, Clone)]
248#[serde(rename_all = "camelCase")]
249pub struct Filter {
250    #[serde(alias = "self")]
251    pub self_ref: String,
252    pub id: String,
253    pub name: String,
254    pub description: Option<String>,
255    pub owner: User,
256    pub jql: String,
257    pub view_url: String,
258    pub search_url: String,
259    pub favourite: bool,
260    pub shared_users: FilterSharedUsers,
261    // pub subscriptions: FilterSubscriptions
262    // pub share_permissions: FilterSharePermissions
263}
264
265impl Display for Filter {
266    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
267        write!(f, "{}: {}", self.name, self.jql)
268    }
269}
270
271#[derive(Deserialize, Serialize, Debug, Clone)]
272#[serde(rename_all = "kebab-case")]
273pub struct FilterSharedUsers {
274    pub size: u32,
275    pub max_results: u32,
276    pub start_index: u32,
277    pub end_index: u32,
278    pub items: Vec<User>,
279}
280
281#[derive(Deserialize, Serialize, Debug, Clone)]
282pub struct Component {
283    pub id: String,
284    pub name: String,
285    #[serde(alias = "self")]
286    pub self_ref: String,
287}
288
289#[derive(Deserialize, Serialize, Debug, Clone)]
290pub struct Status {
291    #[serde(alias = "self")]
292    pub self_ref: String,
293    pub description: String,
294    #[serde(alias = "iconUrl")]
295    pub icon_url: String,
296    pub name: String,
297    pub id: String,
298}
299
300#[derive(Deserialize, Serialize, Debug, Clone)]
301pub struct WorkLog {
302    #[serde(alias = "startAt")]
303    pub start_at: usize,
304    #[serde(alias = "maxResults")]
305    pub max_results: usize,
306    pub total: usize,
307    #[serde(alias = "worklogs")]
308    pub work_logs: Vec<WorkLogItem>,
309}
310
311#[derive(Deserialize, Serialize, Debug, Clone)]
312pub struct WorkLogItem {
313    #[serde(alias = "self")]
314    pub self_ref: String,
315    pub author: Author,
316    #[serde(alias = "updateAuthor")]
317    pub update_author: Author,
318    pub comment: String,
319    pub created: String, // TODO: chrono?
320    pub updated: String,
321    pub started: String,
322    #[serde(alias = "timeSpent")]
323    pub time_spent: String,
324    #[serde(alias = "timeSpentSeconds")]
325    pub time_spent_seconds: usize,
326    pub id: String,
327    #[serde(alias = "issueId")]
328    pub issue_id: String,
329}
330
331#[derive(Deserialize, Serialize, Debug, Clone)]
332pub struct Author {
333    #[serde(alias = "self")]
334    pub self_ref: String,
335    pub name: String,
336    pub key: String,
337    #[serde(alias = "emailAddress")]
338    pub email_address: String,
339    #[serde(alias = "avatarUrls")]
340    pub avatar_urls: AvatarUrls,
341    #[serde(alias = "displayName")]
342    pub display_name: String,
343    pub active: bool,
344    #[serde(alias = "timeZone")]
345    pub time_zone: String,
346}
347
348#[derive(Deserialize, Serialize, Debug, Clone)]
349pub struct AvatarUrls {
350    #[serde(rename = "48x48")]
351    avatar_48x48: String,
352    #[serde(rename = "24x24")]
353    avatar_24x24: String,
354    #[serde(rename = "16x16")]
355    avatar_16x16: String,
356    #[serde(rename = "32x32")]
357    avatar_32x32: String,
358}
359
360#[derive(Deserialize, Serialize, Debug, Clone)]
361pub struct SubTask {
362    pub id: String,
363    pub key: String,
364    #[serde(alias = "self")]
365    pub self_ref: String,
366    pub fields: SubTaskFields,
367}
368
369#[derive(Deserialize, Serialize, Debug, Clone)]
370pub struct SubTaskFields {
371    pub summary: String,
372    pub status: SubTaskFieldsStatus,
373    #[serde(alias = "issuetype")]
374    pub issue_type: SubTaskFieldsIssueType,
375}
376
377#[derive(Deserialize, Serialize, Debug, Clone)]
378pub struct SubTaskFieldsStatus {
379    #[serde(alias = "self")]
380    pub self_ref: String,
381    pub description: String,
382    #[serde(alias = "iconUrl")]
383    pub icon_url: String,
384    pub name: String,
385    pub id: String,
386    #[serde(alias = "statusCategory")]
387    pub status_category: SubTaskFieldsStatusCategory,
388}
389
390#[derive(Deserialize, Serialize, Debug, Clone)]
391pub struct SubTaskFieldsStatusCategory {
392    #[serde(alias = "self")]
393    pub self_ref: String,
394    pub id: u32,
395    pub key: String,
396    #[serde(alias = "colorName")]
397    pub color_name: String,
398    pub name: String,
399}
400#[derive(Deserialize, Serialize, Debug, Clone)]
401pub struct SubTaskFieldsIssueType {
402    #[serde(alias = "self")]
403    pub self_ref: String,
404    pub id: String,
405    pub description: String,
406    #[serde(alias = "iconUrl")]
407    pub icon_url: String,
408    pub name: String,
409    pub subtask: bool,
410    #[serde(alias = "avatarId")]
411    pub avatar_id: usize,
412}
413
414impl Display for Issue {
415    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
416        write!(
417            f,
418            "{} {}",
419            self.key,
420            self.fields
421                .summary
422                .clone()
423                .unwrap_or("summary is None or missing from query response".to_string())
424        )
425    }
426}
427
428#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
429pub struct IssueKey(String);
430
431impl From<IssueKey> for String {
432    fn from(val: IssueKey) -> Self {
433        val.0
434    }
435}
436
437impl Display for IssueKey {
438    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
439        write!(f, "{}", self.0)
440    }
441}
442
443static ISSUE_RE: OnceLock<Regex> = OnceLock::new();
444
445impl TryFrom<String> for IssueKey {
446    type Error = JiraClientError;
447    fn try_from(value: String) -> Result<Self, Self::Error> {
448        let issue_re = ISSUE_RE
449            .get_or_init(|| Regex::new(r"([A-Z]{2,}-[0-9]+)").expect("Unable to compile ISSUE_RE"));
450
451        let upper = value.to_uppercase();
452        let issue_key = match issue_re.captures(&upper) {
453            Some(c) => match c.get(0) {
454                Some(cap) => Ok(cap),
455                None => Err(JiraClientError::TryFromError(
456                    "First capture is none: ISSUE_RE".to_string(),
457                )),
458            },
459            None => Err(JiraClientError::TryFromError(
460                "Malformed issue key supplied".to_string(),
461            )),
462        }?;
463
464        Ok(IssueKey(issue_key.as_str().to_string()))
465    }
466}
467
468#[derive(Deserialize, Serialize, Debug, Clone)]
469#[serde(rename_all = "camelCase")]
470pub struct GetTransitionsBody {
471    pub expand: String,
472    pub transitions: Vec<Transition>,
473}
474
475#[derive(Deserialize, Serialize, Debug, Clone)]
476pub struct Transition {
477    pub fields: HashMap<String, TransitionExpandedFields>,
478    pub id: String,
479    pub name: String,
480}
481
482#[derive(Deserialize, Serialize, Debug, Clone)]
483pub struct TransitionExpandedFields {
484    pub required: bool,
485    pub name: String,
486    pub operations: Vec<String>,
487    pub schema: TransitionExpandedFieldsSchema,
488    pub allowed_values: Option<Vec<TransitionFieldAllowedValue>>,
489    pub has_default_value: Option<bool>,
490    pub default_value: Option<String>,
491}
492
493#[derive(Deserialize, Serialize, Debug, Clone)]
494#[serde(untagged)]
495pub enum TransitionFieldAllowedValue {
496    Str(String),
497    Object {
498        #[serde(alias = "self")]
499        self_ref: String,
500        #[serde(alias = "name")]
501        value: String,
502        id: String,
503    },
504}
505
506#[derive(Deserialize, Serialize, Debug, Clone)]
507pub struct TransitionExpandedFieldsSchema {
508    #[serde(alias = "type")]
509    pub schema_type: String,
510    pub items: Option<String>,
511    pub custom: Option<String>,
512    pub custom_id: Option<u32>,
513    pub system: Option<String>,
514}
515
516#[derive(Serialize, Debug, Clone)]
517pub struct PostTransitionIdBody {
518    pub id: String,
519}
520
521#[derive(Serialize, Debug, Clone)]
522pub struct PostTransitionFieldBody {
523    pub name: String,
524}
525
526#[derive(Serialize, Debug, Clone)]
527pub struct PostTransitionBody {
528    pub transition: PostTransitionIdBody,
529    pub fields: Option<PostTransitionFieldBody>,
530    pub update: Option<PostTransitionUpdateField>,
531}
532
533/// Server
534#[derive(Serialize, Debug, Clone)]
535pub struct PostTransitionUpdateField {
536    pub add: Option<HashMap<String, Vec<String>>>,
537    pub copy: Option<HashMap<String, Vec<String>>>,
538    pub edit: Option<HashMap<String, Vec<String>>>,
539    pub remove: Option<HashMap<String, Vec<String>>>,
540    pub set: Option<HashMap<String, Vec<String>>>,
541}
542
543impl Display for Transition {
544    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
545        write!(f, "{}", self.name)
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn worklog_tryfrom_all_units_returns_duration_in_seconds() -> Result<(), JiraClientError> {
555        let worklogs = vec![
556            (60, "1"),
557            (60, "1m"),
558            (60, "1M"),
559            (3600, "1h"),
560            (3600, "1H"),
561            (3600 * 8, "1d"),
562            (3600 * 8, "1D"),
563            (3600 * 8 * 5, "1w"),
564            (3600 * 8 * 5, "1W"),
565        ];
566
567        for (expected_seconds, input) in worklogs {
568            let seconds = WorklogDuration::try_from(input.to_string())?.0;
569            assert_eq!(expected_seconds.to_string(), seconds);
570        }
571        Ok(())
572    }
573
574    #[test]
575    fn worklog_tryfrom_lowercase_unit() -> Result<(), JiraClientError> {
576        let wl = WorklogDuration::try_from(String::from("1h"))?;
577        assert_eq!(String::from("3600"), wl.to_string());
578        Ok(())
579    }
580    #[test]
581    fn worklog_tryfrom_uppercase_unit() -> Result<(), JiraClientError> {
582        let wl = WorklogDuration::try_from(String::from("2H"))?;
583        assert_eq!(String::from("7200"), wl.to_string());
584        Ok(())
585    }
586
587    #[test]
588    fn worklog_tostring() -> Result<(), JiraClientError> {
589        let wl = WorklogDuration::try_from(String::from("1h"))?;
590        let expected = String::from("3600");
591        assert_eq!(expected, wl.to_string());
592        Ok(())
593    }
594
595    #[test]
596    fn issuekey_tryfrom_uppercase_id() -> Result<(), JiraClientError> {
597        let key = String::from("JB-1");
598        let issue = IssueKey::try_from(key.clone());
599        assert!(issue.is_ok());
600        assert_eq!(key, issue?.0);
601        Ok(())
602    }
603
604    #[test]
605    fn issuekey_tryfrom_lowercase_id() {
606        let issue = IssueKey::try_from(String::from("jb-1"));
607        assert!(issue.is_ok());
608    }
609
610    #[test]
611    fn issuekey_tostring() {
612        let key = String::from("JB-1");
613        let issue = IssueKey(key.clone());
614        assert_eq!(key, issue.to_string());
615    }
616}