gitlab/api/projects/merge_requests/
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;
8use std::iter;
9
10use chrono::{DateTime, Utc};
11use derive_builder::Builder;
12
13use crate::api::common::{CommaSeparatedList, NameOrId};
14use crate::api::endpoint_prelude::*;
15
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub(crate) enum Assignee {
19    Unassigned,
20    Id(u64),
21    Ids(BTreeSet<u64>),
22}
23
24impl Assignee {
25    pub(crate) fn add_params<'a>(&'a self, params: &mut FormParams<'a>) {
26        match self {
27            Assignee::Unassigned => {
28                params.push("assignee_ids", "0");
29            },
30            Assignee::Id(id) => {
31                params.push("assignee_id", *id);
32            },
33            Assignee::Ids(ids) => {
34                params.extend(ids.iter().map(|&id| ("assignee_ids[]", id)));
35            },
36        }
37    }
38}
39
40/// Parameters for setting the reviewer(s) of a merge request.
41#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub(crate) enum Reviewer {
44    /// Unset all reviewers.
45    Unassigned,
46    /// A set of reviewers.
47    Ids(BTreeSet<u64>),
48}
49
50impl Reviewer {
51    pub(crate) fn add_params<'a>(&'a self, params: &mut FormParams<'a>) {
52        match self {
53            Reviewer::Unassigned => {
54                params.push("reviewer_ids", "0");
55            },
56            Reviewer::Ids(ids) => {
57                params.extend(ids.iter().map(|&id| ("reviewer_ids[]", id)));
58            },
59        }
60    }
61}
62
63/// Create a new merge request on project.
64#[derive(Debug, Builder, Clone)]
65#[builder(setter(strip_option))]
66pub struct CreateMergeRequest<'a> {
67    /// The project to open the merge requset *from*.
68    #[builder(setter(into))]
69    project: NameOrId<'a>,
70    /// The name of the source branch for the merge request.
71    #[builder(setter(into))]
72    source_branch: Cow<'a, str>,
73    /// The name of the target branch for the merge request.
74    #[builder(setter(into))]
75    target_branch: Cow<'a, str>,
76    /// The title for the merge request.
77    #[builder(setter(into))]
78    title: Cow<'a, str>,
79
80    /// The assignee of the merge request.
81    #[builder(setter(name = "_assignee"), default, private)]
82    assignee: Option<Assignee>,
83    #[builder(setter(name = "_reviewer"), default, private)]
84    reviewer: Option<Reviewer>,
85    /// The description of the merge request.
86    #[builder(setter(into), default)]
87    description: Option<Cow<'a, str>>,
88    /// The ID of the target project for the merge request.
89    #[builder(default)]
90    target_project_id: Option<u64>,
91    /// Labels to add to the merge request.
92    #[builder(setter(name = "_labels"), default, private)]
93    labels: Option<CommaSeparatedList<Cow<'a, str>>>,
94    /// Prevent merging of the merge request before a point in time.
95    #[builder(default)]
96    merge_after: Option<DateTime<Utc>>,
97    /// The ID of the milestone to add the merge request to.
98    #[builder(default)]
99    milestone_id: Option<u64>,
100    /// Whether to remove the source branch once merged or not.
101    #[builder(default)]
102    remove_source_branch: Option<bool>,
103    /// Whether to allow collaboration with maintainers of the target project or not.
104    #[builder(default)]
105    allow_collaboration: Option<bool>,
106    /// Whether to squash the branch when merging or not.
107    #[builder(default)]
108    squash: Option<bool>,
109}
110
111impl<'a> CreateMergeRequest<'a> {
112    /// Create a builder for the endpoint.
113    pub fn builder() -> CreateMergeRequestBuilder<'a> {
114        CreateMergeRequestBuilder::default()
115    }
116}
117
118impl<'a> CreateMergeRequestBuilder<'a> {
119    /// Filter unassigned merge requests.
120    pub fn unassigned(&mut self) -> &mut Self {
121        self.assignee = Some(Some(Assignee::Unassigned));
122        self
123    }
124
125    /// Filter merge requests assigned to a user (by ID).
126    pub fn assignee(&mut self, assignee: u64) -> &mut Self {
127        let assignee = match self.assignee.take() {
128            Some(Some(Assignee::Ids(mut set))) => {
129                set.insert(assignee);
130                Assignee::Ids(set)
131            },
132            Some(Some(Assignee::Id(old_id))) => {
133                let set = [old_id, assignee].iter().copied().collect();
134                Assignee::Ids(set)
135            },
136            _ => Assignee::Id(assignee),
137        };
138        self.assignee = Some(Some(assignee));
139        self
140    }
141
142    /// Filter merge requests assigned to a users (by ID).
143    pub fn assignees<I>(&mut self, iter: I) -> &mut Self
144    where
145        I: Iterator<Item = u64>,
146    {
147        let assignee = match self.assignee.take() {
148            Some(Some(Assignee::Ids(mut set))) => {
149                set.extend(iter);
150                Assignee::Ids(set)
151            },
152            Some(Some(Assignee::Id(old_id))) => {
153                let set = iter.chain(iter::once(old_id)).collect();
154                Assignee::Ids(set)
155            },
156            _ => Assignee::Ids(iter.collect()),
157        };
158        self.assignee = Some(Some(assignee));
159        self
160    }
161
162    /// Filter merge requests without a reviewer.
163    pub fn without_reviewer(&mut self) -> &mut Self {
164        self.reviewer = Some(Some(Reviewer::Unassigned));
165        self
166    }
167
168    /// Filter merge requests reviewed by a user (by ID).
169    pub fn reviewer(&mut self, reviewer: u64) -> &mut Self {
170        let reviewer = match self.reviewer.take() {
171            Some(Some(Reviewer::Ids(mut set))) => {
172                set.insert(reviewer);
173                Reviewer::Ids(set)
174            },
175            _ => Reviewer::Ids(iter::once(reviewer).collect()),
176        };
177        self.reviewer = Some(Some(reviewer));
178        self
179    }
180
181    /// Filter merge requests reviewed by users (by ID).
182    pub fn reviewers<I>(&mut self, iter: I) -> &mut Self
183    where
184        I: Iterator<Item = u64>,
185    {
186        let reviewer = match self.reviewer.take() {
187            Some(Some(Reviewer::Ids(mut set))) => {
188                set.extend(iter);
189                Reviewer::Ids(set)
190            },
191            _ => Reviewer::Ids(iter.collect()),
192        };
193        self.reviewer = Some(Some(reviewer));
194        self
195    }
196
197    /// Add a label.
198    pub fn label<L>(&mut self, label: L) -> &mut Self
199    where
200        L: Into<Cow<'a, str>>,
201    {
202        self.labels
203            .get_or_insert(None)
204            .get_or_insert_with(CommaSeparatedList::new)
205            .push(label.into());
206        self
207    }
208
209    /// Add multiple labels.
210    pub fn labels<I, L>(&mut self, iter: I) -> &mut Self
211    where
212        I: Iterator<Item = L>,
213        L: Into<Cow<'a, str>>,
214    {
215        self.labels
216            .get_or_insert(None)
217            .get_or_insert_with(CommaSeparatedList::new)
218            .extend(iter.map(Into::into));
219        self
220    }
221}
222
223impl Endpoint for CreateMergeRequest<'_> {
224    fn method(&self) -> Method {
225        Method::POST
226    }
227
228    fn endpoint(&self) -> Cow<'static, str> {
229        format!("projects/{}/merge_requests", self.project).into()
230    }
231
232    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, BodyError> {
233        let mut params = FormParams::default();
234
235        params
236            .push("source_branch", self.source_branch.as_ref())
237            .push("target_branch", self.target_branch.as_ref())
238            .push("title", self.title.as_ref())
239            .push_opt("description", self.description.as_ref())
240            .push_opt("target_project_id", self.target_project_id)
241            .push_opt("milestone_id", self.milestone_id)
242            .push_opt("labels", self.labels.as_ref())
243            .push_opt("merge_after", self.merge_after)
244            .push_opt("remove_source_branch", self.remove_source_branch)
245            .push_opt("allow_collaboration", self.allow_collaboration)
246            .push_opt("squash", self.squash);
247
248        if let Some(assignee) = self.assignee.as_ref() {
249            assignee.add_params(&mut params);
250        }
251        if let Some(reviewer) = self.reviewer.as_ref() {
252            reviewer.add_params(&mut params);
253        }
254
255        params.into_body()
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use chrono::{TimeZone, Utc};
262    use http::Method;
263
264    use crate::api::projects::merge_requests::{
265        CreateMergeRequest, CreateMergeRequestBuilderError,
266    };
267    use crate::api::{self, Query};
268    use crate::test::client::{ExpectedUrl, SingleTestClient};
269
270    #[test]
271    fn project_source_branch_target_branch_and_title_are_necessary() {
272        let err = CreateMergeRequest::builder().build().unwrap_err();
273        crate::test::assert_missing_field!(err, CreateMergeRequestBuilderError, "project");
274    }
275
276    #[test]
277    fn project_is_necessary() {
278        let err = CreateMergeRequest::builder()
279            .source_branch("source")
280            .target_branch("target")
281            .title("title")
282            .build()
283            .unwrap_err();
284        crate::test::assert_missing_field!(err, CreateMergeRequestBuilderError, "project");
285    }
286
287    #[test]
288    fn source_branch_is_necessary() {
289        let err = CreateMergeRequest::builder()
290            .project(1)
291            .target_branch("target")
292            .title("title")
293            .build()
294            .unwrap_err();
295        crate::test::assert_missing_field!(err, CreateMergeRequestBuilderError, "source_branch");
296    }
297
298    #[test]
299    fn target_branch_is_necessary() {
300        let err = CreateMergeRequest::builder()
301            .project(1)
302            .source_branch("source")
303            .title("title")
304            .build()
305            .unwrap_err();
306        crate::test::assert_missing_field!(err, CreateMergeRequestBuilderError, "target_branch");
307    }
308
309    #[test]
310    fn title_is_necessary() {
311        let err = CreateMergeRequest::builder()
312            .project(1)
313            .source_branch("source")
314            .target_branch("target")
315            .build()
316            .unwrap_err();
317        crate::test::assert_missing_field!(err, CreateMergeRequestBuilderError, "title");
318    }
319
320    #[test]
321    fn project_source_branch_target_branch_and_title_are_sufficient() {
322        CreateMergeRequest::builder()
323            .project(1)
324            .source_branch("source")
325            .target_branch("target")
326            .title("title")
327            .build()
328            .unwrap();
329    }
330
331    #[test]
332    fn endpoint() {
333        let endpoint = ExpectedUrl::builder()
334            .method(Method::POST)
335            .endpoint("projects/simple%2Fproject/merge_requests")
336            .content_type("application/x-www-form-urlencoded")
337            .body_str(concat!(
338                "source_branch=source%2Fbranch",
339                "&target_branch=target%2Fbranch",
340                "&title=title",
341            ))
342            .build()
343            .unwrap();
344        let client = SingleTestClient::new_raw(endpoint, "");
345
346        let endpoint = CreateMergeRequest::builder()
347            .project("simple/project")
348            .source_branch("source/branch")
349            .target_branch("target/branch")
350            .title("title")
351            .build()
352            .unwrap();
353        api::ignore(endpoint).query(&client).unwrap();
354    }
355
356    #[test]
357    fn endpoint_unassigned() {
358        let endpoint = ExpectedUrl::builder()
359            .method(Method::POST)
360            .endpoint("projects/simple%2Fproject/merge_requests")
361            .content_type("application/x-www-form-urlencoded")
362            .body_str(concat!(
363                "source_branch=source%2Fbranch",
364                "&target_branch=target%2Fbranch",
365                "&title=title",
366                "&assignee_ids=0",
367            ))
368            .build()
369            .unwrap();
370        let client = SingleTestClient::new_raw(endpoint, "");
371
372        let endpoint = CreateMergeRequest::builder()
373            .project("simple/project")
374            .source_branch("source/branch")
375            .target_branch("target/branch")
376            .title("title")
377            .unassigned()
378            .build()
379            .unwrap();
380        api::ignore(endpoint).query(&client).unwrap();
381    }
382
383    #[test]
384    fn endpoint_assignee() {
385        let endpoint = ExpectedUrl::builder()
386            .method(Method::POST)
387            .endpoint("projects/simple%2Fproject/merge_requests")
388            .content_type("application/x-www-form-urlencoded")
389            .body_str(concat!(
390                "source_branch=source%2Fbranch",
391                "&target_branch=target%2Fbranch",
392                "&title=title",
393                "&assignee_id=1",
394            ))
395            .build()
396            .unwrap();
397        let client = SingleTestClient::new_raw(endpoint, "");
398
399        let endpoint = CreateMergeRequest::builder()
400            .project("simple/project")
401            .source_branch("source/branch")
402            .target_branch("target/branch")
403            .title("title")
404            .assignee(1)
405            .build()
406            .unwrap();
407        api::ignore(endpoint).query(&client).unwrap();
408    }
409
410    #[test]
411    fn endpoint_assignees() {
412        let endpoint = ExpectedUrl::builder()
413            .method(Method::POST)
414            .endpoint("projects/simple%2Fproject/merge_requests")
415            .content_type("application/x-www-form-urlencoded")
416            .body_str(concat!(
417                "source_branch=source%2Fbranch",
418                "&target_branch=target%2Fbranch",
419                "&title=title",
420                "&assignee_ids%5B%5D=1",
421                "&assignee_ids%5B%5D=2",
422            ))
423            .build()
424            .unwrap();
425        let client = SingleTestClient::new_raw(endpoint, "");
426
427        let endpoint = CreateMergeRequest::builder()
428            .project("simple/project")
429            .source_branch("source/branch")
430            .target_branch("target/branch")
431            .title("title")
432            .assignee(1)
433            .assignees([1, 2].iter().copied())
434            .build()
435            .unwrap();
436        api::ignore(endpoint).query(&client).unwrap();
437    }
438
439    #[test]
440    fn endpoint_unreviewed() {
441        let endpoint = ExpectedUrl::builder()
442            .method(Method::POST)
443            .endpoint("projects/simple%2Fproject/merge_requests")
444            .content_type("application/x-www-form-urlencoded")
445            .body_str(concat!(
446                "source_branch=source%2Fbranch",
447                "&target_branch=target%2Fbranch",
448                "&title=title",
449                "&reviewer_ids=0",
450            ))
451            .build()
452            .unwrap();
453        let client = SingleTestClient::new_raw(endpoint, "");
454
455        let endpoint = CreateMergeRequest::builder()
456            .project("simple/project")
457            .source_branch("source/branch")
458            .target_branch("target/branch")
459            .title("title")
460            .without_reviewer()
461            .build()
462            .unwrap();
463        api::ignore(endpoint).query(&client).unwrap();
464    }
465
466    #[test]
467    fn endpoint_reviewer() {
468        let endpoint = ExpectedUrl::builder()
469            .method(Method::POST)
470            .endpoint("projects/simple%2Fproject/merge_requests")
471            .content_type("application/x-www-form-urlencoded")
472            .body_str(concat!(
473                "source_branch=source%2Fbranch",
474                "&target_branch=target%2Fbranch",
475                "&title=title",
476                "&reviewer_ids%5B%5D=1",
477            ))
478            .build()
479            .unwrap();
480        let client = SingleTestClient::new_raw(endpoint, "");
481
482        let endpoint = CreateMergeRequest::builder()
483            .project("simple/project")
484            .source_branch("source/branch")
485            .target_branch("target/branch")
486            .title("title")
487            .reviewer(1)
488            .build()
489            .unwrap();
490        api::ignore(endpoint).query(&client).unwrap();
491    }
492
493    #[test]
494    fn endpoint_reviewers() {
495        let endpoint = ExpectedUrl::builder()
496            .method(Method::POST)
497            .endpoint("projects/simple%2Fproject/merge_requests")
498            .content_type("application/x-www-form-urlencoded")
499            .body_str(concat!(
500                "source_branch=source%2Fbranch",
501                "&target_branch=target%2Fbranch",
502                "&title=title",
503                "&reviewer_ids%5B%5D=1",
504                "&reviewer_ids%5B%5D=2",
505            ))
506            .build()
507            .unwrap();
508        let client = SingleTestClient::new_raw(endpoint, "");
509
510        let endpoint = CreateMergeRequest::builder()
511            .project("simple/project")
512            .source_branch("source/branch")
513            .target_branch("target/branch")
514            .title("title")
515            .reviewer(1)
516            .reviewers([1, 2].iter().copied())
517            .build()
518            .unwrap();
519        api::ignore(endpoint).query(&client).unwrap();
520    }
521
522    #[test]
523    fn endpoint_description() {
524        let endpoint = ExpectedUrl::builder()
525            .method(Method::POST)
526            .endpoint("projects/simple%2Fproject/merge_requests")
527            .content_type("application/x-www-form-urlencoded")
528            .body_str(concat!(
529                "source_branch=source%2Fbranch",
530                "&target_branch=target%2Fbranch",
531                "&title=title",
532                "&description=description",
533            ))
534            .build()
535            .unwrap();
536        let client = SingleTestClient::new_raw(endpoint, "");
537
538        let endpoint = CreateMergeRequest::builder()
539            .project("simple/project")
540            .source_branch("source/branch")
541            .target_branch("target/branch")
542            .title("title")
543            .description("description")
544            .build()
545            .unwrap();
546        api::ignore(endpoint).query(&client).unwrap();
547    }
548
549    #[test]
550    fn endpoint_target_project_id() {
551        let endpoint = ExpectedUrl::builder()
552            .method(Method::POST)
553            .endpoint("projects/simple%2Fproject/merge_requests")
554            .content_type("application/x-www-form-urlencoded")
555            .body_str(concat!(
556                "source_branch=source%2Fbranch",
557                "&target_branch=target%2Fbranch",
558                "&title=title",
559                "&target_project_id=1",
560            ))
561            .build()
562            .unwrap();
563        let client = SingleTestClient::new_raw(endpoint, "");
564
565        let endpoint = CreateMergeRequest::builder()
566            .project("simple/project")
567            .source_branch("source/branch")
568            .target_branch("target/branch")
569            .title("title")
570            .target_project_id(1)
571            .build()
572            .unwrap();
573        api::ignore(endpoint).query(&client).unwrap();
574    }
575
576    #[test]
577    fn endpoint_labels() {
578        let endpoint = ExpectedUrl::builder()
579            .method(Method::POST)
580            .endpoint("projects/simple%2Fproject/merge_requests")
581            .content_type("application/x-www-form-urlencoded")
582            .body_str(concat!(
583                "source_branch=source%2Fbranch",
584                "&target_branch=target%2Fbranch",
585                "&title=title",
586                "&labels=label%2Clabel1%2Clabel2",
587            ))
588            .build()
589            .unwrap();
590        let client = SingleTestClient::new_raw(endpoint, "");
591
592        let endpoint = CreateMergeRequest::builder()
593            .project("simple/project")
594            .source_branch("source/branch")
595            .target_branch("target/branch")
596            .title("title")
597            .label("label")
598            .labels(["label1", "label2"].iter().cloned())
599            .build()
600            .unwrap();
601        api::ignore(endpoint).query(&client).unwrap();
602    }
603
604    #[test]
605    fn endpoint_merge_after() {
606        let endpoint = ExpectedUrl::builder()
607            .method(Method::POST)
608            .endpoint("projects/simple%2Fproject/merge_requests")
609            .content_type("application/x-www-form-urlencoded")
610            .body_str(concat!(
611                "source_branch=source%2Fbranch",
612                "&target_branch=target%2Fbranch",
613                "&title=title",
614                "&merge_after=2025-01-01T12%3A00%3A00Z",
615            ))
616            .build()
617            .unwrap();
618        let client = SingleTestClient::new_raw(endpoint, "");
619
620        let endpoint = CreateMergeRequest::builder()
621            .project("simple/project")
622            .source_branch("source/branch")
623            .target_branch("target/branch")
624            .title("title")
625            .merge_after(Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap())
626            .build()
627            .unwrap();
628        api::ignore(endpoint).query(&client).unwrap();
629    }
630
631    #[test]
632    fn endpoint_milestone_id() {
633        let endpoint = ExpectedUrl::builder()
634            .method(Method::POST)
635            .endpoint("projects/simple%2Fproject/merge_requests")
636            .content_type("application/x-www-form-urlencoded")
637            .body_str(concat!(
638                "source_branch=source%2Fbranch",
639                "&target_branch=target%2Fbranch",
640                "&title=title",
641                "&milestone_id=1",
642            ))
643            .build()
644            .unwrap();
645        let client = SingleTestClient::new_raw(endpoint, "");
646
647        let endpoint = CreateMergeRequest::builder()
648            .project("simple/project")
649            .source_branch("source/branch")
650            .target_branch("target/branch")
651            .title("title")
652            .milestone_id(1)
653            .build()
654            .unwrap();
655        api::ignore(endpoint).query(&client).unwrap();
656    }
657
658    #[test]
659    fn endpoint_remove_source_branch() {
660        let endpoint = ExpectedUrl::builder()
661            .method(Method::POST)
662            .endpoint("projects/simple%2Fproject/merge_requests")
663            .content_type("application/x-www-form-urlencoded")
664            .body_str(concat!(
665                "source_branch=source%2Fbranch",
666                "&target_branch=target%2Fbranch",
667                "&title=title",
668                "&remove_source_branch=true",
669            ))
670            .build()
671            .unwrap();
672        let client = SingleTestClient::new_raw(endpoint, "");
673
674        let endpoint = CreateMergeRequest::builder()
675            .project("simple/project")
676            .source_branch("source/branch")
677            .target_branch("target/branch")
678            .title("title")
679            .remove_source_branch(true)
680            .build()
681            .unwrap();
682        api::ignore(endpoint).query(&client).unwrap();
683    }
684
685    #[test]
686    fn endpoint_allow_collaboration() {
687        let endpoint = ExpectedUrl::builder()
688            .method(Method::POST)
689            .endpoint("projects/simple%2Fproject/merge_requests")
690            .content_type("application/x-www-form-urlencoded")
691            .body_str(concat!(
692                "source_branch=source%2Fbranch",
693                "&target_branch=target%2Fbranch",
694                "&title=title",
695                "&allow_collaboration=true",
696            ))
697            .build()
698            .unwrap();
699        let client = SingleTestClient::new_raw(endpoint, "");
700
701        let endpoint = CreateMergeRequest::builder()
702            .project("simple/project")
703            .source_branch("source/branch")
704            .target_branch("target/branch")
705            .title("title")
706            .allow_collaboration(true)
707            .build()
708            .unwrap();
709        api::ignore(endpoint).query(&client).unwrap();
710    }
711
712    #[test]
713    fn endpoint_squash() {
714        let endpoint = ExpectedUrl::builder()
715            .method(Method::POST)
716            .endpoint("projects/simple%2Fproject/merge_requests")
717            .content_type("application/x-www-form-urlencoded")
718            .body_str(concat!(
719                "source_branch=source%2Fbranch",
720                "&target_branch=target%2Fbranch",
721                "&title=title",
722                "&squash=false",
723            ))
724            .build()
725            .unwrap();
726        let client = SingleTestClient::new_raw(endpoint, "");
727
728        let endpoint = CreateMergeRequest::builder()
729            .project("simple/project")
730            .source_branch("source/branch")
731            .target_branch("target/branch")
732            .title("title")
733            .squash(false)
734            .build()
735            .unwrap();
736        api::ignore(endpoint).query(&client).unwrap();
737    }
738}