gr/cmds/
merge_request.rs

1use crate::api_traits::{CommentMergeRequest, MergeRequest, RemoteProject, Timestamp};
2use crate::cli::merge_request::MergeRequestOptions;
3use crate::config::ConfigProperties;
4use crate::display::{Column, DisplayBody};
5use crate::error::{AddContext, GRError};
6use crate::git::Repo;
7use crate::io::{CmdInfo, ShellResponse, TaskRunner};
8use crate::remote::{CacheCliArgs, CacheType, GetRemoteCliArgs, ListBodyArgs, ListRemoteCliArgs};
9use crate::shell::BlockingCommand;
10use crate::{dialog, display, exec, git, remote, Cmd, Result};
11use std::fmt::{self, Display, Formatter};
12use std::{
13    fs::File,
14    io::{BufRead, BufReader, Cursor, Write},
15    sync::Arc,
16};
17
18use super::common::{self, get_user};
19use super::project::{Member, Project};
20
21/// GPT_PROMPT is a template for the GPT prompt to generate a merge request
22/// description given a list of commit messages.
23const GPT_PROMPT: &str = r#"
24Act like a professional software engineer. You have finished a new feature and
25want to create a new pull request for someone else to review your changes. You
26will be provided by a list of commit messages with the following format:
27
28- Newest commit is at the very bottom.
29- The subject of the commit message is followed by a dash `-` and its short SHA.
30- If there is a body, it will be immediately below the subject `-` short SHA line.
31
32You will provide a description of these changes. The description must not
33mention commit SHAs and it must not mention the number of commits included. Be
34concise and provide at most two paragraphs describing the changes. Use
35imperative mode, be short and to the point. The description for the pull
36request will begin with the sentence `This merge request`.
37
38The formatted output that you will provide is as follows:
39
40- First line will be the title. Keep it within 80 characters width
41- Next line is blank
42- The following lines will be the description of the pull request
43
44Below are the changes:"#;
45
46#[derive(Builder, Clone, Debug, Default)]
47#[builder(default)]
48pub struct MergeRequestResponse {
49    pub id: i64,
50    pub web_url: String,
51    pub author: String,
52    pub updated_at: String,
53    pub source_branch: String,
54    pub sha: String,
55    pub created_at: String,
56    pub title: String,
57    // For Github to filter pull requests from issues.
58    pub pull_request: String,
59    // Optional fields to display for get and list operations
60    pub description: String,
61    pub merged_at: String,
62    pub pipeline_id: Option<i64>,
63    pub pipeline_url: Option<String>,
64}
65
66impl MergeRequestResponse {
67    pub fn builder() -> MergeRequestResponseBuilder {
68        MergeRequestResponseBuilder::default()
69    }
70}
71
72impl From<MergeRequestResponse> for DisplayBody {
73    fn from(mr: MergeRequestResponse) -> DisplayBody {
74        DisplayBody {
75            columns: vec![
76                Column::new("ID", mr.id.to_string()),
77                Column::new("Title", mr.title),
78                Column::new("Source Branch", mr.source_branch),
79                Column::builder()
80                    .name("SHA".to_string())
81                    .value(mr.sha)
82                    .optional(true)
83                    .build()
84                    .unwrap(),
85                Column::builder()
86                    .name("Description".to_string())
87                    .value(mr.description)
88                    .optional(true)
89                    .build()
90                    .unwrap(),
91                Column::new("Author", mr.author),
92                Column::new("URL", mr.web_url),
93                Column::new("Updated at", mr.updated_at),
94                Column::builder()
95                    .name("Merged at".to_string())
96                    .value(mr.merged_at)
97                    .optional(true)
98                    .build()
99                    .unwrap(),
100                Column::builder()
101                    .name("Pipeline ID".to_string())
102                    .value(mr.pipeline_id.map_or("".to_string(), |id| id.to_string()))
103                    .optional(true)
104                    .build()
105                    .unwrap(),
106                Column::builder()
107                    .name("Pipeline URL".to_string())
108                    .value(mr.pipeline_url.unwrap_or("".to_string()))
109                    .optional(true)
110                    .build()
111                    .unwrap(),
112            ],
113        }
114    }
115}
116
117impl Timestamp for MergeRequestResponse {
118    fn created_at(&self) -> String {
119        self.created_at.clone()
120    }
121}
122
123#[derive(Clone, Copy, PartialEq, Debug)]
124pub enum MergeRequestState {
125    Opened,
126    Closed,
127    Merged,
128}
129
130impl TryFrom<&str> for MergeRequestState {
131    type Error = String;
132
133    fn try_from(s: &str) -> std::result::Result<Self, Self::Error> {
134        match s {
135            "opened" => Ok(MergeRequestState::Opened),
136            "closed" => Ok(MergeRequestState::Closed),
137            "merged" => Ok(MergeRequestState::Merged),
138            _ => Err(format!("Invalid merge request state: {}", s)),
139        }
140    }
141}
142
143impl Display for MergeRequestState {
144    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
145        match self {
146            MergeRequestState::Opened => write!(f, "opened"),
147            MergeRequestState::Closed => write!(f, "closed"),
148            MergeRequestState::Merged => write!(f, "merged"),
149        }
150    }
151}
152
153#[derive(Builder, Debug)]
154pub struct MergeRequestBodyArgs {
155    #[builder(default)]
156    pub title: String,
157    #[builder(default)]
158    pub description: String,
159    #[builder(default)]
160    pub source_branch: String,
161    #[builder(default)]
162    pub target_repo: String,
163    #[builder(default)]
164    pub target_branch: String,
165    #[builder(default)]
166    pub assignee: Member,
167    #[builder(default)]
168    pub reviewer: Member,
169    #[builder(default = "String::from(\"true\")")]
170    pub remove_source_branch: String,
171    #[builder(default)]
172    pub draft: bool,
173    #[builder(default)]
174    pub amend: bool,
175}
176
177impl MergeRequestBodyArgs {
178    pub fn builder() -> MergeRequestBodyArgsBuilder {
179        MergeRequestBodyArgsBuilder::default()
180    }
181}
182
183#[derive(Builder, Clone)]
184pub struct MergeRequestListBodyArgs {
185    pub state: MergeRequestState,
186    pub list_args: Option<ListBodyArgs>,
187    #[builder(default)]
188    pub assignee: Option<Member>,
189    #[builder(default)]
190    pub author: Option<Member>,
191    #[builder(default)]
192    pub reviewer: Option<Member>,
193}
194
195impl MergeRequestListBodyArgs {
196    pub fn builder() -> MergeRequestListBodyArgsBuilder {
197        MergeRequestListBodyArgsBuilder::default()
198    }
199}
200
201#[derive(Builder, Clone)]
202pub struct MergeRequestCliArgs {
203    pub title: Option<String>,
204    pub body_from_commit: Option<String>,
205    #[builder(default)]
206    pub body_from_file: Option<String>,
207    pub description: Option<String>,
208    pub description_from_file: Option<String>,
209    #[builder(default)]
210    pub assignee: Option<String>,
211    #[builder(default)]
212    pub reviewer: Option<String>,
213    #[builder(default)]
214    pub rand_reviewer: bool,
215    pub target_branch: Option<String>,
216    #[builder(default)]
217    pub target_repo: Option<String>,
218    #[builder(default)]
219    pub fetch: Option<String>,
220    #[builder(default)]
221    pub rebase: Option<String>,
222    pub auto: bool,
223    pub cache_args: CacheCliArgs,
224    pub open_browser: bool,
225    pub accept_summary: bool,
226    pub commit: Option<String>,
227    pub amend: bool,
228    pub force: bool,
229    pub draft: bool,
230    pub dry_run: bool,
231    #[builder(default)]
232    pub summary: SummaryOptions,
233    #[builder(default)]
234    pub patch: bool,
235    #[builder(default)]
236    pub gpt_prompt: bool,
237}
238
239#[derive(Clone, Debug, Default, PartialEq)]
240pub enum SummaryOptions {
241    Short,
242    Long,
243    #[default]
244    None,
245}
246
247impl MergeRequestCliArgs {
248    pub fn builder() -> MergeRequestCliArgsBuilder {
249        MergeRequestCliArgsBuilder::default()
250    }
251}
252
253/// Enum for filtering merge requests by user
254/// Me: current authenticated user
255/// Other: another username, provided by cli flags.
256#[derive(Clone, Debug, PartialEq)]
257pub enum MergeRequestUser {
258    Me,
259    Other(String),
260}
261
262#[derive(Builder)]
263pub struct MergeRequestListCliArgs {
264    pub state: MergeRequestState,
265    pub list_args: ListRemoteCliArgs,
266    // Filtering options. Make use of the builder pattern.
267    #[builder(default)]
268    pub assignee: Option<MergeRequestUser>,
269    #[builder(default)]
270    pub author: Option<MergeRequestUser>,
271    #[builder(default)]
272    pub reviewer: Option<MergeRequestUser>,
273}
274
275impl MergeRequestListCliArgs {
276    pub fn new(state: MergeRequestState, args: ListRemoteCliArgs) -> MergeRequestListCliArgs {
277        MergeRequestListCliArgs {
278            state,
279            list_args: args,
280            assignee: None,
281            author: None,
282            reviewer: None,
283        }
284    }
285    pub fn builder() -> MergeRequestListCliArgsBuilder {
286        MergeRequestListCliArgsBuilder::default()
287    }
288}
289
290#[derive(Builder)]
291pub struct MergeRequestGetCliArgs {
292    pub id: i64,
293    pub get_args: GetRemoteCliArgs,
294}
295
296impl MergeRequestGetCliArgs {
297    pub fn builder() -> MergeRequestGetCliArgsBuilder {
298        MergeRequestGetCliArgsBuilder::default()
299    }
300}
301
302#[derive(Builder)]
303pub struct CommentMergeRequestCliArgs {
304    pub id: i64,
305    pub comment: Option<String>,
306    pub comment_from_file: Option<String>,
307}
308
309impl CommentMergeRequestCliArgs {
310    pub fn builder() -> CommentMergeRequestCliArgsBuilder {
311        CommentMergeRequestCliArgsBuilder::default()
312    }
313}
314
315#[derive(Builder)]
316pub struct CommentMergeRequestListCliArgs {
317    pub id: i64,
318    pub list_args: ListRemoteCliArgs,
319}
320
321impl CommentMergeRequestListCliArgs {
322    pub fn builder() -> CommentMergeRequestListCliArgsBuilder {
323        CommentMergeRequestListCliArgsBuilder::default()
324    }
325}
326
327#[derive(Builder)]
328pub struct CommentMergeRequestListBodyArgs {
329    pub id: i64,
330    pub list_args: Option<ListBodyArgs>,
331}
332
333impl CommentMergeRequestListBodyArgs {
334    pub fn builder() -> CommentMergeRequestListBodyArgsBuilder {
335        CommentMergeRequestListBodyArgsBuilder::default()
336    }
337}
338
339#[derive(Builder)]
340pub struct CommentMergeRequestBodyArgs {
341    pub id: i64,
342    pub comment: String,
343}
344
345impl CommentMergeRequestBodyArgs {
346    pub fn builder() -> CommentMergeRequestBodyArgsBuilder {
347        CommentMergeRequestBodyArgsBuilder::default()
348    }
349}
350
351#[derive(Builder, Clone)]
352pub struct Comment {
353    pub id: i64,
354    pub body: String,
355    pub author: String,
356    pub created_at: String,
357}
358
359impl Comment {
360    pub fn builder() -> CommentBuilder {
361        CommentBuilder::default()
362    }
363}
364
365impl Timestamp for Comment {
366    fn created_at(&self) -> String {
367        self.created_at.clone()
368    }
369}
370
371impl From<Comment> for DisplayBody {
372    fn from(comment: Comment) -> Self {
373        DisplayBody::new(vec![
374            Column::new("ID", comment.id.to_string()),
375            Column::new("Body", comment.body),
376            Column::new("Author", comment.author),
377            Column::new("Created at", comment.created_at),
378        ])
379    }
380}
381
382pub fn execute(
383    options: MergeRequestOptions,
384    config: Arc<dyn ConfigProperties>,
385    domain: String,
386    path: String,
387) -> Result<()> {
388    match options {
389        MergeRequestOptions::Create(cli_args) => {
390            let mr_remote = remote::get_mr(
391                domain.clone(),
392                path.clone(),
393                config.clone(),
394                Some(&cli_args.cache_args),
395                CacheType::File,
396            )?;
397            let project_remote = remote::get_project(
398                domain,
399                path,
400                config.clone(),
401                Some(&cli_args.cache_args),
402                CacheType::File,
403            )?;
404            if let Some(commit_message) = &cli_args.commit {
405                git::add(&BlockingCommand)?;
406                git::commit(&BlockingCommand, commit_message)?;
407            }
408            let cmds = if let Some(description_file) = &cli_args.description_from_file {
409                let reader = get_reader_file_cli(description_file)?;
410                cmds(
411                    project_remote,
412                    &cli_args,
413                    Arc::new(BlockingCommand),
414                    Some(reader),
415                )
416            } else if let Some(body_from_file) = &cli_args.body_from_file {
417                let reader = get_reader_file_cli(body_from_file)?;
418                cmds(
419                    project_remote,
420                    &cli_args,
421                    Arc::new(BlockingCommand),
422                    Some(reader),
423                )
424            } else {
425                cmds(
426                    project_remote,
427                    &cli_args,
428                    Arc::new(BlockingCommand),
429                    None::<Cursor<&str>>,
430                )
431            };
432            let mr_body = get_repo_project_info(cmds)?;
433            if cli_args.summary != SummaryOptions::None {
434                return summary(mr_body, &cli_args);
435            }
436            if cli_args.patch {
437                return patch(mr_body, &cli_args);
438            }
439            open(mr_remote, config, mr_body, &cli_args)
440        }
441        MergeRequestOptions::List(cli_args) => list_merge_requests(domain, path, config, cli_args),
442        MergeRequestOptions::Merge { id } => {
443            let remote = remote::get_mr(domain, path, config, None, CacheType::None)?;
444            merge(remote, id)
445        }
446        MergeRequestOptions::Checkout { id } => {
447            // TODO: It should propagate the cache cli args.
448            let remote = remote::get_mr(domain, path, config, None, CacheType::File)?;
449            checkout(remote, id)
450        }
451        MergeRequestOptions::Close { id } => {
452            let remote = remote::get_mr(domain, path, config, None, CacheType::None)?;
453            close(remote, id)
454        }
455        MergeRequestOptions::CreateComment(cli_args) => {
456            let remote = remote::get_comment_mr(domain, path, config, None, CacheType::None)?;
457            if let Some(comment_file) = &cli_args.comment_from_file {
458                let reader = get_reader_file_cli(comment_file)?;
459                create_comment(remote, cli_args, Some(reader))
460            } else {
461                create_comment(remote, cli_args, None::<Cursor<&str>>)
462            }
463        }
464        MergeRequestOptions::ListComment(cli_args) => {
465            let remote = remote::get_comment_mr(
466                domain,
467                path,
468                config,
469                Some(&cli_args.list_args.get_args.cache_args),
470                CacheType::File,
471            )?;
472            let from_to_args = remote::validate_from_to_page(&cli_args.list_args)?;
473            let body_args = CommentMergeRequestListBodyArgs::builder()
474                .id(cli_args.id)
475                .list_args(from_to_args)
476                .build()?;
477            if cli_args.list_args.num_pages {
478                return common::num_comment_merge_request_pages(
479                    remote,
480                    body_args,
481                    std::io::stdout(),
482                );
483            }
484            if cli_args.list_args.num_resources {
485                return common::num_comment_merge_request_resources(
486                    remote,
487                    body_args,
488                    std::io::stdout(),
489                );
490            }
491            list_comments(remote, body_args, cli_args, std::io::stdout())
492        }
493        MergeRequestOptions::Get(cli_args) => {
494            let remote = remote::get_mr(
495                domain,
496                path,
497                config,
498                Some(&cli_args.get_args.cache_args),
499                CacheType::File,
500            )?;
501            get_merge_request_details(remote, cli_args, std::io::stdout())
502        }
503        MergeRequestOptions::Approve { id } => {
504            let remote = remote::get_mr(domain, path, config, None, CacheType::None)?;
505            approve(remote, id, std::io::stdout())
506        }
507    }
508}
509
510pub fn get_reader_file_cli(file_path: &str) -> Result<Box<dyn BufRead + Send + Sync>> {
511    if file_path == "-" {
512        Ok(Box::new(BufReader::new(std::io::stdin())))
513    } else {
514        let file = File::open(file_path).err_context(GRError::PreconditionNotMet(format!(
515            "Cannot open file {}",
516            file_path
517        )))?;
518        Ok(Box::new(BufReader::new(file)))
519    }
520}
521
522fn get_filter_user(
523    user: &Option<MergeRequestUser>,
524    domain: &str,
525    path: &str,
526    config: &Arc<dyn ConfigProperties>,
527    list_args: &ListRemoteCliArgs,
528) -> Result<Option<Member>> {
529    let member = match user {
530        Some(MergeRequestUser::Me) => Some(get_user(domain, path, config, list_args)?),
531        // TODO filter by specific username, not necessarily the
532        // authenticated user.
533        _ => None,
534    };
535    Ok(member)
536}
537
538pub fn list_merge_requests(
539    domain: String,
540    path: String,
541    config: Arc<dyn ConfigProperties>,
542    cli_args: MergeRequestListCliArgs,
543) -> Result<()> {
544    // Author, assignee and reviewer are mutually exclusive filters checked on
545    // cli's flags. While we do sequential calls to retrieve them it is a very
546    // fast operation. Only one ends up calling the remote to retrieve it's id.
547    let author = get_filter_user(
548        &cli_args.author,
549        &domain,
550        &path,
551        &config,
552        &cli_args.list_args,
553    )?;
554
555    let assignee = get_filter_user(
556        &cli_args.assignee,
557        &domain,
558        &path,
559        &config,
560        &cli_args.list_args,
561    )?;
562
563    let reviewer = get_filter_user(
564        &cli_args.reviewer,
565        &domain,
566        &path,
567        &config,
568        &cli_args.list_args,
569    )?;
570
571    let remote = remote::get_mr(
572        domain,
573        path,
574        config,
575        Some(&cli_args.list_args.get_args.cache_args),
576        CacheType::File,
577    )?;
578
579    let from_to_args = remote::validate_from_to_page(&cli_args.list_args)?;
580    let body_args = MergeRequestListBodyArgs::builder()
581        .list_args(from_to_args)
582        .state(cli_args.state)
583        .assignee(assignee)
584        .author(author)
585        .reviewer(reviewer)
586        .build()?;
587    if cli_args.list_args.num_pages {
588        return common::num_merge_request_pages(remote, body_args, std::io::stdout());
589    }
590    if cli_args.list_args.num_resources {
591        return common::num_merge_request_resources(remote, body_args, std::io::stdout());
592    }
593    list(remote, body_args, cli_args, std::io::stdout())
594}
595
596fn get_member(members: &[Member], username: &str) -> Option<Member> {
597    members
598        .iter()
599        .find(|member| member.username == username)
600        .cloned()
601}
602
603fn user_prompt_confirmation(
604    mr_body: &MergeRequestBody,
605    config: Arc<dyn ConfigProperties>,
606    description: String,
607    target_branch: &String,
608    cli_args: &MergeRequestCliArgs,
609) -> Result<MergeRequestBodyArgs> {
610    let mut title = mr_body.repo.title().to_string();
611    if cli_args.draft {
612        title = format!("DRAFT: {}", title);
613    }
614    // In Gitlab it is required to gather the user ID for the assignee and
615    // reviewer.
616    let members = config.merge_request_members();
617    let assignee = if cli_args.assignee.is_some() {
618        get_member(&members, &cli_args.assignee.clone().unwrap())
619    } else {
620        None
621    };
622
623    let reviewer = if cli_args.reviewer.is_some() {
624        get_member(&members, &cli_args.reviewer.clone().unwrap())
625    } else if cli_args.rand_reviewer {
626        let members = config.merge_request_members();
627        let num_members = members.len();
628        if num_members == 0 {
629            None
630        } else {
631            let rand_index = rand::random::<usize>();
632            let rand_user = members[rand_index % num_members].clone();
633            Some(rand_user)
634        }
635    } else {
636        None
637    };
638
639    let user_input = if cli_args.auto {
640        let preferred_assignee_members =
641            [assignee.unwrap_or(config.preferred_assignee_username().unwrap_or_default())];
642        dialog::MergeRequestUserInput::builder()
643            .title(title)
644            .description(description)
645            .assignee(preferred_assignee_members[0].clone())
646            .reviewer(reviewer.unwrap_or_default())
647            .build()
648            .unwrap()
649    } else {
650        dialog::prompt_user_merge_request_info(
651            &title,
652            &description,
653            assignee.as_ref(),
654            reviewer.as_ref(),
655            &config,
656        )?
657    };
658
659    Ok(MergeRequestBodyArgs::builder()
660        .title(user_input.title)
661        .description(user_input.description)
662        .source_branch(mr_body.repo.current_branch().to_string())
663        .target_branch(target_branch.to_string())
664        .assignee(user_input.assignee)
665        .reviewer(user_input.reviewer)
666        // TODO make this configurable
667        .remove_source_branch("true".to_string())
668        .draft(cli_args.draft)
669        .amend(cli_args.amend)
670        .build()?)
671}
672
673/// Open a merge request.
674fn open(
675    remote: Arc<dyn MergeRequest>,
676    config: Arc<dyn ConfigProperties>,
677    mr_body: MergeRequestBody,
678    cli_args: &MergeRequestCliArgs,
679) -> Result<()> {
680    let source_branch = &mr_body.repo.current_branch();
681    let target_branch = cli_args.target_branch.clone();
682    let target_branch = target_branch.unwrap_or(mr_body.project.default_branch().to_string());
683
684    let description = build_description(
685        mr_body.repo.last_commit_message(),
686        config.merge_request_description_signature(),
687    );
688
689    // make sure we are in a feature branch or bail
690    in_feature_branch(source_branch, &target_branch)?;
691
692    // confirm title, description and assignee
693    let args = user_prompt_confirmation(&mr_body, config, description, &target_branch, cli_args)?;
694
695    if cli_args.rebase.is_some() {
696        git::rebase(&BlockingCommand, cli_args.rebase.as_ref().unwrap())?;
697    }
698
699    let outgoing_commits = git::outgoing_commits(
700        &BlockingCommand,
701        "origin",
702        &target_branch,
703        &SummaryOptions::Short,
704    )?;
705
706    if outgoing_commits.is_empty() {
707        return Err(GRError::PreconditionNotMet(
708            "No outgoing commits found. Please commit your changes.".to_string(),
709        )
710        .into());
711    }
712
713    // show summary of merge request and confirm
714    if let Ok(()) =
715        dialog::show_summary_merge_request(&outgoing_commits, &args, cli_args.accept_summary)
716    {
717        println!("\nTaking off... 🚀\n");
718        if cli_args.dry_run {
719            println!("Dry run completed. No changes were made.");
720            return Ok(());
721        }
722        git::push(&BlockingCommand, "origin", &mr_body.repo, cli_args.force)?;
723        let merge_request_response = remote.open(args)?;
724        println!("Merge request opened: {}", merge_request_response.web_url);
725        if cli_args.open_browser {
726            open::that(merge_request_response.web_url)?;
727        }
728    }
729    Ok(())
730}
731
732/// Summary - list of outgoing commits
733fn summary(mr_body: MergeRequestBody, cli_args: &MergeRequestCliArgs) -> Result<()> {
734    let source_branch = mr_body.repo.current_branch();
735    let target_branch = cli_args.target_branch.clone();
736    let target_branch = target_branch.unwrap_or(mr_body.project.default_branch().to_string());
737
738    in_feature_branch(source_branch, &target_branch)?;
739
740    if cli_args.rebase.is_some() {
741        git::rebase(&BlockingCommand, cli_args.rebase.as_ref().unwrap())?;
742    }
743
744    let outgoing_commits = git::outgoing_commits(
745        &BlockingCommand,
746        "origin",
747        &target_branch,
748        &cli_args.summary,
749    )?;
750
751    if outgoing_commits.is_empty() {
752        return Err(GRError::PreconditionNotMet(
753            "No outgoing commits found. Please commit your changes.".to_string(),
754        )
755        .into());
756    }
757    if cli_args.gpt_prompt {
758        println!("{}", GPT_PROMPT);
759    }
760    println!("\n{}", outgoing_commits);
761    Ok(())
762}
763
764/// Creates patch for the merge request.
765fn patch(mr_body: MergeRequestBody, cli_args: &MergeRequestCliArgs) -> Result<()> {
766    let source_branch = mr_body.repo.current_branch();
767    let target_branch = cli_args.target_branch.clone();
768    let target_branch = target_branch.unwrap_or(mr_body.project.default_branch().to_string());
769
770    in_feature_branch(source_branch, &target_branch)?;
771
772    if cli_args.rebase.is_some() {
773        git::rebase(&BlockingCommand, cli_args.rebase.as_ref().unwrap())?;
774    }
775    println!(
776        "{}",
777        git::patch(&BlockingCommand, source_branch, target_branch)?
778    );
779    Ok(())
780}
781
782/// Required commands to build a Project and a Repository
783fn cmds<R: BufRead + Send + Sync + 'static>(
784    remote: Arc<dyn RemoteProject + Send + Sync + 'static>,
785    cli_args: &MergeRequestCliArgs,
786    task_runner: Arc<impl TaskRunner<Response = ShellResponse> + Send + Sync + 'static>,
787    reader: Option<R>,
788) -> Vec<Cmd<CmdInfo>> {
789    let remote_cl = remote.clone();
790    let remote_project_cmd = move || -> Result<CmdInfo> { remote_cl.get_project_data(None, None) };
791    let status_runner = task_runner.clone();
792    let git_status_cmd = || -> Result<CmdInfo> { git::status(status_runner) };
793    let current_branch_runner = task_runner.clone();
794    let git_current_branch = || -> Result<CmdInfo> { git::current_branch(current_branch_runner) };
795    let mut cmds: Vec<Cmd<CmdInfo>> = vec![
796        Box::new(remote_project_cmd),
797        Box::new(git_status_cmd),
798        Box::new(git_current_branch),
799    ];
800
801    if cli_args.body_from_file.is_some() {
802        let reader = reader.unwrap();
803        let body_from_file_cmd = move || -> Result<CmdInfo> {
804            let mut description = String::new();
805            let mut lines = reader.lines();
806            let title = lines.next().unwrap_or_else(|| Ok("".to_string()))?;
807            // skip blank line separator
808            lines.next();
809            for line in lines {
810                let line = line?;
811                description.push_str(&line);
812                description.push('\n');
813            }
814            Ok(CmdInfo::CommitBody(title, description))
815        };
816        cmds.push(Box::new(body_from_file_cmd));
817    } else {
818        // Title and description retrieval cmds
819        let title = cli_args.title.clone();
820        let title = title.unwrap_or("".to_string());
821        let body_from_commit = cli_args.body_from_commit.clone();
822        // if we are required to gather the title from specific commit, gather also
823        // its description. The description will be pulled from the same commit as
824        // the title.
825        let description_commit = cli_args.body_from_commit.clone();
826        let commit_summary_runner = task_runner.clone();
827        let git_title_cmd = move || -> Result<CmdInfo> {
828            if title.is_empty() {
829                git::commit_summary(commit_summary_runner, &body_from_commit)
830            } else {
831                Ok(CmdInfo::CommitSummary(title.clone()))
832            }
833        };
834        let description = cli_args.description.clone();
835        let description = description.unwrap_or("".to_string());
836        let commit_msg_runner = task_runner.clone();
837        let git_last_commit_message = move || -> Result<CmdInfo> {
838            if description.is_empty() {
839                if let Some(reader) = reader {
840                    let mut description = String::new();
841                    for line in reader.lines() {
842                        let line = line?;
843                        description.push_str(&line);
844                        description.push('\n');
845                    }
846                    Ok(CmdInfo::CommitMessage(description))
847                } else {
848                    git::commit_message(commit_msg_runner, &description_commit)
849                }
850            } else {
851                Ok(CmdInfo::CommitMessage(description.clone()))
852            }
853        };
854        cmds.push(Box::new(git_title_cmd));
855        cmds.push(Box::new(git_last_commit_message));
856    }
857    if cli_args.fetch.is_some() {
858        let fetch_runner = task_runner.clone();
859        let remote_alias = cli_args.fetch.as_ref().unwrap().clone();
860        let git_fetch_cmd = || -> Result<CmdInfo> { git::fetch(fetch_runner, remote_alias) };
861        cmds.push(Box::new(git_fetch_cmd));
862    }
863    cmds
864}
865
866// append description signature from the configuration
867fn build_description(description: &str, signature: &str) -> String {
868    if description.is_empty() && signature.is_empty() {
869        return "".to_string();
870    }
871    if description.is_empty() {
872        return signature.to_string();
873    }
874    if signature.is_empty() {
875        return description.to_string();
876    }
877    format!("{}\n\n{}", description, signature)
878}
879
880#[derive(Builder)]
881struct MergeRequestBody {
882    repo: Repo,
883    project: Project,
884}
885
886impl MergeRequestBody {
887    fn builder() -> MergeRequestBodyBuilder {
888        MergeRequestBodyBuilder::default()
889    }
890}
891
892fn get_repo_project_info(cmds: Vec<Cmd<CmdInfo>>) -> Result<MergeRequestBody> {
893    let mut project = Project::default();
894    let mut repo = git::Repo::default();
895    let cmd_results = exec::parallel_stream(cmds);
896    for cmd_result in cmd_results {
897        match cmd_result {
898            Ok(CmdInfo::Project(project_data)) => {
899                project = project_data;
900            }
901            Ok(CmdInfo::StatusModified(status)) => repo.with_status(status),
902            Ok(CmdInfo::Branch(branch)) => repo.with_branch(&branch),
903            Ok(CmdInfo::CommitSummary(title)) => repo.with_title(&title),
904            Ok(CmdInfo::CommitMessage(message)) => repo.with_last_commit_message(&message),
905            Ok(CmdInfo::CommitBody(title, description)) => {
906                repo.with_title(&title);
907                repo.with_last_commit_message(&description);
908            }
909            // bail on first error found
910            Err(e) => return Err(e),
911            _ => {}
912        }
913    }
914    Ok(MergeRequestBody::builder()
915        .repo(repo)
916        .project(project)
917        .build()?)
918}
919
920/// This makes sure we don't push to branches considered to be upstream in most cases.
921fn in_feature_branch(current_branch: &str, upstream_branch: &str) -> Result<()> {
922    if current_branch == upstream_branch {
923        let trace = format!(
924            "Current branch {} is the same as the upstream \
925        remote {}. Please use a feature branch",
926            current_branch, upstream_branch
927        );
928        return Err(GRError::PreconditionNotMet(trace).into());
929    }
930    // Being extra-careful. Avoid potential main, master, develop branches
931    // also.
932    match current_branch {
933        "master" | "main" | "develop" => {
934            let trace = format!(
935                "Current branch is {}, which could be a release upstream branch. \
936                Please use a different feature branch name",
937                current_branch
938            );
939            Err(GRError::PreconditionNotMet(trace).into())
940        }
941        _ => Ok(()),
942    }
943}
944
945fn list<W: Write>(
946    remote: Arc<dyn MergeRequest>,
947    body_args: MergeRequestListBodyArgs,
948    cli_args: MergeRequestListCliArgs,
949    mut writer: W,
950) -> Result<()> {
951    common::list_merge_requests(remote, body_args, cli_args, &mut writer)
952}
953
954fn merge(remote: Arc<dyn MergeRequest>, merge_request_id: i64) -> Result<()> {
955    let merge_request = remote.merge(merge_request_id)?;
956    println!("Merge request merged: {}", merge_request.web_url);
957    Ok(())
958}
959
960fn checkout(remote: Arc<dyn MergeRequest>, id: i64) -> Result<()> {
961    let merge_request = remote.get(id)?;
962    // assume origin for now
963    git::fetch(Arc::new(BlockingCommand), "origin".to_string())?;
964    git::checkout(&BlockingCommand, &merge_request.source_branch)
965}
966
967fn close(remote: Arc<dyn MergeRequest>, id: i64) -> Result<()> {
968    let merge_request = remote.close(id)?;
969    println!("Merge request closed: {}", merge_request.web_url);
970    Ok(())
971}
972
973fn approve<W: Write>(remote: Arc<dyn MergeRequest>, id: i64, mut writer: W) -> Result<()> {
974    let merge_request = remote.approve(id)?;
975    writer.write_all(format!("Merge request approved: {}\n", merge_request.web_url).as_bytes())?;
976    Ok(())
977}
978
979fn create_comment<R: BufRead>(
980    remote: Arc<dyn CommentMergeRequest>,
981    args: CommentMergeRequestCliArgs,
982    reader: Option<R>,
983) -> Result<()> {
984    let comment = if let Some(comment) = args.comment {
985        comment
986    } else {
987        let mut comment = String::new();
988        // The unwrap is Ok here. This is enforced at the CLI interface. The
989        // user is required to provide a file or a comment.
990        reader.unwrap().read_to_string(&mut comment)?;
991        comment
992    };
993    remote.create(
994        CommentMergeRequestBodyArgs::builder()
995            .id(args.id)
996            .comment(comment)
997            .build()
998            .unwrap(),
999    )
1000}
1001
1002pub fn get_merge_request_details<W: Write>(
1003    remote: Arc<dyn MergeRequest>,
1004    args: MergeRequestGetCliArgs,
1005    mut writer: W,
1006) -> Result<()> {
1007    let response = remote.get(args.id)?;
1008    display::print(&mut writer, vec![response], args.get_args)?;
1009    Ok(())
1010}
1011
1012fn list_comments<W: Write>(
1013    remote: Arc<dyn CommentMergeRequest>,
1014    body_args: CommentMergeRequestListBodyArgs,
1015    cli_args: CommentMergeRequestListCliArgs,
1016    writer: W,
1017) -> Result<()> {
1018    common::list_merge_request_comments(remote, body_args, cli_args, writer)
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use std::{
1024        io::{Cursor, Read},
1025        sync::Mutex,
1026    };
1027
1028    use crate::{
1029        api_traits::CommentMergeRequest, cli::browse::BrowseOptions,
1030        cmds::project::ProjectListBodyArgs, error,
1031    };
1032
1033    use super::*;
1034
1035    #[test]
1036    fn test_merge_request_args_with_custom_title() {
1037        let args = MergeRequestBodyArgs::builder()
1038            .source_branch("source".to_string())
1039            .target_branch("target".to_string())
1040            .title("title".to_string())
1041            .build()
1042            .unwrap();
1043
1044        assert_eq!(args.source_branch, "source");
1045        assert_eq!(args.target_branch, "target");
1046        assert_eq!(args.title, "title");
1047        assert_eq!(args.remove_source_branch, "true");
1048        assert_eq!(args.description, "");
1049    }
1050
1051    #[test]
1052    fn test_merge_request_get_all_fields() {
1053        let assignee = Member::builder()
1054            .id(1)
1055            .username("username".to_string())
1056            .build()
1057            .unwrap();
1058        let args = MergeRequestBodyArgs::builder()
1059            .source_branch("source".to_string())
1060            .target_branch("target".to_string())
1061            .title("title".to_string())
1062            .description("description".to_string())
1063            .assignee(assignee)
1064            .remove_source_branch("false".to_string())
1065            .build()
1066            .unwrap();
1067
1068        assert_eq!(args.source_branch, "source");
1069        assert_eq!(args.target_branch, "target");
1070        assert_eq!(args.title, "title");
1071        assert_eq!(args.description, "description");
1072        assert_eq!(args.assignee.id, 1);
1073        assert_eq!(args.assignee.username, "username");
1074        assert_eq!(args.remove_source_branch, "false");
1075    }
1076
1077    #[test]
1078    fn test_current_branch_should_not_be_the_upstream_branch() {
1079        let current_branch = "current-branch";
1080        let target_branch = "current-branch";
1081        let result = in_feature_branch(current_branch, target_branch);
1082        assert!(result.is_err());
1083    }
1084
1085    #[test]
1086    fn test_feature_branch_not_main_master_or_develop_is_ok() {
1087        let current_branch = "newfeature";
1088        let target_branch = "main";
1089        let result = in_feature_branch(current_branch, target_branch);
1090        assert!(result.is_ok());
1091    }
1092
1093    #[test]
1094    fn test_feature_branch_is_main_master_or_develop_should_err() {
1095        let test_cases = [
1096            ("main", "upstream-branch"),
1097            ("master", "upstream-branch"),
1098            ("develop", "upstream-branch"),
1099        ];
1100
1101        for (current_branch, upstream_branch) in test_cases {
1102            let result = in_feature_branch(current_branch, upstream_branch);
1103            assert!(result.is_err());
1104        }
1105    }
1106
1107    fn get_cmds_mock(cmd: Arc<CmdMock>) -> Vec<Cmd<CmdInfo>> {
1108        let cmd_status = cmd.clone();
1109        let git_status_cmd =
1110            move || -> Result<CmdInfo> { Ok(CmdInfo::StatusModified(cmd_status.status_modified)) };
1111        let title_cmd = cmd.clone();
1112        let git_title_cmd = move || -> Result<CmdInfo> {
1113            Ok(CmdInfo::CommitSummary(
1114                title_cmd.last_commit_summary.clone(),
1115            ))
1116        };
1117        let message_cmd = cmd.clone();
1118        let git_message_cmd = move || -> Result<CmdInfo> {
1119            Ok(CmdInfo::CommitMessage(
1120                message_cmd.last_commit_message.clone(),
1121            ))
1122        };
1123        let branch_cmd = cmd.clone();
1124        let git_current_branch =
1125            move || -> Result<CmdInfo> { Ok(CmdInfo::Branch(branch_cmd.current_branch.clone())) };
1126        let project_cmd = cmd.clone();
1127        let remote_project_cmd =
1128            move || -> Result<CmdInfo> { Ok(CmdInfo::Project(project_cmd.project.clone())) };
1129        let members_cmd = cmd.clone();
1130        let remote_members_cmd =
1131            move || -> Result<CmdInfo> { Ok(CmdInfo::Members(members_cmd.members.clone())) };
1132        let mut cmds: Vec<Cmd<CmdInfo>> = vec![
1133            Box::new(remote_project_cmd),
1134            Box::new(remote_members_cmd),
1135            Box::new(git_status_cmd),
1136            Box::new(git_title_cmd),
1137            Box::new(git_message_cmd),
1138            Box::new(git_current_branch),
1139        ];
1140        if cmd.error {
1141            let error_cmd =
1142                move || -> Result<CmdInfo> { Err(error::gen("Failure retrieving data")) };
1143            cmds.push(Box::new(error_cmd));
1144        }
1145        cmds
1146    }
1147
1148    #[derive(Clone, Builder)]
1149    struct CmdMock {
1150        #[builder(default = "false")]
1151        status_modified: bool,
1152        last_commit_summary: String,
1153        current_branch: String,
1154        last_commit_message: String,
1155        members: Vec<Member>,
1156        project: Project,
1157        #[builder(default = "false")]
1158        error: bool,
1159    }
1160
1161    #[test]
1162    fn test_get_repo_project_info() {
1163        let cmd_mock = CmdMockBuilder::default()
1164            .status_modified(true)
1165            .current_branch("current-branch".to_string())
1166            .last_commit_summary("title".to_string())
1167            .last_commit_message("last-commit-message".to_string())
1168            .members(Vec::new())
1169            .project(Project::default())
1170            .build()
1171            .unwrap();
1172        let cmds = get_cmds_mock(Arc::new(cmd_mock));
1173        let result = get_repo_project_info(cmds);
1174        assert!(result.is_ok());
1175        let result = result.unwrap();
1176        assert_eq!(result.repo.title(), "title");
1177        assert_eq!(result.repo.current_branch(), "current-branch");
1178        assert_eq!(result.repo.last_commit_message(), "last-commit-message");
1179    }
1180
1181    #[test]
1182    fn test_get_repo_project_info_cmds_error() {
1183        let cmd_mock = CmdMockBuilder::default()
1184            .status_modified(true)
1185            .current_branch("current-branch".to_string())
1186            .last_commit_summary("title".to_string())
1187            .last_commit_message("last-commit-message".to_string())
1188            .members(Vec::new())
1189            .project(Project::default())
1190            .error(true)
1191            .build()
1192            .unwrap();
1193        let cmds = get_cmds_mock(Arc::new(cmd_mock));
1194        let result = get_repo_project_info(cmds);
1195        assert!(result.is_err());
1196    }
1197
1198    #[test]
1199    fn test_get_description_signature() {
1200        let description_signature_table = [
1201            ("", "", ""),
1202            ("", "signature", "signature"),
1203            ("description", "", "description"),
1204            ("description", "signature", "description\n\nsignature"),
1205        ];
1206        for (description, signature, expected) in description_signature_table {
1207            let result = build_description(description, signature);
1208            assert_eq!(result, expected);
1209        }
1210    }
1211
1212    #[test]
1213    fn test_list_merge_requests() {
1214        let remote = Arc::new(
1215            MergeRequestRemoteMock::builder()
1216                .merge_requests(vec![MergeRequestResponse::builder()
1217                    .id(1)
1218                    .title("New feature".to_string())
1219                    .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1220                    .author("author".to_string())
1221                    .updated_at("2021-01-01".to_string())
1222                    .build()
1223                    .unwrap()])
1224                .build()
1225                .unwrap(),
1226        );
1227        let mut buf = Vec::new();
1228        let body_args = MergeRequestListBodyArgs::builder()
1229            .list_args(None)
1230            .state(MergeRequestState::Opened)
1231            .assignee(None)
1232            .build()
1233            .unwrap();
1234        let cli_args = MergeRequestListCliArgs::new(
1235            MergeRequestState::Opened,
1236            ListRemoteCliArgs::builder().build().unwrap(),
1237        );
1238        list(remote, body_args, cli_args, &mut buf).unwrap();
1239        assert_eq!(
1240            "ID|Title|Source Branch|Author|URL|Updated at\n\
1241             1|New feature||author|https://gitlab.com/owner/repo/-/merge_requests/1|2021-01-01\n",
1242            String::from_utf8(buf).unwrap(),
1243        )
1244    }
1245
1246    #[test]
1247    fn test_if_no_merge_requests_are_available_list_should_return_no_merge_requests_found() {
1248        let remote = Arc::new(MergeRequestRemoteMock::builder().build().unwrap());
1249        let mut buf = Vec::new();
1250        let body_args = MergeRequestListBodyArgs::builder()
1251            .list_args(None)
1252            .state(MergeRequestState::Opened)
1253            .assignee(None)
1254            .build()
1255            .unwrap();
1256        let cli_args = MergeRequestListCliArgs::new(
1257            MergeRequestState::Opened,
1258            ListRemoteCliArgs::builder().build().unwrap(),
1259        );
1260        list(remote, body_args, cli_args, &mut buf).unwrap();
1261        assert_eq!("No resources found.\n", String::from_utf8(buf).unwrap(),)
1262    }
1263
1264    #[test]
1265    fn test_list_merge_requests_empty_with_flush_option_no_warn_message() {
1266        let remote = Arc::new(MergeRequestRemoteMock::builder().build().unwrap());
1267        let mut buf = Vec::new();
1268        let body_args = MergeRequestListBodyArgs::builder()
1269            .list_args(None)
1270            .state(MergeRequestState::Opened)
1271            .assignee(None)
1272            .build()
1273            .unwrap();
1274        let cli_args = MergeRequestListCliArgs::new(
1275            MergeRequestState::Opened,
1276            ListRemoteCliArgs::builder().flush(true).build().unwrap(),
1277        );
1278        list(remote, body_args, cli_args, &mut buf).unwrap();
1279        assert_eq!("", String::from_utf8(buf).unwrap());
1280    }
1281
1282    #[test]
1283    fn test_list_merge_requests_no_headers() {
1284        let remote = Arc::new(
1285            MergeRequestRemoteMock::builder()
1286                .merge_requests(vec![MergeRequestResponse::builder()
1287                    .id(1)
1288                    .title("New feature".to_string())
1289                    .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1290                    .author("author".to_string())
1291                    .updated_at("2021-01-01".to_string())
1292                    .build()
1293                    .unwrap()])
1294                .build()
1295                .unwrap(),
1296        );
1297        let mut buf = Vec::new();
1298        let body_args = MergeRequestListBodyArgs::builder()
1299            .list_args(None)
1300            .state(MergeRequestState::Opened)
1301            .assignee(None)
1302            .build()
1303            .unwrap();
1304        let cli_args = MergeRequestListCliArgs::new(
1305            MergeRequestState::Opened,
1306            ListRemoteCliArgs::builder()
1307                .get_args(
1308                    GetRemoteCliArgs::builder()
1309                        .no_headers(true)
1310                        .build()
1311                        .unwrap(),
1312                )
1313                .build()
1314                .unwrap(),
1315        );
1316        list(remote, body_args, cli_args, &mut buf).unwrap();
1317        assert_eq!(
1318            "1|New feature||author|https://gitlab.com/owner/repo/-/merge_requests/1|2021-01-01\n",
1319            String::from_utf8(buf).unwrap(),
1320        )
1321    }
1322
1323    #[derive(Clone, Builder)]
1324    struct MergeRequestRemoteMock {
1325        #[builder(default = "Vec::new()")]
1326        merge_requests: Vec<MergeRequestResponse>,
1327    }
1328
1329    impl MergeRequestRemoteMock {
1330        pub fn builder() -> MergeRequestRemoteMockBuilder {
1331            MergeRequestRemoteMockBuilder::default()
1332        }
1333    }
1334
1335    impl MergeRequest for MergeRequestRemoteMock {
1336        fn open(&self, _args: MergeRequestBodyArgs) -> Result<MergeRequestResponse> {
1337            Ok(MergeRequestResponse::builder().build().unwrap())
1338        }
1339        fn list(&self, _args: MergeRequestListBodyArgs) -> Result<Vec<MergeRequestResponse>> {
1340            Ok(self.merge_requests.clone())
1341        }
1342        fn merge(&self, _id: i64) -> Result<MergeRequestResponse> {
1343            Ok(MergeRequestResponse::builder().build().unwrap())
1344        }
1345        fn get(&self, _id: i64) -> Result<MergeRequestResponse> {
1346            Ok(self.merge_requests[0].clone())
1347        }
1348        fn close(&self, _id: i64) -> Result<MergeRequestResponse> {
1349            Ok(MergeRequestResponse::builder().build().unwrap())
1350        }
1351        fn num_pages(&self, _args: MergeRequestListBodyArgs) -> Result<Option<u32>> {
1352            Ok(None)
1353        }
1354        fn approve(&self, _id: i64) -> Result<MergeRequestResponse> {
1355            Ok(self.merge_requests[0].clone())
1356        }
1357
1358        fn num_resources(
1359            &self,
1360            _args: MergeRequestListBodyArgs,
1361        ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
1362            todo!()
1363        }
1364    }
1365
1366    #[derive(Default)]
1367    struct MockRemoteProject {
1368        comment_called: Mutex<bool>,
1369        comment_argument: Mutex<String>,
1370        list_comments: Vec<Comment>,
1371    }
1372
1373    impl MockRemoteProject {
1374        fn new(comments: Vec<Comment>) -> MockRemoteProject {
1375            MockRemoteProject {
1376                comment_called: Mutex::new(false),
1377                comment_argument: Mutex::new("".to_string()),
1378                list_comments: comments,
1379            }
1380        }
1381    }
1382
1383    impl RemoteProject for MockRemoteProject {
1384        fn get_project_data(&self, _id: Option<i64>, _path: Option<&str>) -> Result<CmdInfo> {
1385            let project = Project::new(1, "main");
1386            Ok(CmdInfo::Project(project))
1387        }
1388
1389        fn get_project_members(&self) -> Result<CmdInfo> {
1390            let members = vec![
1391                Member::builder()
1392                    .id(1)
1393                    .username("user1".to_string())
1394                    .name("User 1".to_string())
1395                    .build()
1396                    .unwrap(),
1397                Member::builder()
1398                    .id(2)
1399                    .username("user2".to_string())
1400                    .name("User 2".to_string())
1401                    .build()
1402                    .unwrap(),
1403            ];
1404            Ok(CmdInfo::Members(members))
1405        }
1406
1407        fn get_url(&self, _option: BrowseOptions) -> String {
1408            todo!()
1409        }
1410
1411        fn list(&self, _args: ProjectListBodyArgs) -> Result<Vec<Project>> {
1412            todo!()
1413        }
1414
1415        fn num_pages(&self, _args: ProjectListBodyArgs) -> Result<Option<u32>> {
1416            todo!()
1417        }
1418
1419        fn num_resources(
1420            &self,
1421            _args: ProjectListBodyArgs,
1422        ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
1423            todo!()
1424        }
1425    }
1426
1427    impl CommentMergeRequest for MockRemoteProject {
1428        fn create(&self, args: CommentMergeRequestBodyArgs) -> Result<()> {
1429            let mut called = self.comment_called.lock().unwrap();
1430            *called = true;
1431            let mut argument = self.comment_argument.lock().unwrap();
1432            *argument = args.comment;
1433            Ok(())
1434        }
1435
1436        fn list(&self, _args: CommentMergeRequestListBodyArgs) -> Result<Vec<Comment>> {
1437            Ok(self.list_comments.clone())
1438        }
1439
1440        fn num_pages(&self, _args: CommentMergeRequestListBodyArgs) -> Result<Option<u32>> {
1441            todo!()
1442        }
1443
1444        fn num_resources(
1445            &self,
1446            _args: CommentMergeRequestListBodyArgs,
1447        ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
1448            todo!()
1449        }
1450    }
1451
1452    struct MockShellRunner {
1453        responses: Mutex<Vec<ShellResponse>>,
1454    }
1455
1456    impl MockShellRunner {
1457        pub fn new(response: Vec<ShellResponse>) -> MockShellRunner {
1458            MockShellRunner {
1459                responses: Mutex::new(response),
1460            }
1461        }
1462    }
1463
1464    impl TaskRunner for MockShellRunner {
1465        type Response = ShellResponse;
1466
1467        fn run<T>(&self, _cmd: T) -> Result<Self::Response>
1468        where
1469            T: IntoIterator,
1470            T::Item: AsRef<std::ffi::OsStr>,
1471        {
1472            let response = self.responses.lock().unwrap().pop().unwrap();
1473            Ok(ShellResponse::builder()
1474                .body(response.body)
1475                .build()
1476                .unwrap())
1477        }
1478    }
1479
1480    fn gen_cmd_responses() -> Vec<ShellResponse> {
1481        let responses = vec![
1482            ShellResponse::builder()
1483                .body("fetch cmd".to_string())
1484                .build()
1485                .unwrap(),
1486            ShellResponse::builder()
1487                .body("last commit message cmd".to_string())
1488                .build()
1489                .unwrap(),
1490            ShellResponse::builder()
1491                .body("title git cmd".to_string())
1492                .build()
1493                .unwrap(),
1494            ShellResponse::builder()
1495                .body("current branch cmd".to_string())
1496                .build()
1497                .unwrap(),
1498            ShellResponse::builder()
1499                .body("status cmd".to_string())
1500                .build()
1501                .unwrap(),
1502        ];
1503        responses
1504    }
1505
1506    #[test]
1507    fn test_cmds_gather_title_from_cli_arg() {
1508        let remote = Arc::new(MockRemoteProject::default());
1509        let cli_args = MergeRequestCliArgs::builder()
1510            .title(Some("title cli".to_string()))
1511            .body_from_commit(None)
1512            .description(None)
1513            .description_from_file(None)
1514            .target_branch(Some("target-branch".to_string()))
1515            .auto(false)
1516            .cache_args(CacheCliArgs::default())
1517            .open_browser(false)
1518            .accept_summary(false)
1519            .commit(Some("commit".to_string()))
1520            .draft(false)
1521            .force(false)
1522            .amend(false)
1523            .dry_run(false)
1524            .build()
1525            .unwrap();
1526
1527        let responses = gen_cmd_responses();
1528
1529        let task_runner = Arc::new(MockShellRunner::new(responses));
1530        let cmds = cmds(remote, &cli_args, task_runner, None::<Cursor<&str>>);
1531        assert_eq!(cmds.len(), 5);
1532        let cmds = cmds
1533            .into_iter()
1534            .map(|cmd| cmd())
1535            .collect::<Result<Vec<CmdInfo>>>()
1536            .unwrap();
1537        let title_result = cmds[3].clone();
1538        let title = match title_result {
1539            CmdInfo::CommitSummary(title) => title,
1540            _ => "".to_string(),
1541        };
1542        assert_eq!("title cli", title);
1543    }
1544
1545    #[test]
1546    fn test_cmds_gather_title_from_git_commit_summary() {
1547        let remote = Arc::new(MockRemoteProject::default());
1548        let cli_args = MergeRequestCliArgs::builder()
1549            .title(None)
1550            .body_from_commit(None)
1551            .description(None)
1552            .description_from_file(None)
1553            .target_branch(Some("target-branch".to_string()))
1554            .auto(false)
1555            .cache_args(CacheCliArgs::default())
1556            .open_browser(false)
1557            .accept_summary(false)
1558            .commit(None)
1559            .draft(false)
1560            .force(false)
1561            .amend(false)
1562            .dry_run(false)
1563            .build()
1564            .unwrap();
1565
1566        let responses = gen_cmd_responses();
1567        let task_runner = Arc::new(MockShellRunner::new(responses));
1568        let cmds = cmds(remote, &cli_args, task_runner, None::<Cursor<&str>>);
1569        let results = cmds
1570            .into_iter()
1571            .map(|cmd| cmd())
1572            .collect::<Result<Vec<CmdInfo>>>()
1573            .unwrap();
1574        let title_result = results[3].clone();
1575        let title = match title_result {
1576            CmdInfo::CommitSummary(title) => title,
1577            _ => "".to_string(),
1578        };
1579        assert_eq!("title git cmd", title);
1580    }
1581
1582    #[test]
1583    fn test_read_description_from_file() {
1584        let remote = Arc::new(MockRemoteProject::default());
1585        let cli_args = MergeRequestCliArgs::builder()
1586            .title(None)
1587            .body_from_commit(None)
1588            .description(None)
1589            .description_from_file(Some("description_file.txt".to_string()))
1590            .target_branch(Some("target-branch".to_string()))
1591            .auto(false)
1592            .cache_args(CacheCliArgs::default())
1593            .open_browser(false)
1594            .accept_summary(false)
1595            .commit(None)
1596            .draft(false)
1597            .force(false)
1598            .amend(false)
1599            .dry_run(false)
1600            .build()
1601            .unwrap();
1602
1603        let responses = gen_cmd_responses();
1604
1605        let task_runner = Arc::new(MockShellRunner::new(responses));
1606
1607        let description_contents = "This merge requests adds a new feature\n";
1608        let reader = Cursor::new(description_contents);
1609        let cmds = cmds(remote, &cli_args, task_runner, Some(reader));
1610        let results = cmds
1611            .into_iter()
1612            .map(|cmd| cmd())
1613            .collect::<Result<Vec<CmdInfo>>>()
1614            .unwrap();
1615        let description_result = results[4].clone();
1616        let description = match description_result {
1617            CmdInfo::CommitMessage(description) => description,
1618            _ => "".to_string(),
1619        };
1620        assert_eq!(description_contents, description);
1621    }
1622
1623    #[test]
1624    fn test_create_comment_on_a_merge_request_with_cli_comment_ok() {
1625        let remote = Arc::new(MockRemoteProject::default());
1626        let cli_args = CommentMergeRequestCliArgs::builder()
1627            .id(1)
1628            .comment(Some("All features complete, ship it".to_string()))
1629            .comment_from_file(None)
1630            .build()
1631            .unwrap();
1632        let reader = Cursor::new("comment");
1633        assert!(create_comment(remote.clone(), cli_args, Some(reader)).is_ok());
1634        assert!(remote.comment_called.lock().unwrap().clone());
1635        assert_eq!(
1636            "All features complete, ship it",
1637            remote.comment_argument.lock().unwrap().clone(),
1638        );
1639    }
1640
1641    #[test]
1642    fn test_create_comment_on_a_merge_request_with_comment_from_file_ok() {
1643        let remote = Arc::new(MockRemoteProject::default());
1644        let cli_args = CommentMergeRequestCliArgs::builder()
1645            .id(1)
1646            .comment(None)
1647            .comment_from_file(Some("comment_file.txt".to_string()))
1648            .build()
1649            .unwrap();
1650        let reader = Cursor::new("Just a long, long comment from a file");
1651        assert!(create_comment(remote.clone(), cli_args, Some(reader)).is_ok());
1652        assert!(remote.comment_called.lock().unwrap().clone());
1653        assert_eq!(
1654            "Just a long, long comment from a file",
1655            remote.comment_argument.lock().unwrap().clone(),
1656        );
1657    }
1658
1659    struct ErrorReader {}
1660
1661    impl Read for ErrorReader {
1662        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1663            Err(std::io::Error::new(
1664                std::io::ErrorKind::Other,
1665                "Error reading from reader",
1666            ))
1667        }
1668    }
1669
1670    impl BufRead for ErrorReader {
1671        fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
1672            Err(std::io::Error::new(
1673                std::io::ErrorKind::Other,
1674                "Error reading from reader",
1675            ))
1676        }
1677        fn consume(&mut self, _amt: usize) {}
1678    }
1679
1680    #[test]
1681    fn test_create_comment_on_a_merge_request_fail_to_read_comment_from_file() {
1682        let remote = Arc::new(MockRemoteProject::default());
1683        let cli_args = CommentMergeRequestCliArgs::builder()
1684            .id(1)
1685            .comment(None)
1686            .comment_from_file(Some("comment_file.txt".to_string()))
1687            .build()
1688            .unwrap();
1689        let reader = ErrorReader {};
1690        assert!(create_comment(remote.clone(), cli_args, Some(reader)).is_err());
1691    }
1692
1693    #[test]
1694    fn test_get_merge_request_details() {
1695        let cli_args = MergeRequestGetCliArgs::builder()
1696            .id(1)
1697            .get_args(
1698                GetRemoteCliArgs::builder()
1699                    .display_optional(true)
1700                    .build()
1701                    .unwrap(),
1702            )
1703            .build()
1704            .unwrap();
1705        let response = MergeRequestResponse::builder()
1706            .id(1)
1707            .title("New feature".to_string())
1708            .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1709            .description("Implement get merge request".to_string())
1710            .merged_at("2024-03-03T00:00:00Z".to_string())
1711            .pipeline_id(Some(1))
1712            .pipeline_url(Some(
1713                "https://gitlab.com/owner/repo/-/pipelines/1".to_string(),
1714            ))
1715            .build()
1716            .unwrap();
1717        let remote = Arc::new(
1718            MergeRequestRemoteMock::builder()
1719                .merge_requests(vec![response])
1720                .build()
1721                .unwrap(),
1722        );
1723        let mut writer = Vec::new();
1724        get_merge_request_details(remote, cli_args, &mut writer).unwrap();
1725        assert_eq!(
1726            "ID|Title|Source Branch|SHA|Description|Author|URL|Updated at|Merged at|Pipeline ID|Pipeline URL\n\
1727             1|New feature|||Implement get merge request||https://gitlab.com/owner/repo/-/merge_requests/1||2024-03-03T00:00:00Z|1|https://gitlab.com/owner/repo/-/pipelines/1\n",
1728            String::from_utf8(writer).unwrap(),
1729        )
1730    }
1731
1732    #[test]
1733    fn test_approve_merge_request_ok() {
1734        let approve_response = MergeRequestResponse::builder()
1735            .id(1)
1736            .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1737            .build()
1738            .unwrap();
1739        let remote = Arc::new(
1740            MergeRequestRemoteMock::builder()
1741                .merge_requests(vec![approve_response])
1742                .build()
1743                .unwrap(),
1744        );
1745        let mut writer = Vec::new();
1746        approve(remote, 1, &mut writer).unwrap();
1747        assert_eq!(
1748            "Merge request approved: https://gitlab.com/owner/repo/-/merge_requests/1\n",
1749            String::from_utf8(writer).unwrap(),
1750        );
1751    }
1752
1753    #[test]
1754    fn test_cmds_fetch_cli_arg() {
1755        let remote = Arc::new(MockRemoteProject::default());
1756        let cli_args = MergeRequestCliArgs::builder()
1757            .title(Some("title cli".to_string()))
1758            .body_from_commit(None)
1759            .description(None)
1760            .description_from_file(None)
1761            .target_branch(Some("target-branch".to_string()))
1762            .fetch(Some("origin".to_string()))
1763            .auto(false)
1764            .cache_args(CacheCliArgs::default())
1765            .open_browser(false)
1766            .accept_summary(false)
1767            .commit(Some("commit".to_string()))
1768            .draft(false)
1769            .force(false)
1770            .amend(false)
1771            .dry_run(false)
1772            .build()
1773            .unwrap();
1774
1775        let responses = gen_cmd_responses();
1776
1777        let task_runner = Arc::new(MockShellRunner::new(responses));
1778        let cmds = cmds(remote, &cli_args, task_runner, None::<Cursor<&str>>);
1779        assert_eq!(cmds.len(), 6);
1780        let cmds = cmds
1781            .into_iter()
1782            .map(|cmd| cmd())
1783            .collect::<Result<Vec<CmdInfo>>>()
1784            .unwrap();
1785        let fetch_result = cmds[5].clone();
1786        match fetch_result {
1787            CmdInfo::Ignore => {}
1788            _ => panic!("Expected ignore cmdinfo variant on fetch"),
1789        };
1790    }
1791
1792    #[test]
1793    fn test_list_merge_request_comments() {
1794        let comments = vec![
1795            Comment::builder()
1796                .id(1)
1797                .body("Great work!".to_string())
1798                .author("user1".to_string())
1799                .created_at("2021-01-01".to_string())
1800                .build()
1801                .unwrap(),
1802            Comment::builder()
1803                .id(2)
1804                .body("Keep it up!".to_string())
1805                .author("user2".to_string())
1806                .created_at("2021-01-02".to_string())
1807                .build()
1808                .unwrap(),
1809        ];
1810        let remote = Arc::new(MockRemoteProject::new(comments));
1811        let body_args = CommentMergeRequestListBodyArgs::builder()
1812            .id(1)
1813            .list_args(None)
1814            .build()
1815            .unwrap();
1816        let cli_args = CommentMergeRequestListCliArgs::builder()
1817            .id(1)
1818            .list_args(ListRemoteCliArgs::builder().build().unwrap())
1819            .build()
1820            .unwrap();
1821        let mut buf = Vec::new();
1822        list_comments(remote, body_args, cli_args, &mut buf).unwrap();
1823        assert_eq!(
1824            "ID|Body|Author|Created at\n\
1825             1|Great work!|user1|2021-01-01\n\
1826             2|Keep it up!|user2|2021-01-02\n",
1827            String::from_utf8(buf).unwrap(),
1828        );
1829    }
1830
1831    #[test]
1832    fn test_gather_member_from_members_list() {
1833        let members = vec![
1834            Member::builder()
1835                .id(1)
1836                .username("user1".to_string())
1837                .name("User 1".to_string())
1838                .build()
1839                .unwrap(),
1840            Member::builder()
1841                .id(2)
1842                .username("user2".to_string())
1843                .name("User 2".to_string())
1844                .build()
1845                .unwrap(),
1846        ];
1847        let assignee_username = "user2";
1848        let member = get_member(&members, assignee_username).unwrap();
1849        assert_eq!(member.username, assignee_username);
1850        assert_eq!(member.id, 2);
1851    }
1852}