1use 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
112pub struct AssigneeEssentials {
113 pub id: u64,
115 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub struct IssueEssentials {
177 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
196pub struct RepositoryEssentials {
197 pub id: u64,
199 pub identifier: String,
201}
202
203#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
205pub struct IssueChangeset {
206 revision: String,
208 user: UserEssentials,
210 comments: String,
212 #[serde(
214 serialize_with = "crate::api::serialize_rfc3339",
215 deserialize_with = "crate::api::deserialize_rfc3339"
216 )]
217 committed_on: time::OffsetDateTime,
218}
219
220#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
222pub enum ChangePropertyType {
223 #[serde(rename = "attr")]
225 Attr,
226 #[serde(rename = "cf")]
228 Cf,
229 #[serde(rename = "relation")]
231 Relation,
232 #[serde(rename = "attachment")]
234 Attachment,
235}
236
237#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
239pub struct JournalChange {
240 pub name: String,
242 pub old_value: Option<String>,
244 pub new_value: Option<String>,
246 pub property: ChangePropertyType,
248}
249
250#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
252pub struct Journal {
253 pub id: u64,
255 pub user: UserEssentials,
257 pub notes: Option<String>,
259 pub private_notes: bool,
261 #[serde(
263 serialize_with = "crate::api::serialize_rfc3339",
264 deserialize_with = "crate::api::deserialize_rfc3339"
265 )]
266 pub created_on: time::OffsetDateTime,
267 #[serde(
269 serialize_with = "crate::api::serialize_rfc3339",
270 deserialize_with = "crate::api::deserialize_rfc3339"
271 )]
272 pub updated_on: time::OffsetDateTime,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub updated_by: Option<UserEssentials>,
276 pub details: Vec<JournalChange>,
278}
279
280#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
282pub struct ChildIssue {
283 pub id: u64,
285 pub subject: String,
287 pub tracker: TrackerEssentials,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub children: Option<Vec<ChildIssue>>,
292}
293
294#[derive(Debug, Clone, Serialize, serde::Deserialize)]
298pub struct Issue {
299 pub id: u64,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub parent: Option<IssueEssentials>,
304 pub project: ProjectEssentials,
306 pub tracker: TrackerEssentials,
308 pub status: IssueStatusEssentials,
310 pub priority: IssuePriorityEssentials,
312 pub author: UserEssentials,
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub assigned_to: Option<AssigneeEssentials>,
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub category: Option<IssueCategoryEssentials>,
320 #[serde(rename = "fixed_version", skip_serializing_if = "Option::is_none")]
322 pub version: Option<VersionEssentials>,
323 #[serde(skip_serializing_if = "Option::is_none")]
325 pub subject: Option<String>,
326 pub description: Option<String>,
328 is_private: Option<bool>,
330 pub start_date: Option<time::Date>,
332 pub due_date: Option<time::Date>,
334 #[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 pub done_ratio: u64,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
345 pub estimated_hours: Option<f64>,
347 #[serde(
349 serialize_with = "crate::api::serialize_rfc3339",
350 deserialize_with = "crate::api::deserialize_rfc3339"
351 )]
352 pub created_on: time::OffsetDateTime,
353 #[serde(
355 serialize_with = "crate::api::serialize_rfc3339",
356 deserialize_with = "crate::api::deserialize_rfc3339"
357 )]
358 pub updated_on: time::OffsetDateTime,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub attachments: Option<Vec<Attachment>>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub relations: Option<Vec<IssueRelation>>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub changesets: Option<Vec<IssueChangeset>>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub journals: Option<Vec<Journal>>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub children: Option<Vec<ChildIssue>>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub watchers: Option<Vec<UserEssentials>>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub spent_hours: Option<f64>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub total_spent_hours: Option<f64>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub total_estimated_hours: Option<f64>,
386}
387
388#[derive(Debug, Clone)]
390pub enum SubProjectFilter {
391 OnlyParentProject,
393 TheseSubProjects(Vec<u64>),
395 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#[derive(Debug, Clone)]
427pub enum StatusFilter {
428 Open,
430 Closed,
432 All,
434 TheseStatuses(Vec<u64>),
436 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#[derive(Debug, Clone)]
474pub enum AuthorFilter {
475 AnyAuthor,
477 Me,
479 NotMe,
481 TheseAuthors(Vec<u64>),
483 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#[derive(Debug, Clone)]
521pub enum AssigneeFilter {
522 AnyAssignee,
524 Me,
526 NotMe,
528 TheseAssignees(Vec<u64>),
530 NotTheseAssignees(Vec<u64>),
532 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#[derive(Debug, Clone)]
573pub enum StringFieldFilter {
574 ExactMatch(String),
576 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
593pub trait ComparableFilterValue {
596 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#[derive(Debug, Clone)]
619pub enum ComparableFilter<V> {
620 ExactMatch(V),
622 Range(V, V),
624 LessThan(V),
626 LessThanOrEqual(V),
628 GreaterThan(V),
630 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#[derive(Debug, Clone)]
664pub enum SortByColumn {
665 Forward {
667 column_name: String,
669 },
670 Reverse {
672 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#[derive(Debug, Clone)]
692pub enum IssueListInclude {
693 Attachments,
695 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#[derive(Debug, Clone, Builder)]
714#[builder(setter(strip_option))]
715pub struct ListIssues {
716 #[builder(default)]
718 include: Option<Vec<IssueListInclude>>,
719 #[builder(default)]
721 sort: Option<Vec<SortByColumn>>,
722 #[builder(default)]
724 issue_id: Option<Vec<u64>>,
725 #[builder(default)]
727 project_id: Option<Vec<u64>>,
728 #[builder(default)]
730 subproject_id: Option<SubProjectFilter>,
731 #[builder(default)]
733 tracker_id: Option<Vec<u64>>,
734 #[builder(default)]
736 priority_id: Option<Vec<u64>>,
737 #[builder(default)]
739 parent_id: Option<Vec<u64>>,
740 #[builder(default)]
742 category_id: Option<Vec<u64>>,
743 #[builder(default)]
745 status_id: Option<StatusFilter>,
746 #[builder(default)]
748 subject: Option<StringFieldFilter>,
749 #[builder(default)]
751 description: Option<StringFieldFilter>,
752 #[builder(default)]
754 author: Option<AuthorFilter>,
755 #[builder(default)]
757 assignee: Option<AssigneeFilter>,
758 #[builder(default)]
760 query_id: Option<u64>,
761 #[builder(default)]
763 version_id: Option<Vec<u64>>,
764 #[builder(default)]
766 created_on: Option<ComparableFilter<time::OffsetDateTime>>,
767 #[builder(default)]
769 updated_on: Option<ComparableFilter<time::OffsetDateTime>>,
770 #[builder(default)]
772 start_date: Option<ComparableFilter<time::Date>>,
773 #[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 #[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#[derive(Debug, Clone)]
849pub enum IssueInclude {
850 Children,
852 Attachments,
854 Relations,
856 Changesets,
858 Journals,
860 Watchers,
862 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#[derive(Debug, Clone, Builder)]
907#[builder(setter(strip_option))]
908pub struct GetIssue {
909 id: u64,
911 #[builder(default)]
913 include: Option<Vec<IssueInclude>>,
914}
915
916impl ReturnsJsonResponse for GetIssue {}
917impl NoPagination for GetIssue {}
918
919impl GetIssue {
920 #[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#[derive(Debug, Clone, Serialize, serde::Deserialize)]
945pub struct CustomField<'a> {
946 pub id: u64,
948 pub name: Option<Cow<'a, str>>,
950 pub value: Cow<'a, str>,
952}
953
954#[derive(Debug, Clone, Serialize)]
957pub struct UploadedAttachment<'a> {
958 pub token: Cow<'a, str>,
960 pub filename: Cow<'a, str>,
962 #[serde(skip_serializing_if = "Option::is_none")]
964 pub description: Option<Cow<'a, str>>,
965 pub content_type: Cow<'a, str>,
967}
968
969#[serde_with::skip_serializing_none]
971#[derive(Debug, Clone, Builder, Serialize)]
972#[builder(setter(strip_option))]
973pub struct CreateIssue<'a> {
974 project_id: u64,
976 #[builder(default)]
978 tracker_id: Option<u64>,
979 #[builder(default)]
981 status_id: Option<u64>,
982 #[builder(default)]
984 priority_id: Option<u64>,
985 #[builder(setter(into), default)]
987 subject: Option<Cow<'a, str>>,
988 #[builder(setter(into), default)]
990 description: Option<Cow<'a, str>>,
991 #[builder(default)]
993 category_id: Option<u64>,
994 #[builder(default, setter(name = "version"))]
996 fixed_version_id: Option<u64>,
997 #[builder(default)]
999 assigned_to_id: Option<u64>,
1000 #[builder(default)]
1002 parent_issue_id: Option<u64>,
1003 #[builder(default)]
1005 custom_fields: Option<Vec<CustomField<'a>>>,
1006 #[builder(default)]
1008 watcher_user_ids: Option<Vec<u64>>,
1009 #[builder(default)]
1011 is_private: Option<bool>,
1012 #[builder(default)]
1014 estimated_hours: Option<f64>,
1015 #[builder(default)]
1017 uploads: Option<Vec<UploadedAttachment<'a>>>,
1018}
1019
1020impl<'a> CreateIssue<'a> {
1021 #[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#[serde_with::skip_serializing_none]
1052#[derive(Debug, Clone, Builder, Serialize)]
1053#[builder(setter(strip_option))]
1054pub struct UpdateIssue<'a> {
1055 #[serde(skip_serializing)]
1057 id: u64,
1058 #[builder(default)]
1060 project_id: Option<u64>,
1061 #[builder(default)]
1063 tracker_id: Option<u64>,
1064 #[builder(default)]
1066 status_id: Option<u64>,
1067 #[builder(default)]
1069 priority_id: Option<u64>,
1070 #[builder(setter(into), default)]
1072 subject: Option<Cow<'a, str>>,
1073 #[builder(setter(into), default)]
1075 description: Option<Cow<'a, str>>,
1076 #[builder(default)]
1078 category_id: Option<u64>,
1079 #[builder(default, setter(name = "version"))]
1081 fixed_version_id: Option<u64>,
1082 #[builder(default)]
1084 assigned_to_id: Option<u64>,
1085 #[builder(default)]
1087 parent_issue_id: Option<u64>,
1088 #[builder(default)]
1090 custom_fields: Option<Vec<CustomField<'a>>>,
1091 #[builder(default)]
1093 watcher_user_ids: Option<Vec<u64>>,
1094 #[builder(default)]
1096 is_private: Option<bool>,
1097 #[builder(default)]
1099 estimated_hours: Option<f64>,
1100 #[builder(default)]
1102 notes: Option<Cow<'a, str>>,
1103 #[builder(default)]
1105 private_notes: Option<bool>,
1106 #[builder(default)]
1108 uploads: Option<Vec<UploadedAttachment<'a>>>,
1109}
1110
1111impl<'a> UpdateIssue<'a> {
1112 #[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#[derive(Debug, Clone, Builder)]
1140#[builder(setter(strip_option))]
1141pub struct DeleteIssue {
1142 id: u64,
1144}
1145
1146impl DeleteIssue {
1147 #[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#[derive(Debug, Clone, Builder, Serialize)]
1166#[builder(setter(strip_option))]
1167pub struct AddWatcher {
1168 #[serde(skip_serializing)]
1170 issue_id: u64,
1171 user_id: u64,
1173}
1174
1175impl AddWatcher {
1176 #[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#[derive(Debug, Clone, Builder)]
1199#[builder(setter(strip_option))]
1200pub struct RemoveWatcher {
1201 issue_id: u64,
1203 user_id: u64,
1205}
1206
1207impl RemoveWatcher {
1208 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1227pub struct IssuesWrapper<T> {
1228 pub issues: Vec<T>,
1230}
1231
1232#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1235pub struct IssueWrapper<T> {
1236 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}