gr/cmds/
merge_request.rs

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