gitlab/api/projects/merge_requests/discussions/
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 chrono::{DateTime, Utc};
8use derive_builder::Builder;
9
10use crate::api::common::NameOrId;
11use crate::api::endpoint_prelude::*;
12use crate::api::ParamValue;
13
14/// The type of line to comment on.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum LineType {
18    /// A removed or edited line.
19    Old,
20    /// A new or post-edit line.
21    New,
22}
23
24impl LineType {
25    fn as_str(self) -> &'static str {
26        match self {
27            LineType::Old => "old",
28            LineType::New => "new",
29        }
30    }
31}
32
33impl ParamValue<'static> for LineType {
34    fn as_value(&self) -> Cow<'static, str> {
35        self.as_str().into()
36    }
37}
38
39/// The line code for a discussion comment.
40#[derive(Debug, Clone, Builder)]
41pub struct LineCode<'a> {
42    /// The line code.
43    ///
44    /// Note that this is an internal format without much documentation.
45    #[builder(setter(into))]
46    line_code: Cow<'a, str>,
47    /// The type of the line to comment on.
48    type_: LineType,
49}
50
51impl<'a> LineCode<'a> {
52    /// Create a builder for the line range.
53    pub fn builder() -> LineCodeBuilder<'a> {
54        LineCodeBuilder::default()
55    }
56}
57
58/// A range of lines for a discussion.
59#[derive(Debug, Clone, Builder)]
60#[builder(setter(strip_option))]
61pub struct LineRange<'a> {
62    /// The line code for the start of the range.
63    #[builder(setter(into))]
64    start: LineCode<'a>,
65    /// The line code for the end of the range.
66    #[builder(setter(into))]
67    end: LineCode<'a>,
68}
69
70impl<'a> LineRange<'a> {
71    /// Create a builder for the line range.
72    pub fn builder() -> LineRangeBuilder<'a> {
73        LineRangeBuilder::default()
74    }
75
76    fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
77        params
78            .push(
79                "position[line_range][start][line_code]",
80                self.start.line_code.as_ref(),
81            )
82            .push("position[line_range][start][type]", self.start.type_)
83            .push(
84                "position[line_range][end][line_code]",
85                self.end.line_code.as_ref(),
86            )
87            .push("position[line_range][end][type]", self.end.type_);
88    }
89}
90
91/// A position within a text file for a discussion.
92#[derive(Debug, Clone, Builder)]
93#[builder(setter(strip_option))]
94pub struct TextPosition<'a> {
95    /// The name of the path for the new side of the diff.
96    #[builder(setter(into), default)]
97    new_path: Option<Cow<'a, str>>,
98    /// The line number for the new side of the diff.
99    #[builder(default)]
100    new_line: Option<u64>,
101    /// The name of the path for the old side of the diff.
102    #[builder(setter(into), default)]
103    old_path: Option<Cow<'a, str>>,
104    /// The line number for the old side of the diff.
105    #[builder(default)]
106    old_line: Option<u64>,
107    /// The range of lines to discuss.
108    #[builder(default)]
109    line_range: Option<LineRange<'a>>,
110}
111
112impl<'a> TextPosition<'a> {
113    /// Create a builder for a text position.
114    pub fn builder() -> TextPositionBuilder<'a> {
115        TextPositionBuilder::default()
116    }
117
118    fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
119        params
120            .push_opt("position[new_path]", self.new_path.as_ref())
121            .push_opt("position[new_line]", self.new_line)
122            .push_opt("position[old_path]", self.old_path.as_ref())
123            .push_opt("position[old_line]", self.old_line);
124
125        if let Some(line_range) = self.line_range.as_ref() {
126            line_range.add_params(params);
127        }
128    }
129}
130
131/// A position within an image for file a discussion.
132#[derive(Debug, Clone, Copy, Builder)]
133#[builder(setter(strip_option))]
134pub struct ImagePosition {
135    /// The width of the image.
136    #[builder(default)]
137    width: Option<u64>,
138    /// The height of the image.
139    #[builder(default)]
140    height: Option<u64>,
141    /// The `x` coordinate for the image.
142    #[builder(default)]
143    x: Option<u64>,
144    /// The `y` coordinate for the image.
145    #[builder(default)]
146    y: Option<u64>,
147}
148
149impl ImagePosition {
150    /// Create a builder for a image position.
151    pub fn builder() -> ImagePositionBuilder {
152        ImagePositionBuilder::default()
153    }
154
155    fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
156        params
157            .push_opt("position[width]", self.width)
158            .push_opt("position[height]", self.height)
159            .push_opt("position[x]", self.x)
160            .push_opt("position[y]", self.y);
161    }
162}
163
164#[derive(Debug, Clone)]
165#[non_exhaustive]
166enum FilePosition<'a> {
167    Text(TextPosition<'a>),
168    Image(ImagePosition),
169}
170
171impl FilePosition<'_> {
172    fn type_str(&self) -> &'static str {
173        match self {
174            Self::Text(_) => "text",
175            Self::Image(_) => "image",
176        }
177    }
178
179    fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
180        match self {
181            Self::Text(text) => text.add_params(params),
182            Self::Image(image) => image.add_params(params),
183        }
184    }
185}
186
187/// A position in a merge request diff for a discussion.
188#[derive(Debug, Clone, Builder)]
189#[builder(setter(strip_option))]
190pub struct Position<'a> {
191    /// The base commit SHA in the source branch.
192    #[builder(setter(into))]
193    base_sha: Cow<'a, str>,
194    /// The base commit SHA in the target branch.
195    #[builder(setter(into))]
196    start_sha: Cow<'a, str>,
197    /// The commit SHA for the HEAD of the merge request.
198    #[builder(setter(into))]
199    head_sha: Cow<'a, str>,
200    /// The position within the diff to discuss.
201    #[builder(setter(name = "_position"), private)]
202    position: FilePosition<'a>,
203}
204
205impl<'a> PositionBuilder<'a> {
206    /// The position within a text file.
207    pub fn text_position(&mut self, position: TextPosition<'a>) -> &mut Self {
208        self.position = Some(FilePosition::Text(position));
209        self
210    }
211
212    /// The position within an image file.
213    pub fn image_position(&mut self, position: ImagePosition) -> &mut Self {
214        self.position = Some(FilePosition::Image(position));
215        self
216    }
217}
218
219impl<'a> Position<'a> {
220    /// Create a builder for a position.
221    pub fn builder() -> PositionBuilder<'a> {
222        PositionBuilder::default()
223    }
224
225    fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
226        params
227            .push("position[base_sha]", self.base_sha.as_ref())
228            .push("position[start_sha]", self.start_sha.as_ref())
229            .push("position[head_sha]", self.head_sha.as_ref())
230            .push("position[position_type]", self.position.type_str());
231
232        self.position.add_params(params);
233    }
234}
235
236/// Create a new discussion on a merge request on a project.
237#[derive(Debug, Builder, Clone)]
238#[builder(setter(strip_option))]
239pub struct CreateMergeRequestDiscussion<'a> {
240    /// The project of the merge request.
241    #[builder(setter(into))]
242    project: NameOrId<'a>,
243    /// The merge method to start a new discussion on.
244    merge_request: u64,
245    /// The content of the discussion.
246    #[builder(setter(into))]
247    body: Cow<'a, str>,
248    #[builder(setter(into), default)]
249    /// A sha referencing a commit to start the thread on.
250    commit_id: Option<Cow<'a, str>>,
251
252    /// When the discussion was created.
253    ///
254    /// Requires administrator or owner permissions.
255    #[builder(default)]
256    created_at: Option<DateTime<Utc>>,
257    /// The location of the discussion in the diff.
258    #[builder(default)]
259    position: Option<Position<'a>>,
260}
261
262impl<'a> CreateMergeRequestDiscussion<'a> {
263    /// Create a builder for the endpoint.
264    pub fn builder() -> CreateMergeRequestDiscussionBuilder<'a> {
265        CreateMergeRequestDiscussionBuilder::default()
266    }
267}
268
269impl Endpoint for CreateMergeRequestDiscussion<'_> {
270    fn method(&self) -> Method {
271        Method::POST
272    }
273
274    fn endpoint(&self) -> Cow<'static, str> {
275        format!(
276            "projects/{}/merge_requests/{}/discussions",
277            self.project, self.merge_request,
278        )
279        .into()
280    }
281
282    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, BodyError> {
283        let mut params = FormParams::default();
284
285        params
286            .push("body", self.body.as_ref())
287            .push_opt("commit_id", self.commit_id.as_ref())
288            .push_opt("created_at", self.created_at);
289
290        if let Some(position) = self.position.as_ref() {
291            position.add_params(&mut params);
292        }
293
294        params.into_body()
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use chrono::{TimeZone, Utc};
301    use http::Method;
302
303    use crate::api::projects::merge_requests::discussions::{
304        CreateMergeRequestDiscussion, CreateMergeRequestDiscussionBuilderError, ImagePosition,
305        LineCode, LineCodeBuilderError, LineRange, LineRangeBuilderError, LineType, Position,
306        PositionBuilderError, TextPosition,
307    };
308    use crate::api::{self, Query};
309    use crate::test::client::{ExpectedUrl, SingleTestClient};
310
311    use super::FilePosition;
312
313    #[test]
314    fn line_type_as_str() {
315        let items = &[(LineType::Old, "old"), (LineType::New, "new")];
316
317        for (i, s) in items {
318            assert_eq!(i.as_str(), *s);
319        }
320    }
321
322    #[test]
323    fn line_code_line_code_and_type_are_necessary() {
324        let err = LineCode::builder().build().unwrap_err();
325        crate::test::assert_missing_field!(err, LineCodeBuilderError, "line_code");
326    }
327
328    #[test]
329    fn line_code_line_code_is_necessary() {
330        let err = LineCode::builder()
331            .type_(LineType::Old)
332            .build()
333            .unwrap_err();
334        crate::test::assert_missing_field!(err, LineCodeBuilderError, "line_code");
335    }
336
337    #[test]
338    fn line_code_type_is_necessary() {
339        let err = LineCode::builder().line_code("code").build().unwrap_err();
340        crate::test::assert_missing_field!(err, LineCodeBuilderError, "type_");
341    }
342
343    #[test]
344    fn line_code_line_code_and_type_are_sufficient() {
345        LineCode::builder()
346            .line_code("start")
347            .type_(LineType::Old)
348            .build()
349            .unwrap();
350    }
351
352    #[test]
353    fn line_range_start_and_end_are_necessary() {
354        let err = LineRange::builder().build().unwrap_err();
355        crate::test::assert_missing_field!(err, LineRangeBuilderError, "start");
356    }
357
358    #[test]
359    fn line_range_start_is_necessary() {
360        let err = LineRange::builder()
361            .end(
362                LineCode::builder()
363                    .line_code("end")
364                    .type_(LineType::Old)
365                    .build()
366                    .unwrap(),
367            )
368            .build()
369            .unwrap_err();
370        crate::test::assert_missing_field!(err, LineRangeBuilderError, "start");
371    }
372
373    #[test]
374    fn line_range_end_is_necessary() {
375        let err = LineRange::builder()
376            .start(
377                LineCode::builder()
378                    .line_code("start")
379                    .type_(LineType::Old)
380                    .build()
381                    .unwrap(),
382            )
383            .build()
384            .unwrap_err();
385        crate::test::assert_missing_field!(err, LineRangeBuilderError, "end");
386    }
387
388    #[test]
389    fn line_range_start_and_end_are_sufficient() {
390        LineRange::builder()
391            .start(
392                LineCode::builder()
393                    .line_code("start")
394                    .type_(LineType::Old)
395                    .build()
396                    .unwrap(),
397            )
398            .end(
399                LineCode::builder()
400                    .line_code("end")
401                    .type_(LineType::Old)
402                    .build()
403                    .unwrap(),
404            )
405            .build()
406            .unwrap();
407    }
408
409    #[test]
410    fn text_position_defaults_are_sufficient() {
411        TextPosition::builder().build().unwrap();
412    }
413
414    #[test]
415    fn image_position_defaults_are_sufficient() {
416        ImagePosition::builder().build().unwrap();
417    }
418
419    #[test]
420    fn file_position_type_str() {
421        let items = &[
422            (
423                FilePosition::Text(TextPosition::builder().build().unwrap()),
424                "text",
425            ),
426            (
427                FilePosition::Image(ImagePosition::builder().build().unwrap()),
428                "image",
429            ),
430        ];
431
432        for (i, s) in items {
433            assert_eq!(i.type_str(), *s);
434        }
435    }
436
437    #[test]
438    fn position_base_start_head_and_position_are_necessary() {
439        let err = Position::builder().build().unwrap_err();
440        crate::test::assert_missing_field!(err, PositionBuilderError, "base_sha");
441    }
442
443    #[test]
444    fn position_base_sha_is_necessary() {
445        let err = Position::builder()
446            .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
447            .head_sha("cafebabecafebabecafebabecafebabecafebabe")
448            .text_position(TextPosition::builder().build().unwrap())
449            .build()
450            .unwrap_err();
451        crate::test::assert_missing_field!(err, PositionBuilderError, "base_sha");
452    }
453
454    #[test]
455    fn position_start_sha_is_necessary() {
456        let err = Position::builder()
457            .base_sha("0000000000000000000000000000000000000000")
458            .head_sha("cafebabecafebabecafebabecafebabecafebabe")
459            .text_position(TextPosition::builder().build().unwrap())
460            .build()
461            .unwrap_err();
462        crate::test::assert_missing_field!(err, PositionBuilderError, "start_sha");
463    }
464
465    #[test]
466    fn position_head_sha_is_necessary() {
467        let err = Position::builder()
468            .base_sha("0000000000000000000000000000000000000000")
469            .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
470            .text_position(TextPosition::builder().build().unwrap())
471            .build()
472            .unwrap_err();
473        crate::test::assert_missing_field!(err, PositionBuilderError, "head_sha");
474    }
475
476    #[test]
477    fn position_position_is_necessary() {
478        let err = Position::builder()
479            .base_sha("0000000000000000000000000000000000000000")
480            .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
481            .head_sha("cafebabecafebabecafebabecafebabecafebabe")
482            .build()
483            .unwrap_err();
484        crate::test::assert_missing_field!(err, PositionBuilderError, "position");
485    }
486
487    #[test]
488    fn position_base_start_head_and_position_are_sufficient() {
489        Position::builder()
490            .base_sha("0000000000000000000000000000000000000000")
491            .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
492            .head_sha("cafebabecafebabecafebabecafebabecafebabe")
493            .text_position(TextPosition::builder().build().unwrap())
494            .build()
495            .unwrap();
496    }
497
498    #[test]
499    fn project_merge_request_and_body_are_necessary() {
500        let err = CreateMergeRequestDiscussion::builder().build().unwrap_err();
501        crate::test::assert_missing_field!(
502            err,
503            CreateMergeRequestDiscussionBuilderError,
504            "project",
505        );
506    }
507
508    #[test]
509    fn project_is_necessary() {
510        let err = CreateMergeRequestDiscussion::builder()
511            .merge_request(1)
512            .body("body")
513            .build()
514            .unwrap_err();
515        crate::test::assert_missing_field!(
516            err,
517            CreateMergeRequestDiscussionBuilderError,
518            "project",
519        );
520    }
521
522    #[test]
523    fn merge_request_is_necessary() {
524        let err = CreateMergeRequestDiscussion::builder()
525            .project(1)
526            .body("body")
527            .build()
528            .unwrap_err();
529        crate::test::assert_missing_field!(
530            err,
531            CreateMergeRequestDiscussionBuilderError,
532            "merge_request",
533        );
534    }
535
536    #[test]
537    fn body_is_necessary() {
538        let err = CreateMergeRequestDiscussion::builder()
539            .project(1)
540            .merge_request(1)
541            .build()
542            .unwrap_err();
543        crate::test::assert_missing_field!(err, CreateMergeRequestDiscussionBuilderError, "body");
544    }
545
546    #[test]
547    fn project_merge_request_and_body_are_sufficient() {
548        CreateMergeRequestDiscussion::builder()
549            .project(1)
550            .merge_request(1)
551            .body("body")
552            .build()
553            .unwrap();
554    }
555
556    #[test]
557    fn endpoint() {
558        let endpoint = ExpectedUrl::builder()
559            .method(Method::POST)
560            .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
561            .content_type("application/x-www-form-urlencoded")
562            .body_str("body=body")
563            .build()
564            .unwrap();
565        let client = SingleTestClient::new_raw(endpoint, "");
566
567        let endpoint = CreateMergeRequestDiscussion::builder()
568            .project("simple/project")
569            .merge_request(1)
570            .body("body")
571            .build()
572            .unwrap();
573        api::ignore(endpoint).query(&client).unwrap();
574    }
575
576    #[test]
577    fn endpoint_commit_id() {
578        let endpoint = ExpectedUrl::builder()
579            .method(Method::POST)
580            .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
581            .content_type("application/x-www-form-urlencoded")
582            .body_str(concat!(
583                "body=body",
584                "&commit_id=0000000000000000000000000000000000000000"
585            ))
586            .build()
587            .unwrap();
588        let client = SingleTestClient::new_raw(endpoint, "");
589
590        let endpoint = CreateMergeRequestDiscussion::builder()
591            .project("simple/project")
592            .merge_request(1)
593            .body("body")
594            .commit_id("0000000000000000000000000000000000000000")
595            .build()
596            .unwrap();
597        api::ignore(endpoint).query(&client).unwrap();
598    }
599
600    #[test]
601    fn endpoint_created_at() {
602        let endpoint = ExpectedUrl::builder()
603            .method(Method::POST)
604            .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
605            .content_type("application/x-www-form-urlencoded")
606            .body_str(concat!("body=body", "&created_at=2020-01-01T00%3A00%3A00Z"))
607            .build()
608            .unwrap();
609        let client = SingleTestClient::new_raw(endpoint, "");
610
611        let endpoint = CreateMergeRequestDiscussion::builder()
612            .project("simple/project")
613            .merge_request(1)
614            .body("body")
615            .created_at(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap())
616            .build()
617            .unwrap();
618        api::ignore(endpoint).query(&client).unwrap();
619    }
620
621    #[test]
622    fn endpoint_position_file() {
623        let endpoint = ExpectedUrl::builder()
624            .method(Method::POST)
625            .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
626            .content_type("application/x-www-form-urlencoded")
627            .body_str(concat!(
628                "body=body",
629                "&position%5Bbase_sha%5D=0000000000000000000000000000000000000000",
630                "&position%5Bstart_sha%5D=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
631                "&position%5Bhead_sha%5D=cafebabecafebabecafebabecafebabecafebabe",
632                "&position%5Bposition_type%5D=text",
633            ))
634            .build()
635            .unwrap();
636        let client = SingleTestClient::new_raw(endpoint, "");
637
638        let endpoint = CreateMergeRequestDiscussion::builder()
639            .project("simple/project")
640            .merge_request(1)
641            .body("body")
642            .position(
643                Position::builder()
644                    .base_sha("0000000000000000000000000000000000000000")
645                    .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
646                    .head_sha("cafebabecafebabecafebabecafebabecafebabe")
647                    .text_position(TextPosition::builder().build().unwrap())
648                    .build()
649                    .unwrap(),
650            )
651            .build()
652            .unwrap();
653        api::ignore(endpoint).query(&client).unwrap();
654    }
655
656    #[test]
657    fn endpoint_position_file_full() {
658        let endpoint = ExpectedUrl::builder()
659            .method(Method::POST)
660            .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
661            .content_type("application/x-www-form-urlencoded")
662            .body_str(concat!(
663                "body=body",
664                "&position%5Bbase_sha%5D=0000000000000000000000000000000000000000",
665                "&position%5Bstart_sha%5D=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
666                "&position%5Bhead_sha%5D=cafebabecafebabecafebabecafebabecafebabe",
667                "&position%5Bposition_type%5D=text",
668                "&position%5Bnew_path%5D=path%2Fto%2Ffile%2Fnew",
669                "&position%5Bnew_line%5D=0",
670                "&position%5Bold_path%5D=path%2Fto%2Ffile%2Fold",
671                "&position%5Bold_line%5D=0",
672                "&position%5Bline_range%5D%5Bstart%5D%5Bline_code%5D=some_complicated_line_code_thing",
673                "&position%5Bline_range%5D%5Bstart%5D%5Btype%5D=old",
674                "&position%5Bline_range%5D%5Bend%5D%5Bline_code%5D=some_complicated_line_code_thing",
675                "&position%5Bline_range%5D%5Bend%5D%5Btype%5D=new",
676            ))
677            .build()
678            .unwrap();
679        let client = SingleTestClient::new_raw(endpoint, "");
680
681        let endpoint = CreateMergeRequestDiscussion::builder()
682            .project("simple/project")
683            .merge_request(1)
684            .body("body")
685            .position(
686                Position::builder()
687                    .base_sha("0000000000000000000000000000000000000000")
688                    .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
689                    .head_sha("cafebabecafebabecafebabecafebabecafebabe")
690                    .text_position(
691                        TextPosition::builder()
692                            .new_path("path/to/file/new")
693                            .new_line(0)
694                            .old_path("path/to/file/old")
695                            .old_line(0)
696                            .line_range(
697                                LineRange::builder()
698                                    .start(
699                                        LineCode::builder()
700                                            .line_code("some_complicated_line_code_thing")
701                                            .type_(LineType::Old)
702                                            .build()
703                                            .unwrap(),
704                                    )
705                                    .end(
706                                        LineCode::builder()
707                                            .line_code("some_complicated_line_code_thing")
708                                            .type_(LineType::New)
709                                            .build()
710                                            .unwrap(),
711                                    )
712                                    .build()
713                                    .unwrap(),
714                            )
715                            .build()
716                            .unwrap(),
717                    )
718                    .build()
719                    .unwrap(),
720            )
721            .build()
722            .unwrap();
723        api::ignore(endpoint).query(&client).unwrap();
724    }
725
726    #[test]
727    fn endpoint_position_image() {
728        let endpoint = ExpectedUrl::builder()
729            .method(Method::POST)
730            .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
731            .content_type("application/x-www-form-urlencoded")
732            .body_str(concat!(
733                "body=body",
734                "&position%5Bbase_sha%5D=0000000000000000000000000000000000000000",
735                "&position%5Bstart_sha%5D=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
736                "&position%5Bhead_sha%5D=cafebabecafebabecafebabecafebabecafebabe",
737                "&position%5Bposition_type%5D=image",
738            ))
739            .build()
740            .unwrap();
741        let client = SingleTestClient::new_raw(endpoint, "");
742
743        let endpoint = CreateMergeRequestDiscussion::builder()
744            .project("simple/project")
745            .merge_request(1)
746            .body("body")
747            .position(
748                Position::builder()
749                    .base_sha("0000000000000000000000000000000000000000")
750                    .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
751                    .head_sha("cafebabecafebabecafebabecafebabecafebabe")
752                    .image_position(ImagePosition::builder().build().unwrap())
753                    .build()
754                    .unwrap(),
755            )
756            .build()
757            .unwrap();
758        api::ignore(endpoint).query(&client).unwrap();
759    }
760
761    #[test]
762    fn endpoint_position_image_full() {
763        let endpoint = ExpectedUrl::builder()
764            .method(Method::POST)
765            .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
766            .content_type("application/x-www-form-urlencoded")
767            .body_str(concat!(
768                "body=body",
769                "&position%5Bbase_sha%5D=0000000000000000000000000000000000000000",
770                "&position%5Bstart_sha%5D=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
771                "&position%5Bhead_sha%5D=cafebabecafebabecafebabecafebabecafebabe",
772                "&position%5Bposition_type%5D=image",
773                "&position%5Bwidth%5D=100",
774                "&position%5Bheight%5D=100",
775                "&position%5Bx%5D=0",
776                "&position%5By%5D=0",
777            ))
778            .build()
779            .unwrap();
780        let client = SingleTestClient::new_raw(endpoint, "");
781
782        let endpoint = CreateMergeRequestDiscussion::builder()
783            .project("simple/project")
784            .merge_request(1)
785            .body("body")
786            .position(
787                Position::builder()
788                    .base_sha("0000000000000000000000000000000000000000")
789                    .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
790                    .head_sha("cafebabecafebabecafebabecafebabecafebabe")
791                    .image_position(
792                        ImagePosition::builder()
793                            .width(100)
794                            .height(100)
795                            .x(0)
796                            .y(0)
797                            .build()
798                            .unwrap(),
799                    )
800                    .build()
801                    .unwrap(),
802            )
803            .build()
804            .unwrap();
805        api::ignore(endpoint).query(&client).unwrap();
806    }
807}