Skip to main content

gitlab/api/projects/issues/
create.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7use std::collections::BTreeSet;
8
9use chrono::{DateTime, NaiveDate, Utc};
10use derive_builder::Builder;
11
12use crate::api::common::{CommaSeparatedList, NameOrId};
13use crate::api::endpoint_prelude::*;
14use crate::api::issues::IssueType;
15
16/// Create a new issue on a project.
17#[derive(Debug, Builder, Clone)]
18#[builder(setter(strip_option))]
19pub struct CreateIssue<'a> {
20    /// The project to add the issue to.
21    #[builder(setter(into))]
22    project: NameOrId<'a>,
23    /// The title of the new issue.
24    ///
25    /// Note: this is technically optional if `merge_request_to_resolve_discussions_of` is given,
26    /// but to avoid more complicated shenanigans around choosing one or the other, this is always
27    /// marked as required. Instead, if `title` is explicitly empty and
28    /// `merge_request_to_resolve_discussions_of` is given, `title` will not be sent allowing
29    /// GitLab to generate the default title.
30    #[builder(setter(into))]
31    title: Cow<'a, str>,
32
33    /// The internal ID of the issue.
34    ///
35    /// Requires administrator or owner permissions.
36    #[builder(default)]
37    iid: Option<u64>,
38    /// The description of the new issue.
39    #[builder(setter(into), default)]
40    description: Option<Cow<'a, str>>,
41    /// Whether the issue is confidential or not.
42    #[builder(default)]
43    confidential: Option<bool>,
44    /// Assignees for the issue.
45    #[builder(setter(name = "_assignee_ids"), default, private)]
46    assignee_ids: BTreeSet<u64>,
47    /// The ID of the milestone for the issue.
48    #[builder(default)]
49    milestone_id: Option<u64>,
50    /// Labels to add to the issue.
51    #[builder(setter(name = "_labels"), default, private)]
52    labels: Option<CommaSeparatedList<Cow<'a, str>>>,
53    /// The creation date of the issue.
54    ///
55    /// Requires administrator or owner permissions.
56    #[builder(default)]
57    created_at: Option<DateTime<Utc>>,
58    /// The due date for the issue.
59    #[builder(default)]
60    due_date: Option<NaiveDate>,
61    /// The ID of a merge request for which to resolve the discussions.
62    ///
63    /// Resolves all open discussions unless `discussion_to_resolve` is also passed.
64    #[builder(default)]
65    merge_request_to_resolve_discussions_of: Option<u64>,
66    /// The ID of the discussion to resolve.
67    #[builder(setter(into), default)]
68    discussion_to_resolve: Option<Cow<'a, str>>,
69    /// The weight of the issue.
70    #[builder(default)]
71    weight: Option<u64>,
72    /// The ID of the epic to add the issue to.
73    #[builder(default)]
74    epic_id: Option<u64>,
75    /// The type of issue.
76    #[builder(default)]
77    issue_type: Option<IssueType>,
78}
79
80impl<'a> CreateIssue<'a> {
81    /// Create a builder for the endpoint.
82    pub fn builder() -> CreateIssueBuilder<'a> {
83        CreateIssueBuilder::default()
84    }
85}
86
87impl<'a> CreateIssueBuilder<'a> {
88    /// Assign the issue to a user.
89    pub fn assignee_id(&mut self, assignee: u64) -> &mut Self {
90        self.assignee_ids
91            .get_or_insert_with(BTreeSet::new)
92            .insert(assignee);
93        self
94    }
95
96    /// Assign the issue to a set of users.
97    pub fn assignee_ids<I>(&mut self, iter: I) -> &mut Self
98    where
99        I: Iterator<Item = u64>,
100    {
101        self.assignee_ids
102            .get_or_insert_with(BTreeSet::new)
103            .extend(iter);
104        self
105    }
106
107    /// Add a label to the issue.
108    pub fn label<L>(&mut self, label: L) -> &mut Self
109    where
110        L: Into<Cow<'a, str>>,
111    {
112        self.labels
113            .get_or_insert(None)
114            .get_or_insert_with(CommaSeparatedList::new)
115            .push(label.into());
116        self
117    }
118
119    /// Add a set of labels to the issue.
120    pub fn labels<I, L>(&mut self, iter: I) -> &mut Self
121    where
122        I: IntoIterator<Item = L>,
123        L: Into<Cow<'a, str>>,
124    {
125        self.labels
126            .get_or_insert(None)
127            .get_or_insert_with(CommaSeparatedList::new)
128            .extend(iter.into_iter().map(Into::into));
129        self
130    }
131}
132
133impl Endpoint for CreateIssue<'_> {
134    fn method(&self) -> Method {
135        Method::POST
136    }
137
138    fn endpoint(&self) -> Cow<'static, str> {
139        format!("projects/{}/issues", self.project).into()
140    }
141
142    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, BodyError> {
143        let mut params = FormParams::default();
144
145        if !self.title.is_empty() || self.merge_request_to_resolve_discussions_of.is_none() {
146            params.push("title", &self.title);
147        }
148
149        params
150            .push_opt("iid", self.iid)
151            .push_opt("description", self.description.as_ref())
152            .push_opt("confidential", self.confidential)
153            .extend(
154                self.assignee_ids
155                    .iter()
156                    .map(|&value| ("assignee_ids[]", value)),
157            )
158            .push_opt("milestone_id", self.milestone_id)
159            .push_opt("labels", self.labels.as_ref())
160            .push_opt("created_at", self.created_at)
161            .push_opt("due_date", self.due_date)
162            .push_opt(
163                "merge_request_to_resolve_discussions_of",
164                self.merge_request_to_resolve_discussions_of,
165            )
166            .push_opt("discussion_to_resolve", self.discussion_to_resolve.as_ref())
167            .push_opt("weight", self.weight)
168            .push_opt("epic_id", self.epic_id)
169            .push_opt("issue_type", self.issue_type);
170
171        params.into_body()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use chrono::{NaiveDate, TimeZone, Utc};
178    use http::Method;
179
180    use crate::api::issues::IssueType;
181    use crate::api::projects::issues::{CreateIssue, CreateIssueBuilderError};
182    use crate::api::{self, Query};
183    use crate::test::client::{ExpectedUrl, SingleTestClient};
184
185    #[test]
186    fn project_and_title_are_necessary() {
187        let err = CreateIssue::builder().build().unwrap_err();
188        crate::test::assert_missing_field!(err, CreateIssueBuilderError, "project");
189    }
190
191    #[test]
192    fn project_is_necessary() {
193        let err = CreateIssue::builder().title("title").build().unwrap_err();
194        crate::test::assert_missing_field!(err, CreateIssueBuilderError, "project");
195    }
196
197    #[test]
198    fn title_is_necessary() {
199        let err = CreateIssue::builder().project(1).build().unwrap_err();
200        crate::test::assert_missing_field!(err, CreateIssueBuilderError, "title");
201    }
202
203    #[test]
204    fn project_and_title_are_sufficient() {
205        CreateIssue::builder()
206            .project(1)
207            .title("title")
208            .build()
209            .unwrap();
210    }
211
212    #[test]
213    fn endpoint() {
214        let endpoint = ExpectedUrl::builder()
215            .method(Method::POST)
216            .endpoint("projects/simple%2Fproject/issues")
217            .content_type("application/x-www-form-urlencoded")
218            .body_str("title=title+of+issue")
219            .build()
220            .unwrap();
221        let client = SingleTestClient::new_raw(endpoint, "");
222
223        let endpoint = CreateIssue::builder()
224            .project("simple/project")
225            .title("title of issue")
226            .build()
227            .unwrap();
228        api::ignore(endpoint).query(&client).unwrap();
229    }
230
231    #[test]
232    fn endpoint_iid() {
233        let endpoint = ExpectedUrl::builder()
234            .method(Method::POST)
235            .endpoint("projects/simple%2Fproject/issues")
236            .content_type("application/x-www-form-urlencoded")
237            .body_str(concat!("title=title", "&iid=1"))
238            .build()
239            .unwrap();
240        let client = SingleTestClient::new_raw(endpoint, "");
241
242        let endpoint = CreateIssue::builder()
243            .project("simple/project")
244            .title("title")
245            .iid(1)
246            .build()
247            .unwrap();
248        api::ignore(endpoint).query(&client).unwrap();
249    }
250
251    #[test]
252    fn endpoint_description() {
253        let endpoint = ExpectedUrl::builder()
254            .method(Method::POST)
255            .endpoint("projects/simple%2Fproject/issues")
256            .content_type("application/x-www-form-urlencoded")
257            .body_str(concat!("title=title", "&description=description"))
258            .build()
259            .unwrap();
260        let client = SingleTestClient::new_raw(endpoint, "");
261
262        let endpoint = CreateIssue::builder()
263            .project("simple/project")
264            .title("title")
265            .description("description")
266            .build()
267            .unwrap();
268        api::ignore(endpoint).query(&client).unwrap();
269    }
270
271    #[test]
272    fn endpoint_confidential() {
273        let endpoint = ExpectedUrl::builder()
274            .method(Method::POST)
275            .endpoint("projects/simple%2Fproject/issues")
276            .content_type("application/x-www-form-urlencoded")
277            .body_str(concat!("title=title", "&confidential=true"))
278            .build()
279            .unwrap();
280        let client = SingleTestClient::new_raw(endpoint, "");
281
282        let endpoint = CreateIssue::builder()
283            .project("simple/project")
284            .title("title")
285            .confidential(true)
286            .build()
287            .unwrap();
288        api::ignore(endpoint).query(&client).unwrap();
289    }
290
291    #[test]
292    fn endpoint_assignee_ids() {
293        let endpoint = ExpectedUrl::builder()
294            .method(Method::POST)
295            .endpoint("projects/simple%2Fproject/issues")
296            .content_type("application/x-www-form-urlencoded")
297            .body_str(concat!(
298                "title=title",
299                "&assignee_ids%5B%5D=1",
300                "&assignee_ids%5B%5D=2",
301            ))
302            .build()
303            .unwrap();
304        let client = SingleTestClient::new_raw(endpoint, "");
305
306        let endpoint = CreateIssue::builder()
307            .project("simple/project")
308            .title("title")
309            .assignee_id(1)
310            .assignee_ids([1, 2].iter().copied())
311            .build()
312            .unwrap();
313        api::ignore(endpoint).query(&client).unwrap();
314    }
315
316    #[test]
317    fn endpoint_milestone_id() {
318        let endpoint = ExpectedUrl::builder()
319            .method(Method::POST)
320            .endpoint("projects/simple%2Fproject/issues")
321            .content_type("application/x-www-form-urlencoded")
322            .body_str(concat!("title=title", "&milestone_id=1"))
323            .build()
324            .unwrap();
325        let client = SingleTestClient::new_raw(endpoint, "");
326
327        let endpoint = CreateIssue::builder()
328            .project("simple/project")
329            .title("title")
330            .milestone_id(1)
331            .build()
332            .unwrap();
333        api::ignore(endpoint).query(&client).unwrap();
334    }
335
336    #[test]
337    fn endpoint_labels() {
338        let endpoint = ExpectedUrl::builder()
339            .method(Method::POST)
340            .endpoint("projects/simple%2Fproject/issues")
341            .content_type("application/x-www-form-urlencoded")
342            .body_str(concat!("title=title", "&labels=label"))
343            .build()
344            .unwrap();
345        let client = SingleTestClient::new_raw(endpoint, "");
346
347        let endpoint = CreateIssue::builder()
348            .project("simple/project")
349            .title("title")
350            .label("label")
351            .build()
352            .unwrap();
353        api::ignore(endpoint).query(&client).unwrap();
354    }
355
356    #[test]
357    fn endpoint_labels_multiple() {
358        let endpoint = ExpectedUrl::builder()
359            .method(Method::POST)
360            .endpoint("projects/simple%2Fproject/issues")
361            .content_type("application/x-www-form-urlencoded")
362            .body_str(concat!("title=title", "&labels=label1%2Clabel2"))
363            .build()
364            .unwrap();
365        let client = SingleTestClient::new_raw(endpoint, "");
366
367        let endpoint = CreateIssue::builder()
368            .project("simple/project")
369            .title("title")
370            .labels(["label1", "label2"].iter().copied())
371            .build()
372            .unwrap();
373        api::ignore(endpoint).query(&client).unwrap();
374    }
375
376    #[test]
377    fn endpoint_created_at() {
378        let endpoint = ExpectedUrl::builder()
379            .method(Method::POST)
380            .endpoint("projects/simple%2Fproject/issues")
381            .content_type("application/x-www-form-urlencoded")
382            .body_str(concat!(
383                "title=title",
384                "&created_at=2020-01-01T00%3A00%3A00Z",
385            ))
386            .build()
387            .unwrap();
388        let client = SingleTestClient::new_raw(endpoint, "");
389
390        let endpoint = CreateIssue::builder()
391            .project("simple/project")
392            .title("title")
393            .created_at(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap())
394            .build()
395            .unwrap();
396        api::ignore(endpoint).query(&client).unwrap();
397    }
398
399    #[test]
400    fn endpoint_due_date() {
401        let endpoint = ExpectedUrl::builder()
402            .method(Method::POST)
403            .endpoint("projects/simple%2Fproject/issues")
404            .content_type("application/x-www-form-urlencoded")
405            .body_str(concat!("title=title", "&due_date=2020-01-01"))
406            .build()
407            .unwrap();
408        let client = SingleTestClient::new_raw(endpoint, "");
409
410        let endpoint = CreateIssue::builder()
411            .project("simple/project")
412            .title("title")
413            .due_date(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap())
414            .build()
415            .unwrap();
416        api::ignore(endpoint).query(&client).unwrap();
417    }
418
419    #[test]
420    fn endpoint_merge_request_to_resolve_discussions_of() {
421        let endpoint = ExpectedUrl::builder()
422            .method(Method::POST)
423            .endpoint("projects/simple%2Fproject/issues")
424            .content_type("application/x-www-form-urlencoded")
425            .body_str(concat!(
426                "title=title",
427                "&merge_request_to_resolve_discussions_of=1",
428            ))
429            .build()
430            .unwrap();
431        let client = SingleTestClient::new_raw(endpoint, "");
432
433        let endpoint = CreateIssue::builder()
434            .project("simple/project")
435            .title("title")
436            .merge_request_to_resolve_discussions_of(1)
437            .build()
438            .unwrap();
439        api::ignore(endpoint).query(&client).unwrap();
440    }
441
442    #[test]
443    fn endpoint_merge_request_to_resolve_discussions_of_no_title() {
444        let endpoint = ExpectedUrl::builder()
445            .method(Method::POST)
446            .endpoint("projects/simple%2Fproject/issues")
447            .content_type("application/x-www-form-urlencoded")
448            .body_str("merge_request_to_resolve_discussions_of=1")
449            .build()
450            .unwrap();
451        let client = SingleTestClient::new_raw(endpoint, "");
452
453        let endpoint = CreateIssue::builder()
454            .project("simple/project")
455            .title("") // This should trigger logic to not send the parameter.
456            .merge_request_to_resolve_discussions_of(1)
457            .build()
458            .unwrap();
459        api::ignore(endpoint).query(&client).unwrap();
460    }
461
462    #[test]
463    fn endpoint_discussion_to_resolve() {
464        let endpoint = ExpectedUrl::builder()
465            .method(Method::POST)
466            .endpoint("projects/simple%2Fproject/issues")
467            .content_type("application/x-www-form-urlencoded")
468            .body_str(concat!("title=title", "&discussion_to_resolve=deadbeef"))
469            .build()
470            .unwrap();
471        let client = SingleTestClient::new_raw(endpoint, "");
472
473        let endpoint = CreateIssue::builder()
474            .project("simple/project")
475            .title("title")
476            .discussion_to_resolve("deadbeef")
477            .build()
478            .unwrap();
479        api::ignore(endpoint).query(&client).unwrap();
480    }
481
482    #[test]
483    fn endpoint_weight() {
484        let endpoint = ExpectedUrl::builder()
485            .method(Method::POST)
486            .endpoint("projects/simple%2Fproject/issues")
487            .content_type("application/x-www-form-urlencoded")
488            .body_str(concat!("title=title", "&weight=1"))
489            .build()
490            .unwrap();
491        let client = SingleTestClient::new_raw(endpoint, "");
492
493        let endpoint = CreateIssue::builder()
494            .project("simple/project")
495            .title("title")
496            .weight(1)
497            .build()
498            .unwrap();
499        api::ignore(endpoint).query(&client).unwrap();
500    }
501
502    #[test]
503    fn endpoint_epic_id() {
504        let endpoint = ExpectedUrl::builder()
505            .method(Method::POST)
506            .endpoint("projects/simple%2Fproject/issues")
507            .content_type("application/x-www-form-urlencoded")
508            .body_str(concat!("title=title", "&epic_id=1"))
509            .build()
510            .unwrap();
511        let client = SingleTestClient::new_raw(endpoint, "");
512
513        let endpoint = CreateIssue::builder()
514            .project("simple/project")
515            .title("title")
516            .epic_id(1)
517            .build()
518            .unwrap();
519        api::ignore(endpoint).query(&client).unwrap();
520    }
521
522    #[test]
523    fn endpoint_issue_type() {
524        let endpoint = ExpectedUrl::builder()
525            .method(Method::POST)
526            .endpoint("projects/simple%2Fproject/issues")
527            .content_type("application/x-www-form-urlencoded")
528            .body_str(concat!("title=title", "&issue_type=test_case"))
529            .build()
530            .unwrap();
531        let client = SingleTestClient::new_raw(endpoint, "");
532
533        let endpoint = CreateIssue::builder()
534            .project("simple/project")
535            .title("title")
536            .issue_type(IssueType::TestCase)
537            .build()
538            .unwrap();
539        api::ignore(endpoint).query(&client).unwrap();
540    }
541}