redmine_api/api/
issues.rs

1//! Issues Rest API Endpoint definitions
2//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_Issues)
3//!
4//! [Redmine Documentation Journals](https://www.redmine.org/projects/redmine/wiki/Rest_IssueJournals)
5//! (Journals in Redmine terminology are notes/comments and change histories for an issue)
6//!
7//! - [ ] all issues endpoint
8//!   - [x] sort
9//!     - [ ] limit sort to the existing columns only instead of a string value
10//!   - [x] query_id parameter
11//!   - [x] pagination
12//!   - [x] issue_id filter
13//!     - [x] issue id (multiple are possible, comma separated)
14//!   - [x] project_id filter
15//!     - [x] project id (multiple are possible, comma separated)
16//!   - [x] subproject_id filter
17//!     - [x] !* filter to only get parent project issues
18//!   - [x] tracker_id filter
19//!     - [x] tracker id (multiple are possible, comma separated)
20//!   - [x] status_id filter
21//!     - [x] open (default)
22//!     - [x] closed
23//!     - [x] for both
24//!     - [x] status id (multiple are possible, comma separated)
25//!   - [x] category_id filter
26//!     - [x] category id (multiple are possible, comma separated)
27//!   - [x] priority_id filter
28//!     - [x] priority id (multiple are possible, comma separated)
29//!   - [x] author_id filter
30//!     - [x] any
31//!     - [x] me
32//!     - [x] !me
33//!     - [x] user/group id (multiple are possible, comma separated)
34//!     - [x] negation of list
35//!   - [x] assigned_to_id filter
36//!     - [x] any
37//!     - [x] me
38//!     - [x] !me
39//!     - [x] user/group id (multiple are possible, comma separated)
40//!     - [x] negation of list
41//!     - [x] none (!*)
42//!   - [x] fixed_version_id filter (Target version, API uses old name)
43//!     - [x] version id (multiple are possible, comma separated)
44//!   - [ ] is_private filter
45//!   - [x] parent_id filter
46//!     - [x] issue id (multiple are possible, comma separated)
47//!   - [ ] custom field filter
48//!     - [ ] exact match
49//!     - [ ] substring match
50//!     - [ ] what about multiple value custom fields?
51//!   - [x] subject filter
52//!     - [x] exact match
53//!     - [x] substring match
54//!   - [x] description filter
55//!     - [x] exact match
56//!     - [x] substring match
57//!   - [ ] done_ratio filter
58//!     - [ ] exact match
59//!     - [ ] less than, greater than ?
60//!     - [ ] range?
61//!   - [ ] estimated_hours filter
62//!     - [ ] exact match
63//!     - [ ] less than, greater than ?
64//!     - [ ] range?
65//!   - [x] created_on filter
66//!     - [x] exact match
67//!     - [x] less than, greater than
68//!     - [x] date range
69//!   - [x] updated_on filter
70//!     - [x] exact match
71//!     - [x] less than, greater than
72//!     - [x] date range
73//!   - [x] start_date filter
74//!     - [x] exact match
75//!     - [x] less than, greater than
76//!     - [x] date range
77//!   - [x] due_date filter
78//!     - [x] exact match
79//!     - [x] less than, greater than
80//!     - [x] date range
81//! - [x] specific issue endpoint
82//! - [x] create issue endpoint
83//!   - [ ] attachments
84//! - [x] update issue endpoint
85//!   - [ ] attachments
86//! - [x] delete issue endpoint
87//! - [x] add watcher endpoint
88//! - [x] remove watcher endpoint
89//!
90use derive_builder::Builder;
91use reqwest::Method;
92use std::borrow::Cow;
93
94use crate::api::attachments::Attachment;
95use crate::api::custom_fields::CustomFieldEssentialsWithValue;
96use crate::api::enumerations::IssuePriorityEssentials;
97use crate::api::groups::{Group, GroupEssentials};
98use crate::api::issue_categories::IssueCategoryEssentials;
99use crate::api::issue_relations::IssueRelation;
100use crate::api::issue_statuses::IssueStatusEssentials;
101use crate::api::projects::ProjectEssentials;
102use crate::api::trackers::TrackerEssentials;
103use crate::api::users::UserEssentials;
104use crate::api::versions::VersionEssentials;
105use crate::api::{Endpoint, NoPagination, Pageable, QueryParams, ReturnsJsonResponse};
106use serde::Serialize;
107
108/// a minimal type for Redmine users or groups used in lists of assignees included in
109/// other Redmine objects
110#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111pub struct AssigneeEssentials {
112    /// numeric id
113    pub id: u64,
114    /// display name
115    pub name: String,
116}
117
118impl From<UserEssentials> for AssigneeEssentials {
119    fn from(v: UserEssentials) -> Self {
120        AssigneeEssentials {
121            id: v.id,
122            name: v.name,
123        }
124    }
125}
126
127impl From<&UserEssentials> for AssigneeEssentials {
128    fn from(v: &UserEssentials) -> Self {
129        AssigneeEssentials {
130            id: v.id,
131            name: v.name.to_owned(),
132        }
133    }
134}
135
136impl From<GroupEssentials> for AssigneeEssentials {
137    fn from(v: GroupEssentials) -> Self {
138        AssigneeEssentials {
139            id: v.id,
140            name: v.name,
141        }
142    }
143}
144
145impl From<&GroupEssentials> for AssigneeEssentials {
146    fn from(v: &GroupEssentials) -> Self {
147        AssigneeEssentials {
148            id: v.id,
149            name: v.name.to_owned(),
150        }
151    }
152}
153
154impl From<Group> for AssigneeEssentials {
155    fn from(v: Group) -> Self {
156        AssigneeEssentials {
157            id: v.id,
158            name: v.name,
159        }
160    }
161}
162
163impl From<&Group> for AssigneeEssentials {
164    fn from(v: &Group) -> Self {
165        AssigneeEssentials {
166            id: v.id,
167            name: v.name.to_owned(),
168        }
169    }
170}
171
172/// a minimal type for Redmine issues included in
173/// other Redmine objects
174#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
175pub struct IssueEssentials {
176    /// numeric id
177    pub id: u64,
178}
179
180impl From<Issue> for IssueEssentials {
181    fn from(v: Issue) -> Self {
182        IssueEssentials { id: v.id }
183    }
184}
185
186impl From<&Issue> for IssueEssentials {
187    fn from(v: &Issue) -> Self {
188        IssueEssentials { id: v.id }
189    }
190}
191
192/// the type of journal change
193#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
194pub enum ChangePropertyType {
195    /// issue attribute change
196    #[serde(rename = "attr")]
197    Attr,
198    /// TODO: not quite sure what cf stands for
199    #[serde(rename = "cf")]
200    Cf,
201    /// change in issue relations
202    #[serde(rename = "relation")]
203    Relation,
204    /// change in attachments
205    #[serde(rename = "attachment")]
206    Attachment,
207}
208
209/// a changed attribute entry in a journal entry
210#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
211pub struct JournalChange {
212    /// name of the attribute
213    pub name: String,
214    /// old value
215    pub old_value: Option<String>,
216    /// new value
217    pub new_value: Option<String>,
218    /// what kind of property we are dealing with
219    pub property: ChangePropertyType,
220}
221
222/// journals (issue comments and changes)
223#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
224pub struct Journal {
225    /// numeric id
226    pub id: u64,
227    /// the author of the journal entry
228    pub user: UserEssentials,
229    /// the comment content
230    pub notes: Option<String>,
231    /// is this a private comment
232    pub private_notes: bool,
233    /// The time when this comment/change was created
234    #[serde(
235        serialize_with = "crate::api::serialize_rfc3339",
236        deserialize_with = "crate::api::deserialize_rfc3339"
237    )]
238    pub created_on: time::OffsetDateTime,
239    /// The time when this comment/change was last updated
240    #[serde(
241        serialize_with = "crate::api::serialize_rfc3339",
242        deserialize_with = "crate::api::deserialize_rfc3339"
243    )]
244    pub updated_on: time::OffsetDateTime,
245    /// The user who updated the comment/change if it differs from the author
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub updated_by: Option<UserEssentials>,
248    /// changed issue attributes
249    pub details: Vec<JournalChange>,
250}
251
252/// minimal issue used e.g. in child issues
253#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
254pub struct ChildIssue {
255    /// numeric id
256    pub id: u64,
257    /// subject
258    pub subject: String,
259    /// tracker
260    pub tracker: TrackerEssentials,
261    /// children
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub children: Option<Vec<ChildIssue>>,
264}
265
266/// a type for issue to use as an API return type
267///
268/// alternatively you can use your own type limited to the fields you need
269#[derive(Debug, Clone, Serialize, serde::Deserialize)]
270pub struct Issue {
271    /// numeric id
272    pub id: u64,
273    /// parent issue
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub parent: Option<IssueEssentials>,
276    /// the project of the issue
277    pub project: ProjectEssentials,
278    /// the tracker of the issue
279    pub tracker: TrackerEssentials,
280    /// the issue status
281    pub status: IssueStatusEssentials,
282    /// the issue priority
283    pub priority: IssuePriorityEssentials,
284    /// the issue author
285    pub author: UserEssentials,
286    /// the user or group the issue is assigned to
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub assigned_to: Option<AssigneeEssentials>,
289    /// the issue category
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub category: Option<IssueCategoryEssentials>,
292    /// the version the issue is assigned to
293    #[serde(rename = "fixed_version", skip_serializing_if = "Option::is_none")]
294    pub version: Option<VersionEssentials>,
295    /// the issue subject
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub subject: Option<String>,
298    /// the issue description
299    pub description: Option<String>,
300    /// is the issue private (only visible to roles that have the relevant permission enabled)
301    is_private: Option<bool>,
302    /// the start date for the issue
303    pub start_date: Option<time::Date>,
304    /// the due date for the issue
305    pub due_date: Option<time::Date>,
306    /// the time when the issue was closed
307    #[serde(
308        serialize_with = "crate::api::serialize_optional_rfc3339",
309        deserialize_with = "crate::api::deserialize_optional_rfc3339"
310    )]
311    pub closed_on: Option<time::OffsetDateTime>,
312    /// the percentage done
313    pub done_ratio: u64,
314    /// custom fields with values
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
317    /// estimated hours it will take to implement this issue
318    pub estimated_hours: Option<f64>,
319    /// The time when this issue was created
320    #[serde(
321        serialize_with = "crate::api::serialize_rfc3339",
322        deserialize_with = "crate::api::deserialize_rfc3339"
323    )]
324    pub created_on: time::OffsetDateTime,
325    /// The time when this issue was last updated
326    #[serde(
327        serialize_with = "crate::api::serialize_rfc3339",
328        deserialize_with = "crate::api::deserialize_rfc3339"
329    )]
330    pub updated_on: time::OffsetDateTime,
331    /// issue attachments (only when include parameter is used)
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub attachments: Option<Vec<Attachment>>,
334    /// issue relations (only when include parameter is used)
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub relations: Option<Vec<IssueRelation>>,
337    /// journal entries (comments and changes), only when include parameter is used
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub journals: Option<Vec<Journal>>,
340    /// child issues (only when include parameter is used)
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub children: Option<Vec<ChildIssue>>,
343    /// watchers
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub watchers: Option<Vec<UserEssentials>>,
346    /// the hours spent
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub spent_hours: Option<f64>,
349    /// the total hours spent on this and sub-tasks
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub total_spent_hours: Option<f64>,
352    /// the total hours estimated on this and sub-tasks
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub total_estimated_hours: Option<f64>,
355}
356
357/// ways to filter for subproject
358#[derive(Debug, Clone)]
359pub enum SubProjectFilter {
360    /// return no issues from subjects
361    OnlyParentProject,
362    /// return issues from a specific list of sub project ids
363    TheseSubProjects(Vec<u64>),
364    /// return issues from any but a specific list of sub project ids
365    NotTheseSubProjects(Vec<u64>),
366}
367
368impl std::fmt::Display for SubProjectFilter {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        match self {
371            SubProjectFilter::OnlyParentProject => {
372                write!(f, "!*")
373            }
374            SubProjectFilter::TheseSubProjects(ids) => {
375                let s: String = ids
376                    .iter()
377                    .map(|e| e.to_string())
378                    .collect::<Vec<_>>()
379                    .join(",");
380                write!(f, "{s}")
381            }
382            SubProjectFilter::NotTheseSubProjects(ids) => {
383                let s: String = ids
384                    .iter()
385                    .map(|e| format!("!{e}"))
386                    .collect::<Vec<_>>()
387                    .join(",");
388                write!(f, "{s}")
389            }
390        }
391    }
392}
393
394/// ways to filter for issue status
395#[derive(Debug, Clone)]
396pub enum StatusFilter {
397    /// match all open statuses (default if no status filter is specified
398    Open,
399    /// match all closed statuses
400    Closed,
401    /// match both open and closed statuses
402    All,
403    /// match a specific list of statuses
404    TheseStatuses(Vec<u64>),
405    /// match any status but a specific list of statuses
406    NotTheseStatuses(Vec<u64>),
407}
408
409impl std::fmt::Display for StatusFilter {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        match self {
412            StatusFilter::Open => {
413                write!(f, "open")
414            }
415            StatusFilter::Closed => {
416                write!(f, "closed")
417            }
418            StatusFilter::All => {
419                write!(f, "*")
420            }
421            StatusFilter::TheseStatuses(ids) => {
422                let s: String = ids
423                    .iter()
424                    .map(|e| e.to_string())
425                    .collect::<Vec<_>>()
426                    .join(",");
427                write!(f, "{s}")
428            }
429            StatusFilter::NotTheseStatuses(ids) => {
430                let s: String = ids
431                    .iter()
432                    .map(|e| format!("!{e}"))
433                    .collect::<Vec<_>>()
434                    .join(",");
435                write!(f, "{s}")
436            }
437        }
438    }
439}
440
441/// ways to filter for users in author (always a user (not group), never !*)
442#[derive(Debug, Clone)]
443pub enum AuthorFilter {
444    /// match any user
445    AnyAuthor,
446    /// match the current API user
447    Me,
448    /// match any author but the current API user
449    NotMe,
450    /// match a specific list of users
451    TheseAuthors(Vec<u64>),
452    /// match a negated specific list of users
453    NotTheseAuthors(Vec<u64>),
454}
455
456impl std::fmt::Display for AuthorFilter {
457    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
458        match self {
459            AuthorFilter::AnyAuthor => {
460                write!(f, "*")
461            }
462            AuthorFilter::Me => {
463                write!(f, "me")
464            }
465            AuthorFilter::NotMe => {
466                write!(f, "!me")
467            }
468            AuthorFilter::TheseAuthors(ids) => {
469                let s: String = ids
470                    .iter()
471                    .map(|e| e.to_string())
472                    .collect::<Vec<_>>()
473                    .join(",");
474                write!(f, "{s}")
475            }
476            AuthorFilter::NotTheseAuthors(ids) => {
477                let s: String = ids
478                    .iter()
479                    .map(|e| format!("!{e}"))
480                    .collect::<Vec<_>>()
481                    .join(",");
482                write!(f, "{s}")
483            }
484        }
485    }
486}
487
488/// ways to filter for users or groups in assignee
489#[derive(Debug, Clone)]
490pub enum AssigneeFilter {
491    /// match any user or group
492    AnyAssignee,
493    /// match the current API user
494    Me,
495    /// match any assignee but the current API user
496    NotMe,
497    /// match a specific list of users or groups
498    TheseAssignees(Vec<u64>),
499    /// match a negated specific list of users or groups
500    NotTheseAssignees(Vec<u64>),
501    /// match unassigned
502    NoAssignee,
503}
504
505impl std::fmt::Display for AssigneeFilter {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        match self {
508            AssigneeFilter::AnyAssignee => {
509                write!(f, "*")
510            }
511            AssigneeFilter::Me => {
512                write!(f, "me")
513            }
514            AssigneeFilter::NotMe => {
515                write!(f, "!me")
516            }
517            AssigneeFilter::TheseAssignees(ids) => {
518                let s: String = ids
519                    .iter()
520                    .map(|e| e.to_string())
521                    .collect::<Vec<_>>()
522                    .join(",");
523                write!(f, "{s}")
524            }
525            AssigneeFilter::NotTheseAssignees(ids) => {
526                let s: String = ids
527                    .iter()
528                    .map(|e| format!("!{e}"))
529                    .collect::<Vec<_>>()
530                    .join(",");
531                write!(f, "{s}")
532            }
533            AssigneeFilter::NoAssignee => {
534                write!(f, "!*")
535            }
536        }
537    }
538}
539
540/// Filter options for subject and description
541#[derive(Debug, Clone)]
542pub enum StringFieldFilter {
543    /// match exactly this value
544    ExactMatch(String),
545    /// match this substring of the actual value
546    SubStringMatch(String),
547}
548
549impl std::fmt::Display for StringFieldFilter {
550    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
551        match self {
552            StringFieldFilter::ExactMatch(s) => {
553                write!(f, "{s}")
554            }
555            StringFieldFilter::SubStringMatch(s) => {
556                write!(f, "~{s}")
557            }
558        }
559    }
560}
561
562/// a trait for comparable filter values, we do not just use Display because
563/// one of our main application is dates and we need a specific format
564pub trait ComparableFilterValue {
565    /// returns a string representation of a single value (e.g. a date)
566    /// to be combined with the comparison operators by the Display trait of
567    /// [ComparableFilter]
568    fn value_string(&self) -> Cow<'static, str>;
569}
570
571impl ComparableFilterValue for time::Date {
572    fn value_string(&self) -> Cow<'static, str> {
573        let format = time::format_description::parse("[year]-[month]-[day]").unwrap();
574        self.format(&format).unwrap().into()
575    }
576}
577
578impl ComparableFilterValue for time::OffsetDateTime {
579    fn value_string(&self) -> Cow<'static, str> {
580        self.format(&time::format_description::well_known::Rfc3339)
581            .unwrap()
582            .into()
583    }
584}
585
586/// Filter for a comparable filter (those you can use ranges, less, greater,...) on
587#[derive(Debug, Clone)]
588pub enum ComparableFilter<V> {
589    /// an exact match
590    ExactMatch(V),
591    /// a range match
592    Range(V, V),
593    /// we only want values less than the parameter
594    LessThan(V),
595    /// we only want values less than or equal to the parameter
596    LessThanOrEqual(V),
597    /// we only want values greater than the parameter
598    GreaterThan(V),
599    /// we only want values greater than or equal to the parameter
600    GreaterThanOrEqual(V),
601}
602
603impl<V> std::fmt::Display for ComparableFilter<V>
604where
605    V: ComparableFilterValue,
606{
607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608        match self {
609            ComparableFilter::ExactMatch(v) => {
610                write!(f, "{}", v.value_string())
611            }
612            ComparableFilter::Range(v_start, v_end) => {
613                write!(f, "><{}|{}", v_start.value_string(), v_end.value_string())
614            }
615            ComparableFilter::LessThan(v) => {
616                write!(f, "<{}", v.value_string())
617            }
618            ComparableFilter::LessThanOrEqual(v) => {
619                write!(f, "<={}", v.value_string())
620            }
621            ComparableFilter::GreaterThan(v) => {
622                write!(f, ">{}", v.value_string())
623            }
624            ComparableFilter::GreaterThanOrEqual(v) => {
625                write!(f, ">={}", v.value_string())
626            }
627        }
628    }
629}
630
631/// Sort by this column
632#[derive(Debug, Clone)]
633pub enum SortByColumn {
634    /// Sort in an ascending direction
635    Forward {
636        /// the column to sort by
637        column_name: String,
638    },
639    /// Sort in a descending direction
640    Reverse {
641        /// the column to sort by
642        column_name: String,
643    },
644}
645
646impl std::fmt::Display for SortByColumn {
647    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648        match self {
649            SortByColumn::Forward { column_name } => {
650                write!(f, "{column_name}")
651            }
652            SortByColumn::Reverse { column_name } => {
653                write!(f, "{column_name}:desc")
654            }
655        }
656    }
657}
658
659/// The types of associated data which can be fetched along with a issue
660#[derive(Debug, Clone)]
661pub enum IssueListInclude {
662    /// Issue Attachments
663    Attachments,
664    /// Issue relations
665    Relations,
666}
667
668impl std::fmt::Display for IssueListInclude {
669    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
670        match self {
671            Self::Attachments => {
672                write!(f, "attachments")
673            }
674            Self::Relations => {
675                write!(f, "relations")
676            }
677        }
678    }
679}
680
681/// The endpoint for all Redmine issues
682#[derive(Debug, Clone, Builder)]
683#[builder(setter(strip_option))]
684pub struct ListIssues {
685    /// Include associated data
686    #[builder(default)]
687    include: Option<Vec<IssueListInclude>>,
688    /// Sort by column
689    #[builder(default)]
690    sort: Option<Vec<SortByColumn>>,
691    /// Filter by issue id(s)
692    #[builder(default)]
693    issue_id: Option<Vec<u64>>,
694    /// Filter by project id
695    #[builder(default)]
696    project_id: Option<Vec<u64>>,
697    /// Filter by subproject
698    #[builder(default)]
699    subproject_id: Option<SubProjectFilter>,
700    /// Filter by tracker id
701    #[builder(default)]
702    tracker_id: Option<Vec<u64>>,
703    /// Filter by priority id
704    #[builder(default)]
705    priority_id: Option<Vec<u64>>,
706    /// Filter by parent issue id
707    #[builder(default)]
708    parent_id: Option<Vec<u64>>,
709    /// Filter by issue category id
710    #[builder(default)]
711    category_id: Option<Vec<u64>>,
712    /// Filter by issue status
713    #[builder(default)]
714    status_id: Option<StatusFilter>,
715    /// Filter by subject
716    #[builder(default)]
717    subject: Option<StringFieldFilter>,
718    /// Filter by description
719    #[builder(default)]
720    description: Option<StringFieldFilter>,
721    /// Filter by author
722    #[builder(default)]
723    author: Option<AuthorFilter>,
724    /// Filter by assignee
725    #[builder(default)]
726    assignee: Option<AssigneeFilter>,
727    /// Filter by a saved query
728    #[builder(default)]
729    query_id: Option<u64>,
730    /// Filter by target version
731    #[builder(default)]
732    version_id: Option<Vec<u64>>,
733    /// Filter by creation time
734    #[builder(default)]
735    created_on: Option<ComparableFilter<time::OffsetDateTime>>,
736    /// Filter by update time
737    #[builder(default)]
738    updated_on: Option<ComparableFilter<time::OffsetDateTime>>,
739    /// Filter by start date
740    #[builder(default)]
741    start_date: Option<ComparableFilter<time::Date>>,
742    /// Filter by due date
743    #[builder(default)]
744    due_date: Option<ComparableFilter<time::Date>>,
745}
746
747impl ReturnsJsonResponse for ListIssues {}
748
749impl Pageable for ListIssues {
750    fn response_wrapper_key(&self) -> String {
751        "issues".to_string()
752    }
753}
754
755impl ListIssues {
756    /// Create a builder for the endpoint.
757    #[must_use]
758    pub fn builder() -> ListIssuesBuilder {
759        ListIssuesBuilder::default()
760    }
761}
762
763impl Endpoint for ListIssues {
764    fn method(&self) -> Method {
765        Method::GET
766    }
767
768    fn endpoint(&self) -> Cow<'static, str> {
769        "issues.json".into()
770    }
771
772    fn parameters(&self) -> QueryParams {
773        let mut params = QueryParams::default();
774        params.push_opt("include", self.include.as_ref());
775        params.push_opt("sort", self.sort.as_ref());
776        params.push_opt("issue_id", self.issue_id.as_ref());
777        params.push_opt("project_id", self.project_id.as_ref());
778        params.push_opt(
779            "subproject_id",
780            self.subproject_id.as_ref().map(|s| s.to_string()),
781        );
782        params.push_opt("tracker_id", self.tracker_id.as_ref());
783        params.push_opt("priority_id", self.priority_id.as_ref());
784        params.push_opt("parent_id", self.parent_id.as_ref());
785        params.push_opt("category_id", self.category_id.as_ref());
786        params.push_opt("status_id", self.status_id.as_ref().map(|s| s.to_string()));
787        params.push_opt("subject", self.subject.as_ref().map(|s| s.to_string()));
788        params.push_opt(
789            "description",
790            self.description.as_ref().map(|s| s.to_string()),
791        );
792        params.push_opt("author_id", self.author.as_ref().map(|s| s.to_string()));
793        params.push_opt(
794            "assigned_to_id",
795            self.assignee.as_ref().map(|s| s.to_string()),
796        );
797        params.push_opt("query_id", self.query_id);
798        params.push_opt("fixed_version_id", self.version_id.as_ref());
799        params.push_opt(
800            "created_on",
801            self.created_on.as_ref().map(|s| s.to_string()),
802        );
803        params.push_opt(
804            "updated_on",
805            self.updated_on.as_ref().map(|s| s.to_string()),
806        );
807        params.push_opt(
808            "start_date",
809            self.start_date.as_ref().map(|s| s.to_string()),
810        );
811        params.push_opt("due_date", self.due_date.as_ref().map(|s| s.to_string()));
812        params
813    }
814}
815
816/// The types of associated data which can be fetched along with a issue
817#[derive(Debug, Clone)]
818pub enum IssueInclude {
819    /// Child issues
820    Children,
821    /// Issue attachments
822    Attachments,
823    /// Issue relations
824    Relations,
825    /// VCS changesets
826    Changesets,
827    /// the notes and changes to the issue
828    Journals,
829    /// Users watching the issue
830    Watchers,
831    /// The statuses this issue can transition to
832    ///
833    /// This can be influenced by
834    ///
835    /// - the defined workflow
836    ///   - the issue's current tracker
837    ///   - the issue's current status
838    ///   - the member's role
839    /// - the existence of any open subtask(s)
840    /// - the existence of any open blocking issue(s)
841    /// - the existence of a closed parent issue
842    ///
843    AllowedStatuses,
844}
845
846impl std::fmt::Display for IssueInclude {
847    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848        match self {
849            Self::Children => {
850                write!(f, "children")
851            }
852            Self::Attachments => {
853                write!(f, "attachments")
854            }
855            Self::Relations => {
856                write!(f, "relations")
857            }
858            Self::Changesets => {
859                write!(f, "relations")
860            }
861            Self::Journals => {
862                write!(f, "journals")
863            }
864            Self::Watchers => {
865                write!(f, "watchers")
866            }
867            Self::AllowedStatuses => {
868                write!(f, "allowed_statuses")
869            }
870        }
871    }
872}
873
874/// The endpoint for a specific Redmine issue
875#[derive(Debug, Clone, Builder)]
876#[builder(setter(strip_option))]
877pub struct GetIssue {
878    /// id of the issue to retrieve
879    id: u64,
880    /// associated data to include
881    #[builder(default)]
882    include: Option<Vec<IssueInclude>>,
883}
884
885impl ReturnsJsonResponse for GetIssue {}
886impl NoPagination for GetIssue {}
887
888impl GetIssue {
889    /// Create a builder for the endpoint.
890    #[must_use]
891    pub fn builder() -> GetIssueBuilder {
892        GetIssueBuilder::default()
893    }
894}
895
896impl Endpoint for GetIssue {
897    fn method(&self) -> Method {
898        Method::GET
899    }
900
901    fn endpoint(&self) -> Cow<'static, str> {
902        format!("issues/{}.json", &self.id).into()
903    }
904
905    fn parameters(&self) -> QueryParams {
906        let mut params = QueryParams::default();
907        params.push_opt("include", self.include.as_ref());
908        params
909    }
910}
911
912/// a custom field
913#[derive(Debug, Clone, Serialize, serde::Deserialize)]
914pub struct CustomField<'a> {
915    /// the custom field's id
916    pub id: u64,
917    /// is usually present in contexts where it is returned by Redmine but can be omitted when it is sent by the client
918    pub name: Option<Cow<'a, str>>,
919    /// the custom field's value
920    pub value: Cow<'a, str>,
921}
922
923/// the information the uploader needs to supply for an attachment
924/// in [CreateIssue] or [UpdateIssue]
925#[derive(Debug, Clone, Serialize)]
926pub struct UploadedAttachment<'a> {
927    /// the upload token from [UploadFile|crate::api::uploads::UploadFile]
928    pub token: Cow<'a, str>,
929    /// the filename
930    pub filename: Cow<'a, str>,
931    /// a description for the file
932    #[serde(skip_serializing_if = "Option::is_none")]
933    pub description: Option<Cow<'a, str>>,
934    /// the MIME content type of the file
935    pub content_type: Cow<'a, str>,
936}
937
938/// The endpoint to create a Redmine issue
939#[serde_with::skip_serializing_none]
940#[derive(Debug, Clone, Builder, Serialize)]
941#[builder(setter(strip_option))]
942pub struct CreateIssue<'a> {
943    /// project for the issue
944    project_id: u64,
945    /// tracker for the issue
946    #[builder(default)]
947    tracker_id: Option<u64>,
948    /// status of the issue
949    #[builder(default)]
950    status_id: Option<u64>,
951    /// issue priority
952    #[builder(default)]
953    priority_id: Option<u64>,
954    /// issue subject
955    #[builder(setter(into), default)]
956    subject: Option<Cow<'a, str>>,
957    /// issue description
958    #[builder(setter(into), default)]
959    description: Option<Cow<'a, str>>,
960    /// issue category
961    #[builder(default)]
962    category_id: Option<u64>,
963    /// ID of the Target Versions (previously called 'Fixed Version' and still referred to as such in the API)
964    #[builder(default, setter(name = "version"))]
965    fixed_version_id: Option<u64>,
966    /// user/group id the issue will be assigned to
967    #[builder(default)]
968    assigned_to_id: Option<u64>,
969    /// Id of the parent issue
970    #[builder(default)]
971    parent_issue_id: Option<u64>,
972    /// custom field values
973    #[builder(default)]
974    custom_fields: Option<Vec<CustomField<'a>>>,
975    /// user ids of watchers of the issue
976    #[builder(default)]
977    watcher_user_ids: Option<Vec<u64>>,
978    /// is the issue private (only visible to roles that have the relevant permission enabled)
979    #[builder(default)]
980    is_private: Option<bool>,
981    /// estimated hours it will take to implement this issue
982    #[builder(default)]
983    estimated_hours: Option<f64>,
984    /// attachments (files)
985    #[builder(default)]
986    uploads: Option<Vec<UploadedAttachment<'a>>>,
987}
988
989impl<'a> CreateIssue<'a> {
990    /// Create a builder for the endpoint.
991    #[must_use]
992    pub fn builder() -> CreateIssueBuilder<'a> {
993        CreateIssueBuilder::default()
994    }
995}
996
997impl ReturnsJsonResponse for CreateIssue<'_> {}
998impl NoPagination for CreateIssue<'_> {}
999
1000impl Endpoint for CreateIssue<'_> {
1001    fn method(&self) -> Method {
1002        Method::POST
1003    }
1004
1005    fn endpoint(&self) -> Cow<'static, str> {
1006        "issues.json".into()
1007    }
1008
1009    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1010        Ok(Some((
1011            "application/json",
1012            serde_json::to_vec(&IssueWrapper::<CreateIssue> {
1013                issue: (*self).to_owned(),
1014            })?,
1015        )))
1016    }
1017}
1018
1019/// The endpoint to update an existing Redmine issue
1020#[serde_with::skip_serializing_none]
1021#[derive(Debug, Clone, Builder, Serialize)]
1022#[builder(setter(strip_option))]
1023pub struct UpdateIssue<'a> {
1024    /// id of the issue to update
1025    #[serde(skip_serializing)]
1026    id: u64,
1027    /// project for the issue
1028    #[builder(default)]
1029    project_id: Option<u64>,
1030    /// tracker for the issue
1031    #[builder(default)]
1032    tracker_id: Option<u64>,
1033    /// status of the issue
1034    #[builder(default)]
1035    status_id: Option<u64>,
1036    /// issue priority
1037    #[builder(default)]
1038    priority_id: Option<u64>,
1039    /// issue subject
1040    #[builder(setter(into), default)]
1041    subject: Option<Cow<'a, str>>,
1042    /// issue description
1043    #[builder(setter(into), default)]
1044    description: Option<Cow<'a, str>>,
1045    /// issue category
1046    #[builder(default)]
1047    category_id: Option<u64>,
1048    /// ID of the Target Versions (previously called 'Fixed Version' and still referred to as such in the API)
1049    #[builder(default, setter(name = "version"))]
1050    fixed_version_id: Option<u64>,
1051    /// user/group id the issue will be assigned to
1052    #[builder(default)]
1053    assigned_to_id: Option<u64>,
1054    /// Id of the parent issue
1055    #[builder(default)]
1056    parent_issue_id: Option<u64>,
1057    /// custom field values
1058    #[builder(default)]
1059    custom_fields: Option<Vec<CustomField<'a>>>,
1060    /// user ids of watchers of the issue
1061    #[builder(default)]
1062    watcher_user_ids: Option<Vec<u64>>,
1063    /// is the issue private (only visible to roles that have the relevant permission enabled)
1064    #[builder(default)]
1065    is_private: Option<bool>,
1066    /// estimated hours it will take to implement this issue
1067    #[builder(default)]
1068    estimated_hours: Option<f64>,
1069    /// add a comment (note)
1070    #[builder(default)]
1071    notes: Option<Cow<'a, str>>,
1072    /// is the added comment/note private
1073    #[builder(default)]
1074    private_notes: Option<bool>,
1075    /// attachments (files)
1076    #[builder(default)]
1077    uploads: Option<Vec<UploadedAttachment<'a>>>,
1078}
1079
1080impl<'a> UpdateIssue<'a> {
1081    /// Create a builder for the endpoint.
1082    #[must_use]
1083    pub fn builder() -> UpdateIssueBuilder<'a> {
1084        UpdateIssueBuilder::default()
1085    }
1086}
1087
1088impl Endpoint for UpdateIssue<'_> {
1089    fn method(&self) -> Method {
1090        Method::PUT
1091    }
1092
1093    fn endpoint(&self) -> Cow<'static, str> {
1094        format!("issues/{}.json", self.id).into()
1095    }
1096
1097    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1098        Ok(Some((
1099            "application/json",
1100            serde_json::to_vec(&IssueWrapper::<UpdateIssue> {
1101                issue: (*self).to_owned(),
1102            })?,
1103        )))
1104    }
1105}
1106
1107/// The endpoint to delete a Redmine issue
1108#[derive(Debug, Clone, Builder)]
1109#[builder(setter(strip_option))]
1110pub struct DeleteIssue {
1111    /// id of the issue to delete
1112    id: u64,
1113}
1114
1115impl DeleteIssue {
1116    /// Create a builder for the endpoint.
1117    #[must_use]
1118    pub fn builder() -> DeleteIssueBuilder {
1119        DeleteIssueBuilder::default()
1120    }
1121}
1122
1123impl Endpoint for DeleteIssue {
1124    fn method(&self) -> Method {
1125        Method::DELETE
1126    }
1127
1128    fn endpoint(&self) -> Cow<'static, str> {
1129        format!("issues/{}.json", &self.id).into()
1130    }
1131}
1132
1133/// The endpoint to add a Redmine user as a watcher on a Redmine issue
1134#[derive(Debug, Clone, Builder, Serialize)]
1135#[builder(setter(strip_option))]
1136pub struct AddWatcher {
1137    /// id of the issue to add the watcher to
1138    #[serde(skip_serializing)]
1139    issue_id: u64,
1140    /// id of the user to add as a watcher
1141    user_id: u64,
1142}
1143
1144impl AddWatcher {
1145    /// Create a builder for the endpoint.
1146    #[must_use]
1147    pub fn builder() -> AddWatcherBuilder {
1148        AddWatcherBuilder::default()
1149    }
1150}
1151
1152impl Endpoint for AddWatcher {
1153    fn method(&self) -> Method {
1154        Method::POST
1155    }
1156
1157    fn endpoint(&self) -> Cow<'static, str> {
1158        format!("issues/{}/watchers.json", &self.issue_id).into()
1159    }
1160
1161    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1162        Ok(Some(("application/json", serde_json::to_vec(self)?)))
1163    }
1164}
1165
1166/// The endpoint to remove a Redmine user from a Redmine issue as a watcher
1167#[derive(Debug, Clone, Builder)]
1168#[builder(setter(strip_option))]
1169pub struct RemoveWatcher {
1170    /// id of the issue to remove the watcher from
1171    issue_id: u64,
1172    /// id of the user to remove as a watcher
1173    user_id: u64,
1174}
1175
1176impl RemoveWatcher {
1177    /// Create a builder for the endpoint.
1178    #[must_use]
1179    pub fn builder() -> RemoveWatcherBuilder {
1180        RemoveWatcherBuilder::default()
1181    }
1182}
1183
1184impl Endpoint for RemoveWatcher {
1185    fn method(&self) -> Method {
1186        Method::DELETE
1187    }
1188
1189    fn endpoint(&self) -> Cow<'static, str> {
1190        format!("issues/{}/watchers/{}.json", &self.issue_id, &self.user_id).into()
1191    }
1192}
1193
1194/// helper struct for outer layers with a issues field holding the inner data
1195#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1196pub struct IssuesWrapper<T> {
1197    /// to parse JSON with issues key
1198    pub issues: Vec<T>,
1199}
1200
1201/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
1202/// helper struct for outer layers with a issue field holding the inner data
1203#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1204pub struct IssueWrapper<T> {
1205    /// to parse JSON with an issue key
1206    pub issue: T,
1207}
1208
1209#[cfg(test)]
1210pub(crate) mod test {
1211    use super::*;
1212    use crate::api::test_helpers::with_project;
1213    use crate::api::ResponsePage;
1214    use pretty_assertions::assert_eq;
1215    use std::error::Error;
1216    use tokio::sync::RwLock;
1217    use tracing_test::traced_test;
1218
1219    /// needed so we do not get 404s when listing while
1220    /// creating/deleting or creating/updating/deleting
1221    pub static ISSUES_LOCK: RwLock<()> = RwLock::const_new(());
1222
1223    #[traced_test]
1224    #[test]
1225    fn test_list_issues_first_page() -> Result<(), Box<dyn Error>> {
1226        let _r_issues = ISSUES_LOCK.read();
1227        dotenvy::dotenv()?;
1228        let redmine = crate::api::Redmine::from_env(
1229            reqwest::blocking::Client::builder()
1230                .use_rustls_tls()
1231                .build()?,
1232        )?;
1233        let endpoint = ListIssues::builder().build()?;
1234        redmine.json_response_body_page::<_, Issue>(&endpoint, 0, 25)?;
1235        Ok(())
1236    }
1237
1238    /// this version of the test will load all pages of issues which means it
1239    /// can take a while (a minute or more) so you need to use --include-ignored
1240    /// or --ignored to run it
1241    #[traced_test]
1242    #[test]
1243    #[ignore]
1244    fn test_list_issues_all_pages() -> Result<(), Box<dyn Error>> {
1245        let _r_issues = ISSUES_LOCK.read();
1246        dotenvy::dotenv()?;
1247        let redmine = crate::api::Redmine::from_env(
1248            reqwest::blocking::Client::builder()
1249                .use_rustls_tls()
1250                .build()?,
1251        )?;
1252        let endpoint = ListIssues::builder().build()?;
1253        redmine.json_response_body_all_pages::<_, Issue>(&endpoint)?;
1254        Ok(())
1255    }
1256
1257    #[traced_test]
1258    #[test]
1259    fn test_get_issue() -> Result<(), Box<dyn Error>> {
1260        let _r_issues = ISSUES_LOCK.read();
1261        dotenvy::dotenv()?;
1262        let redmine = crate::api::Redmine::from_env(
1263            reqwest::blocking::Client::builder()
1264                .use_rustls_tls()
1265                .build()?,
1266        )?;
1267        let endpoint = GetIssue::builder().id(40000).build()?;
1268        redmine.json_response_body::<_, IssueWrapper<Issue>>(&endpoint)?;
1269        Ok(())
1270    }
1271
1272    #[function_name::named]
1273    #[traced_test]
1274    #[test]
1275    fn test_create_issue() -> Result<(), Box<dyn Error>> {
1276        let _w_issues = ISSUES_LOCK.write();
1277        let name = format!("unittest_{}", function_name!());
1278        with_project(&name, |redmine, project_id, _| {
1279            let create_endpoint = super::CreateIssue::builder()
1280                .project_id(project_id)
1281                .subject("old test subject")
1282                .build()?;
1283            redmine.json_response_body::<_, IssueWrapper<Issue>>(&create_endpoint)?;
1284            Ok(())
1285        })?;
1286        Ok(())
1287    }
1288
1289    #[function_name::named]
1290    #[traced_test]
1291    #[test]
1292    fn test_update_issue() -> Result<(), Box<dyn Error>> {
1293        let _w_issues = ISSUES_LOCK.write();
1294        let name = format!("unittest_{}", function_name!());
1295        with_project(&name, |redmine, project_id, _name| {
1296            let create_endpoint = super::CreateIssue::builder()
1297                .project_id(project_id)
1298                .subject("old test subject")
1299                .build()?;
1300            let IssueWrapper { issue }: IssueWrapper<Issue> =
1301                redmine.json_response_body::<_, _>(&create_endpoint)?;
1302            let update_endpoint = super::UpdateIssue::builder()
1303                .id(issue.id)
1304                .subject("New test subject")
1305                .build()?;
1306            redmine.ignore_response_body::<_>(&update_endpoint)?;
1307            Ok(())
1308        })?;
1309        Ok(())
1310    }
1311
1312    #[function_name::named]
1313    #[traced_test]
1314    #[test]
1315    fn test_delete_issue() -> Result<(), Box<dyn Error>> {
1316        let _w_issues = ISSUES_LOCK.write();
1317        let name = format!("unittest_{}", function_name!());
1318        with_project(&name, |redmine, project_id, _name| {
1319            let create_endpoint = super::CreateIssue::builder()
1320                .project_id(project_id)
1321                .subject("test subject")
1322                .build()?;
1323            let IssueWrapper { issue }: IssueWrapper<Issue> =
1324                redmine.json_response_body::<_, _>(&create_endpoint)?;
1325            let delete_endpoint = super::DeleteIssue::builder().id(issue.id).build()?;
1326            redmine.ignore_response_body::<_>(&delete_endpoint)?;
1327            Ok(())
1328        })?;
1329        Ok(())
1330    }
1331
1332    /// this tests if any of the results contain a field we are not deserializing
1333    ///
1334    /// this will only catch fields we missed if they are part of the response but
1335    /// it is better than nothing
1336    #[traced_test]
1337    #[test]
1338    fn test_completeness_issue_type_first_page() -> Result<(), Box<dyn Error>> {
1339        let _r_issues = ISSUES_LOCK.read();
1340        dotenvy::dotenv()?;
1341        let redmine = crate::api::Redmine::from_env(
1342            reqwest::blocking::Client::builder()
1343                .use_rustls_tls()
1344                .build()?,
1345        )?;
1346        let endpoint = ListIssues::builder()
1347            .include(vec![
1348                IssueListInclude::Attachments,
1349                IssueListInclude::Relations,
1350            ])
1351            .build()?;
1352        let ResponsePage {
1353            values,
1354            total_count: _,
1355            offset: _,
1356            limit: _,
1357        } = redmine.json_response_body_page::<_, serde_json::Value>(&endpoint, 0, 100)?;
1358        for value in values {
1359            let o: Issue = serde_json::from_value(value.clone())?;
1360            let reserialized = serde_json::to_value(o)?;
1361            assert_eq!(value, reserialized);
1362        }
1363        Ok(())
1364    }
1365
1366    /// this tests if any of the results contain a field we are not deserializing
1367    ///
1368    /// this will only catch fields we missed if they are part of the response but
1369    /// it is better than nothing
1370    ///
1371    /// this version of the test will load all pages of issues which means it
1372    /// can take a while (a minute or more) so you need to use --include-ignored
1373    /// or --ignored to run it
1374    #[traced_test]
1375    #[test]
1376    #[ignore]
1377    fn test_completeness_issue_type_all_pages() -> Result<(), Box<dyn Error>> {
1378        let _r_issues = ISSUES_LOCK.read();
1379        dotenvy::dotenv()?;
1380        let redmine = crate::api::Redmine::from_env(
1381            reqwest::blocking::Client::builder()
1382                .use_rustls_tls()
1383                .build()?,
1384        )?;
1385        let endpoint = ListIssues::builder()
1386            .include(vec![
1387                IssueListInclude::Attachments,
1388                IssueListInclude::Relations,
1389            ])
1390            .build()?;
1391        let values = redmine.json_response_body_all_pages::<_, serde_json::Value>(&endpoint)?;
1392        for value in values {
1393            let o: Issue = serde_json::from_value(value.clone())?;
1394            let reserialized = serde_json::to_value(o)?;
1395            assert_eq!(value, reserialized);
1396        }
1397        Ok(())
1398    }
1399
1400    /// this tests if any of the results contain a field we are not deserializing
1401    ///
1402    /// this will only catch fields we missed if they are part of the response but
1403    /// it is better than nothing
1404    ///
1405    /// this version of the test will load all pages of issues and the individual
1406    /// issues for each via GetIssue which means it
1407    /// can take a while (about 400 seconds) so you need to use --include-ignored
1408    /// or --ignored to run it
1409    #[traced_test]
1410    #[test]
1411    #[ignore]
1412    fn test_completeness_issue_type_all_pages_all_issue_details() -> Result<(), Box<dyn Error>> {
1413        let _r_issues = ISSUES_LOCK.read();
1414        dotenvy::dotenv()?;
1415        let redmine = crate::api::Redmine::from_env(
1416            reqwest::blocking::Client::builder()
1417                .use_rustls_tls()
1418                .build()?,
1419        )?;
1420        let endpoint = ListIssues::builder()
1421            .include(vec![
1422                IssueListInclude::Attachments,
1423                IssueListInclude::Relations,
1424            ])
1425            .build()?;
1426        let issues = redmine.json_response_body_all_pages::<_, Issue>(&endpoint)?;
1427        for issue in issues {
1428            let get_endpoint = GetIssue::builder()
1429                .id(issue.id)
1430                .include(vec![
1431                    IssueInclude::Attachments,
1432                    IssueInclude::Children,
1433                    IssueInclude::Changesets,
1434                    IssueInclude::Relations,
1435                    IssueInclude::Journals,
1436                    IssueInclude::Watchers,
1437                ])
1438                .build()?;
1439            let IssueWrapper { issue: mut value } =
1440                redmine.json_response_body::<_, IssueWrapper<serde_json::Value>>(&get_endpoint)?;
1441            let o: Issue = serde_json::from_value(value.clone())?;
1442            // workaround for the fact that the field total_estimated_hours is put into the result
1443            // when its null in the GetIssue endpoint but not in the ListIssues one
1444            // we can only do one or the other in our JSON serialization unless we want to add
1445            // extra fields just to keep track of the missing field vs. field with null value
1446            // difference
1447            let value_object = value.as_object_mut().unwrap();
1448            if value_object.get("total_estimated_hours") == Some(&serde_json::Value::Null) {
1449                value_object.remove("total_estimated_hours");
1450            }
1451            let reserialized = serde_json::to_value(o)?;
1452            assert_eq!(value, reserialized);
1453        }
1454        Ok(())
1455    }
1456}