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