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 {}",
518            file_path
519        )))?;
520        Ok(Box::new(BufReader::new(file)))
521    }
522}
523
524fn get_filter_user(
525    user: &Option<MergeRequestUser>,
526    domain: &str,
527    path: &str,
528    config: &Arc<dyn ConfigProperties>,
529    list_args: &ListRemoteCliArgs,
530) -> Result<Option<Member>> {
531    let member = match user {
532        Some(MergeRequestUser::Me) => Some(get_user(domain, path, config, list_args)?),
533        // TODO filter by specific username, not necessarily the
534        // authenticated user.
535        _ => None,
536    };
537    Ok(member)
538}
539
540pub fn list_merge_requests(
541    domain: String,
542    path: String,
543    config: Arc<dyn ConfigProperties>,
544    cli_args: MergeRequestListCliArgs,
545) -> Result<()> {
546    // Author, assignee and reviewer are mutually exclusive filters checked on
547    // cli's flags. While we do sequential calls to retrieve them it is a very
548    // fast operation. Only one ends up calling the remote to retrieve it's id.
549    let author = get_filter_user(
550        &cli_args.author,
551        &domain,
552        &path,
553        &config,
554        &cli_args.list_args,
555    )?;
556
557    let assignee = get_filter_user(
558        &cli_args.assignee,
559        &domain,
560        &path,
561        &config,
562        &cli_args.list_args,
563    )?;
564
565    let reviewer = get_filter_user(
566        &cli_args.reviewer,
567        &domain,
568        &path,
569        &config,
570        &cli_args.list_args,
571    )?;
572
573    let remote = remote::get_mr(
574        domain,
575        path,
576        config,
577        Some(&cli_args.list_args.get_args.cache_args),
578        CacheType::File,
579    )?;
580
581    let from_to_args = remote::validate_from_to_page(&cli_args.list_args)?;
582    let body_args = MergeRequestListBodyArgs::builder()
583        .list_args(from_to_args)
584        .state(cli_args.state)
585        .assignee(assignee)
586        .author(author)
587        .reviewer(reviewer)
588        .build()?;
589    if cli_args.list_args.num_pages {
590        return common::num_merge_request_pages(remote, body_args, std::io::stdout());
591    }
592    if cli_args.list_args.num_resources {
593        return common::num_merge_request_resources(remote, body_args, std::io::stdout());
594    }
595    list(remote, body_args, cli_args, std::io::stdout())
596}
597
598fn get_member(members: &[Member], username: &str) -> Option<Member> {
599    members
600        .iter()
601        .find(|member| member.username == username)
602        .cloned()
603}
604
605fn user_prompt_confirmation(
606    mr_body: &MergeRequestBody,
607    config: Arc<dyn ConfigProperties>,
608    description: String,
609    target_branch: &String,
610    cli_args: &MergeRequestCliArgs,
611) -> Result<MergeRequestBodyArgs> {
612    let mut title = mr_body.repo.title().to_string();
613    if cli_args.draft {
614        title = format!("DRAFT: {}", title);
615    }
616    // In Gitlab it is required to gather the user ID for the assignee and
617    // reviewer.
618    let members = config.merge_request_members();
619    let assignee = if cli_args.assignee.is_some() {
620        get_member(&members, &cli_args.assignee.clone().unwrap())
621    } else {
622        None
623    };
624
625    let reviewer = if cli_args.reviewer.is_some() {
626        get_member(&members, &cli_args.reviewer.clone().unwrap())
627    } else if cli_args.rand_reviewer {
628        let members = config.merge_request_members();
629        let num_members = members.len();
630        if num_members == 0 {
631            None
632        } else {
633            let rand_index = rand::rng().random_range(0..num_members);
634            let rand_user = members[rand_index % num_members].clone();
635            Some(rand_user)
636        }
637    } else {
638        None
639    };
640
641    let user_input = if cli_args.auto {
642        let preferred_assignee_members =
643            [assignee.unwrap_or(config.preferred_assignee_username().unwrap_or_default())];
644        dialog::MergeRequestUserInput::builder()
645            .title(title)
646            .description(description)
647            .assignee(preferred_assignee_members[0].clone())
648            .reviewer(reviewer.unwrap_or_default())
649            .build()
650            .unwrap()
651    } else {
652        dialog::prompt_user_merge_request_info(
653            &title,
654            &description,
655            assignee.as_ref(),
656            reviewer.as_ref(),
657            &config,
658        )?
659    };
660
661    Ok(MergeRequestBodyArgs::builder()
662        .title(user_input.title)
663        .description(user_input.description)
664        .source_branch(mr_body.repo.current_branch().to_string())
665        .target_branch(target_branch.to_string())
666        .assignee(user_input.assignee)
667        .reviewer(user_input.reviewer)
668        // TODO make this configurable
669        .remove_source_branch("true".to_string())
670        .draft(cli_args.draft)
671        .amend(cli_args.amend)
672        .build()?)
673}
674
675/// Open a merge request.
676fn open(
677    remote: Arc<dyn MergeRequest>,
678    config: Arc<dyn ConfigProperties>,
679    mr_body: MergeRequestBody,
680    cli_args: &MergeRequestCliArgs,
681) -> Result<()> {
682    let source_branch = &mr_body.repo.current_branch();
683    let target_branch = cli_args.target_branch.clone();
684    let target_branch = target_branch.unwrap_or(mr_body.project.default_branch().to_string());
685
686    let description = build_description(
687        mr_body.repo.last_commit_message(),
688        config.merge_request_description_signature(),
689    );
690
691    // make sure we are in a feature branch or bail
692    in_feature_branch(source_branch, &target_branch)?;
693
694    // confirm title, description and assignee
695    let args = user_prompt_confirmation(&mr_body, config, description, &target_branch, cli_args)?;
696
697    if cli_args.rebase.is_some() {
698        git::rebase(&BlockingCommand, cli_args.rebase.as_ref().unwrap())?;
699    }
700
701    let outgoing_commits = git::outgoing_commits(
702        &BlockingCommand,
703        "origin",
704        &target_branch,
705        &SummaryOptions::Short,
706    )?;
707
708    if outgoing_commits.is_empty() {
709        return Err(GRError::PreconditionNotMet(
710            "No outgoing commits found. Please commit your changes.".to_string(),
711        )
712        .into());
713    }
714
715    // show summary of merge request and confirm
716    if let Ok(()) =
717        dialog::show_summary_merge_request(&outgoing_commits, &args, cli_args.accept_summary)
718    {
719        println!("\nTaking off... 🚀\n");
720        if cli_args.dry_run {
721            println!("Dry run completed. No changes were made.");
722            return Ok(());
723        }
724        git::push(&BlockingCommand, "origin", &mr_body.repo, cli_args.force)?;
725        let merge_request_response = remote.open(args)?;
726        println!("Merge request opened: {}", merge_request_response.web_url);
727        if cli_args.open_browser {
728            open::that(merge_request_response.web_url)?;
729        }
730    }
731    Ok(())
732}
733
734/// Summary - list of outgoing commits
735fn summary(mr_body: MergeRequestBody, cli_args: &MergeRequestCliArgs) -> Result<()> {
736    let source_branch = mr_body.repo.current_branch();
737    let target_branch = cli_args.target_branch.clone();
738    let target_branch = target_branch.unwrap_or(mr_body.project.default_branch().to_string());
739
740    in_feature_branch(source_branch, &target_branch)?;
741
742    if cli_args.rebase.is_some() {
743        git::rebase(&BlockingCommand, cli_args.rebase.as_ref().unwrap())?;
744    }
745
746    let outgoing_commits = git::outgoing_commits(
747        &BlockingCommand,
748        "origin",
749        &target_branch,
750        &cli_args.summary,
751    )?;
752
753    if outgoing_commits.is_empty() {
754        return Err(GRError::PreconditionNotMet(
755            "No outgoing commits found. Please commit your changes.".to_string(),
756        )
757        .into());
758    }
759    if cli_args.gpt_prompt {
760        println!("{}", GPT_PROMPT);
761    }
762    println!("\n{}", outgoing_commits);
763    Ok(())
764}
765
766/// Creates patch for the merge request.
767fn patch(mr_body: MergeRequestBody, cli_args: &MergeRequestCliArgs) -> Result<()> {
768    let source_branch = mr_body.repo.current_branch();
769    let target_branch = cli_args.target_branch.clone();
770    let target_branch = target_branch.unwrap_or(mr_body.project.default_branch().to_string());
771
772    in_feature_branch(source_branch, &target_branch)?;
773
774    if cli_args.rebase.is_some() {
775        git::rebase(&BlockingCommand, cli_args.rebase.as_ref().unwrap())?;
776    }
777    println!(
778        "{}",
779        git::patch(&BlockingCommand, source_branch, target_branch)?
780    );
781    Ok(())
782}
783
784/// Required commands to build a Project and a Repository
785fn cmds<R: BufRead + Send + Sync + 'static>(
786    remote: Arc<dyn RemoteProject + Send + Sync + 'static>,
787    cli_args: &MergeRequestCliArgs,
788    task_runner: Arc<impl TaskRunner<Response = ShellResponse> + Send + Sync + 'static>,
789    reader: Option<R>,
790) -> Vec<Cmd<CmdInfo>> {
791    let remote_cl = remote.clone();
792    let remote_project_cmd = move || -> Result<CmdInfo> { remote_cl.get_project_data(None, None) };
793    let status_runner = task_runner.clone();
794    let git_status_cmd = || -> Result<CmdInfo> { git::status(status_runner) };
795    let current_branch_runner = task_runner.clone();
796    let git_current_branch = || -> Result<CmdInfo> { git::current_branch(current_branch_runner) };
797    let mut cmds: Vec<Cmd<CmdInfo>> = vec![
798        Box::new(remote_project_cmd),
799        Box::new(git_status_cmd),
800        Box::new(git_current_branch),
801    ];
802
803    if cli_args.body_from_file.is_some() {
804        let reader = reader.unwrap();
805        let body_from_file_cmd = move || -> Result<CmdInfo> {
806            let mut description = String::new();
807            let mut lines = reader.lines();
808            let title = lines.next().unwrap_or_else(|| Ok("".to_string()))?;
809            // skip blank line separator
810            lines.next();
811            for line in lines {
812                let line = line?;
813                description.push_str(&line);
814                description.push('\n');
815            }
816            Ok(CmdInfo::CommitBody(title, description))
817        };
818        cmds.push(Box::new(body_from_file_cmd));
819    } else {
820        // Title and description retrieval cmds
821        let title = cli_args.title.clone();
822        let title = title.unwrap_or("".to_string());
823        let body_from_commit = cli_args.body_from_commit.clone();
824        // if we are required to gather the title from specific commit, gather also
825        // its description. The description will be pulled from the same commit as
826        // the title.
827        let description_commit = cli_args.body_from_commit.clone();
828        let commit_summary_runner = task_runner.clone();
829        let git_title_cmd = move || -> Result<CmdInfo> {
830            if title.is_empty() {
831                git::commit_summary(commit_summary_runner, &body_from_commit)
832            } else {
833                Ok(CmdInfo::CommitSummary(title.clone()))
834            }
835        };
836        let description = cli_args.description.clone();
837        let description = description.unwrap_or("".to_string());
838        let commit_msg_runner = task_runner.clone();
839        let git_last_commit_message = move || -> Result<CmdInfo> {
840            if description.is_empty() {
841                if let Some(reader) = reader {
842                    let mut description = String::new();
843                    for line in reader.lines() {
844                        let line = line?;
845                        description.push_str(&line);
846                        description.push('\n');
847                    }
848                    Ok(CmdInfo::CommitMessage(description))
849                } else {
850                    git::commit_message(commit_msg_runner, &description_commit)
851                }
852            } else {
853                Ok(CmdInfo::CommitMessage(description.clone()))
854            }
855        };
856        cmds.push(Box::new(git_title_cmd));
857        cmds.push(Box::new(git_last_commit_message));
858    }
859    if cli_args.fetch.is_some() {
860        let fetch_runner = task_runner.clone();
861        let remote_alias = cli_args.fetch.as_ref().unwrap().clone();
862        let git_fetch_cmd = || -> Result<CmdInfo> { git::fetch(fetch_runner, remote_alias) };
863        cmds.push(Box::new(git_fetch_cmd));
864    }
865    cmds
866}
867
868// append description signature from the configuration
869fn build_description(description: &str, signature: &str) -> String {
870    if description.is_empty() && signature.is_empty() {
871        return "".to_string();
872    }
873    if description.is_empty() {
874        return signature.to_string();
875    }
876    if signature.is_empty() {
877        return description.to_string();
878    }
879    format!("{}\n\n{}", description, signature)
880}
881
882#[derive(Builder)]
883struct MergeRequestBody {
884    repo: Repo,
885    project: Project,
886}
887
888impl MergeRequestBody {
889    fn builder() -> MergeRequestBodyBuilder {
890        MergeRequestBodyBuilder::default()
891    }
892}
893
894fn get_repo_project_info(cmds: Vec<Cmd<CmdInfo>>) -> Result<MergeRequestBody> {
895    let mut project = Project::default();
896    let mut repo = git::Repo::default();
897    let cmd_results = exec::parallel_stream(cmds);
898    for cmd_result in cmd_results {
899        match cmd_result {
900            Ok(CmdInfo::Project(project_data)) => {
901                project = project_data;
902            }
903            Ok(CmdInfo::StatusModified(status)) => repo.with_status(status),
904            Ok(CmdInfo::Branch(branch)) => repo.with_branch(&branch),
905            Ok(CmdInfo::CommitSummary(title)) => repo.with_title(&title),
906            Ok(CmdInfo::CommitMessage(message)) => repo.with_last_commit_message(&message),
907            Ok(CmdInfo::CommitBody(title, description)) => {
908                repo.with_title(&title);
909                repo.with_last_commit_message(&description);
910            }
911            // bail on first error found
912            Err(e) => return Err(e),
913            _ => {}
914        }
915    }
916    Ok(MergeRequestBody::builder()
917        .repo(repo)
918        .project(project)
919        .build()?)
920}
921
922/// This makes sure we don't push to branches considered to be upstream in most cases.
923fn in_feature_branch(current_branch: &str, upstream_branch: &str) -> Result<()> {
924    if current_branch == upstream_branch {
925        let trace = format!(
926            "Current branch {} is the same as the upstream \
927        remote {}. Please use a feature branch",
928            current_branch, upstream_branch
929        );
930        return Err(GRError::PreconditionNotMet(trace).into());
931    }
932    // Being extra-careful. Avoid potential main, master, develop branches
933    // also.
934    match current_branch {
935        "master" | "main" | "develop" => {
936            let trace = format!(
937                "Current branch is {}, which could be a release upstream branch. \
938                Please use a different feature branch name",
939                current_branch
940            );
941            Err(GRError::PreconditionNotMet(trace).into())
942        }
943        _ => Ok(()),
944    }
945}
946
947fn list<W: Write>(
948    remote: Arc<dyn MergeRequest>,
949    body_args: MergeRequestListBodyArgs,
950    cli_args: MergeRequestListCliArgs,
951    mut writer: W,
952) -> Result<()> {
953    common::list_merge_requests(remote, body_args, cli_args, &mut writer)
954}
955
956fn merge(remote: Arc<dyn MergeRequest>, merge_request_id: i64) -> Result<()> {
957    let merge_request = remote.merge(merge_request_id)?;
958    println!("Merge request merged: {}", merge_request.web_url);
959    Ok(())
960}
961
962fn checkout(remote: Arc<dyn MergeRequest>, id: i64) -> Result<()> {
963    let merge_request = remote.get(id)?;
964    // assume origin for now
965    git::fetch(Arc::new(BlockingCommand), "origin".to_string())?;
966    git::checkout(&BlockingCommand, &merge_request.source_branch)
967}
968
969fn close(remote: Arc<dyn MergeRequest>, id: i64) -> Result<()> {
970    let merge_request = remote.close(id)?;
971    println!("Merge request closed: {}", merge_request.web_url);
972    Ok(())
973}
974
975fn approve<W: Write>(remote: Arc<dyn MergeRequest>, id: i64, mut writer: W) -> Result<()> {
976    let merge_request = remote.approve(id)?;
977    writer.write_all(format!("Merge request approved: {}\n", merge_request.web_url).as_bytes())?;
978    Ok(())
979}
980
981fn create_comment<R: BufRead>(
982    remote: Arc<dyn CommentMergeRequest>,
983    args: CommentMergeRequestCliArgs,
984    reader: Option<R>,
985) -> Result<()> {
986    let comment = if let Some(comment) = args.comment {
987        comment
988    } else {
989        let mut comment = String::new();
990        // The unwrap is Ok here. This is enforced at the CLI interface. The
991        // user is required to provide a file or a comment.
992        reader.unwrap().read_to_string(&mut comment)?;
993        comment
994    };
995    remote.create(
996        CommentMergeRequestBodyArgs::builder()
997            .id(args.id)
998            .comment(comment)
999            .build()
1000            .unwrap(),
1001    )
1002}
1003
1004pub fn get_merge_request_details<W: Write>(
1005    remote: Arc<dyn MergeRequest>,
1006    args: MergeRequestGetCliArgs,
1007    mut writer: W,
1008) -> Result<()> {
1009    let response = remote.get(args.id)?;
1010    display::print(&mut writer, vec![response], args.get_args)?;
1011    Ok(())
1012}
1013
1014fn list_comments<W: Write>(
1015    remote: Arc<dyn CommentMergeRequest>,
1016    body_args: CommentMergeRequestListBodyArgs,
1017    cli_args: CommentMergeRequestListCliArgs,
1018    writer: W,
1019) -> Result<()> {
1020    common::list_merge_request_comments(remote, body_args, cli_args, writer)
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use std::{
1026        io::{Cursor, Read},
1027        sync::Mutex,
1028    };
1029
1030    use crate::{
1031        api_traits::CommentMergeRequest, cli::browse::BrowseOptions,
1032        cmds::project::ProjectListBodyArgs, error,
1033    };
1034
1035    use super::*;
1036
1037    #[test]
1038    fn test_merge_request_args_with_custom_title() {
1039        let args = MergeRequestBodyArgs::builder()
1040            .source_branch("source".to_string())
1041            .target_branch("target".to_string())
1042            .title("title".to_string())
1043            .build()
1044            .unwrap();
1045
1046        assert_eq!(args.source_branch, "source");
1047        assert_eq!(args.target_branch, "target");
1048        assert_eq!(args.title, "title");
1049        assert_eq!(args.remove_source_branch, "true");
1050        assert_eq!(args.description, "");
1051    }
1052
1053    #[test]
1054    fn test_merge_request_get_all_fields() {
1055        let assignee = Member::builder()
1056            .id(1)
1057            .username("username".to_string())
1058            .build()
1059            .unwrap();
1060        let args = MergeRequestBodyArgs::builder()
1061            .source_branch("source".to_string())
1062            .target_branch("target".to_string())
1063            .title("title".to_string())
1064            .description("description".to_string())
1065            .assignee(assignee)
1066            .remove_source_branch("false".to_string())
1067            .build()
1068            .unwrap();
1069
1070        assert_eq!(args.source_branch, "source");
1071        assert_eq!(args.target_branch, "target");
1072        assert_eq!(args.title, "title");
1073        assert_eq!(args.description, "description");
1074        assert_eq!(args.assignee.id, 1);
1075        assert_eq!(args.assignee.username, "username");
1076        assert_eq!(args.remove_source_branch, "false");
1077    }
1078
1079    #[test]
1080    fn test_current_branch_should_not_be_the_upstream_branch() {
1081        let current_branch = "current-branch";
1082        let target_branch = "current-branch";
1083        let result = in_feature_branch(current_branch, target_branch);
1084        assert!(result.is_err());
1085    }
1086
1087    #[test]
1088    fn test_feature_branch_not_main_master_or_develop_is_ok() {
1089        let current_branch = "newfeature";
1090        let target_branch = "main";
1091        let result = in_feature_branch(current_branch, target_branch);
1092        assert!(result.is_ok());
1093    }
1094
1095    #[test]
1096    fn test_feature_branch_is_main_master_or_develop_should_err() {
1097        let test_cases = [
1098            ("main", "upstream-branch"),
1099            ("master", "upstream-branch"),
1100            ("develop", "upstream-branch"),
1101        ];
1102
1103        for (current_branch, upstream_branch) in test_cases {
1104            let result = in_feature_branch(current_branch, upstream_branch);
1105            assert!(result.is_err());
1106        }
1107    }
1108
1109    fn get_cmds_mock(cmd: Arc<CmdMock>) -> Vec<Cmd<CmdInfo>> {
1110        let cmd_status = cmd.clone();
1111        let git_status_cmd =
1112            move || -> Result<CmdInfo> { Ok(CmdInfo::StatusModified(cmd_status.status_modified)) };
1113        let title_cmd = cmd.clone();
1114        let git_title_cmd = move || -> Result<CmdInfo> {
1115            Ok(CmdInfo::CommitSummary(
1116                title_cmd.last_commit_summary.clone(),
1117            ))
1118        };
1119        let message_cmd = cmd.clone();
1120        let git_message_cmd = move || -> Result<CmdInfo> {
1121            Ok(CmdInfo::CommitMessage(
1122                message_cmd.last_commit_message.clone(),
1123            ))
1124        };
1125        let branch_cmd = cmd.clone();
1126        let git_current_branch =
1127            move || -> Result<CmdInfo> { Ok(CmdInfo::Branch(branch_cmd.current_branch.clone())) };
1128        let project_cmd = cmd.clone();
1129        let remote_project_cmd =
1130            move || -> Result<CmdInfo> { Ok(CmdInfo::Project(project_cmd.project.clone())) };
1131        let members_cmd = cmd.clone();
1132        let remote_members_cmd =
1133            move || -> Result<CmdInfo> { Ok(CmdInfo::Members(members_cmd.members.clone())) };
1134        let mut cmds: Vec<Cmd<CmdInfo>> = vec![
1135            Box::new(remote_project_cmd),
1136            Box::new(remote_members_cmd),
1137            Box::new(git_status_cmd),
1138            Box::new(git_title_cmd),
1139            Box::new(git_message_cmd),
1140            Box::new(git_current_branch),
1141        ];
1142        if cmd.error {
1143            let error_cmd =
1144                move || -> Result<CmdInfo> { Err(error::gen("Failure retrieving data")) };
1145            cmds.push(Box::new(error_cmd));
1146        }
1147        cmds
1148    }
1149
1150    #[derive(Clone, Builder)]
1151    struct CmdMock {
1152        #[builder(default = "false")]
1153        status_modified: bool,
1154        last_commit_summary: String,
1155        current_branch: String,
1156        last_commit_message: String,
1157        members: Vec<Member>,
1158        project: Project,
1159        #[builder(default = "false")]
1160        error: bool,
1161    }
1162
1163    #[test]
1164    fn test_get_repo_project_info() {
1165        let cmd_mock = CmdMockBuilder::default()
1166            .status_modified(true)
1167            .current_branch("current-branch".to_string())
1168            .last_commit_summary("title".to_string())
1169            .last_commit_message("last-commit-message".to_string())
1170            .members(Vec::new())
1171            .project(Project::default())
1172            .build()
1173            .unwrap();
1174        let cmds = get_cmds_mock(Arc::new(cmd_mock));
1175        let result = get_repo_project_info(cmds);
1176        assert!(result.is_ok());
1177        let result = result.unwrap();
1178        assert_eq!(result.repo.title(), "title");
1179        assert_eq!(result.repo.current_branch(), "current-branch");
1180        assert_eq!(result.repo.last_commit_message(), "last-commit-message");
1181    }
1182
1183    #[test]
1184    fn test_get_repo_project_info_cmds_error() {
1185        let cmd_mock = CmdMockBuilder::default()
1186            .status_modified(true)
1187            .current_branch("current-branch".to_string())
1188            .last_commit_summary("title".to_string())
1189            .last_commit_message("last-commit-message".to_string())
1190            .members(Vec::new())
1191            .project(Project::default())
1192            .error(true)
1193            .build()
1194            .unwrap();
1195        let cmds = get_cmds_mock(Arc::new(cmd_mock));
1196        let result = get_repo_project_info(cmds);
1197        assert!(result.is_err());
1198    }
1199
1200    #[test]
1201    fn test_get_description_signature() {
1202        let description_signature_table = [
1203            ("", "", ""),
1204            ("", "signature", "signature"),
1205            ("description", "", "description"),
1206            ("description", "signature", "description\n\nsignature"),
1207        ];
1208        for (description, signature, expected) in description_signature_table {
1209            let result = build_description(description, signature);
1210            assert_eq!(result, expected);
1211        }
1212    }
1213
1214    #[test]
1215    fn test_list_merge_requests() {
1216        let remote = Arc::new(
1217            MergeRequestRemoteMock::builder()
1218                .merge_requests(vec![MergeRequestResponse::builder()
1219                    .id(1)
1220                    .title("New feature".to_string())
1221                    .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1222                    .author("author".to_string())
1223                    .updated_at("2021-01-01".to_string())
1224                    .build()
1225                    .unwrap()])
1226                .build()
1227                .unwrap(),
1228        );
1229        let mut buf = Vec::new();
1230        let body_args = MergeRequestListBodyArgs::builder()
1231            .list_args(None)
1232            .state(MergeRequestState::Opened)
1233            .assignee(None)
1234            .build()
1235            .unwrap();
1236        let cli_args = MergeRequestListCliArgs::new(
1237            MergeRequestState::Opened,
1238            ListRemoteCliArgs::builder().build().unwrap(),
1239        );
1240        list(remote, body_args, cli_args, &mut buf).unwrap();
1241        assert_eq!(
1242            "ID|Title|Source Branch|Author|URL|Updated at\n\
1243             1|New feature||author|https://gitlab.com/owner/repo/-/merge_requests/1|2021-01-01\n",
1244            String::from_utf8(buf).unwrap(),
1245        )
1246    }
1247
1248    #[test]
1249    fn test_if_no_merge_requests_are_available_list_should_return_no_merge_requests_found() {
1250        let remote = Arc::new(MergeRequestRemoteMock::builder().build().unwrap());
1251        let mut buf = Vec::new();
1252        let body_args = MergeRequestListBodyArgs::builder()
1253            .list_args(None)
1254            .state(MergeRequestState::Opened)
1255            .assignee(None)
1256            .build()
1257            .unwrap();
1258        let cli_args = MergeRequestListCliArgs::new(
1259            MergeRequestState::Opened,
1260            ListRemoteCliArgs::builder().build().unwrap(),
1261        );
1262        list(remote, body_args, cli_args, &mut buf).unwrap();
1263        assert_eq!("No resources found.\n", String::from_utf8(buf).unwrap(),)
1264    }
1265
1266    #[test]
1267    fn test_list_merge_requests_empty_with_flush_option_no_warn_message() {
1268        let remote = Arc::new(MergeRequestRemoteMock::builder().build().unwrap());
1269        let mut buf = Vec::new();
1270        let body_args = MergeRequestListBodyArgs::builder()
1271            .list_args(None)
1272            .state(MergeRequestState::Opened)
1273            .assignee(None)
1274            .build()
1275            .unwrap();
1276        let cli_args = MergeRequestListCliArgs::new(
1277            MergeRequestState::Opened,
1278            ListRemoteCliArgs::builder().flush(true).build().unwrap(),
1279        );
1280        list(remote, body_args, cli_args, &mut buf).unwrap();
1281        assert_eq!("", String::from_utf8(buf).unwrap());
1282    }
1283
1284    #[test]
1285    fn test_list_merge_requests_no_headers() {
1286        let remote = Arc::new(
1287            MergeRequestRemoteMock::builder()
1288                .merge_requests(vec![MergeRequestResponse::builder()
1289                    .id(1)
1290                    .title("New feature".to_string())
1291                    .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1292                    .author("author".to_string())
1293                    .updated_at("2021-01-01".to_string())
1294                    .build()
1295                    .unwrap()])
1296                .build()
1297                .unwrap(),
1298        );
1299        let mut buf = Vec::new();
1300        let body_args = MergeRequestListBodyArgs::builder()
1301            .list_args(None)
1302            .state(MergeRequestState::Opened)
1303            .assignee(None)
1304            .build()
1305            .unwrap();
1306        let cli_args = MergeRequestListCliArgs::new(
1307            MergeRequestState::Opened,
1308            ListRemoteCliArgs::builder()
1309                .get_args(
1310                    GetRemoteCliArgs::builder()
1311                        .no_headers(true)
1312                        .build()
1313                        .unwrap(),
1314                )
1315                .build()
1316                .unwrap(),
1317        );
1318        list(remote, body_args, cli_args, &mut buf).unwrap();
1319        assert_eq!(
1320            "1|New feature||author|https://gitlab.com/owner/repo/-/merge_requests/1|2021-01-01\n",
1321            String::from_utf8(buf).unwrap(),
1322        )
1323    }
1324
1325    #[derive(Clone, Builder)]
1326    struct MergeRequestRemoteMock {
1327        #[builder(default = "Vec::new()")]
1328        merge_requests: Vec<MergeRequestResponse>,
1329    }
1330
1331    impl MergeRequestRemoteMock {
1332        pub fn builder() -> MergeRequestRemoteMockBuilder {
1333            MergeRequestRemoteMockBuilder::default()
1334        }
1335    }
1336
1337    impl MergeRequest for MergeRequestRemoteMock {
1338        fn open(&self, _args: MergeRequestBodyArgs) -> Result<MergeRequestResponse> {
1339            Ok(MergeRequestResponse::builder().build().unwrap())
1340        }
1341        fn list(&self, _args: MergeRequestListBodyArgs) -> Result<Vec<MergeRequestResponse>> {
1342            Ok(self.merge_requests.clone())
1343        }
1344        fn merge(&self, _id: i64) -> Result<MergeRequestResponse> {
1345            Ok(MergeRequestResponse::builder().build().unwrap())
1346        }
1347        fn get(&self, _id: i64) -> Result<MergeRequestResponse> {
1348            Ok(self.merge_requests[0].clone())
1349        }
1350        fn close(&self, _id: i64) -> Result<MergeRequestResponse> {
1351            Ok(MergeRequestResponse::builder().build().unwrap())
1352        }
1353        fn num_pages(&self, _args: MergeRequestListBodyArgs) -> Result<Option<u32>> {
1354            Ok(None)
1355        }
1356        fn approve(&self, _id: i64) -> Result<MergeRequestResponse> {
1357            Ok(self.merge_requests[0].clone())
1358        }
1359
1360        fn num_resources(
1361            &self,
1362            _args: MergeRequestListBodyArgs,
1363        ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
1364            todo!()
1365        }
1366    }
1367
1368    #[derive(Default)]
1369    struct MockRemoteProject {
1370        comment_called: Mutex<bool>,
1371        comment_argument: Mutex<String>,
1372        list_comments: Vec<Comment>,
1373    }
1374
1375    impl MockRemoteProject {
1376        fn new(comments: Vec<Comment>) -> MockRemoteProject {
1377            MockRemoteProject {
1378                comment_called: Mutex::new(false),
1379                comment_argument: Mutex::new("".to_string()),
1380                list_comments: comments,
1381            }
1382        }
1383    }
1384
1385    impl RemoteProject for MockRemoteProject {
1386        fn get_project_data(&self, _id: Option<i64>, _path: Option<&str>) -> Result<CmdInfo> {
1387            let project = Project::new(1, "main");
1388            Ok(CmdInfo::Project(project))
1389        }
1390
1391        fn get_project_members(&self) -> Result<CmdInfo> {
1392            let members = vec![
1393                Member::builder()
1394                    .id(1)
1395                    .username("user1".to_string())
1396                    .name("User 1".to_string())
1397                    .build()
1398                    .unwrap(),
1399                Member::builder()
1400                    .id(2)
1401                    .username("user2".to_string())
1402                    .name("User 2".to_string())
1403                    .build()
1404                    .unwrap(),
1405            ];
1406            Ok(CmdInfo::Members(members))
1407        }
1408
1409        fn get_url(&self, _option: BrowseOptions) -> String {
1410            todo!()
1411        }
1412
1413        fn list(&self, _args: ProjectListBodyArgs) -> Result<Vec<Project>> {
1414            todo!()
1415        }
1416
1417        fn num_pages(&self, _args: ProjectListBodyArgs) -> Result<Option<u32>> {
1418            todo!()
1419        }
1420
1421        fn num_resources(
1422            &self,
1423            _args: ProjectListBodyArgs,
1424        ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
1425            todo!()
1426        }
1427    }
1428
1429    impl CommentMergeRequest for MockRemoteProject {
1430        fn create(&self, args: CommentMergeRequestBodyArgs) -> Result<()> {
1431            let mut called = self.comment_called.lock().unwrap();
1432            *called = true;
1433            let mut argument = self.comment_argument.lock().unwrap();
1434            *argument = args.comment;
1435            Ok(())
1436        }
1437
1438        fn list(&self, _args: CommentMergeRequestListBodyArgs) -> Result<Vec<Comment>> {
1439            Ok(self.list_comments.clone())
1440        }
1441
1442        fn num_pages(&self, _args: CommentMergeRequestListBodyArgs) -> Result<Option<u32>> {
1443            todo!()
1444        }
1445
1446        fn num_resources(
1447            &self,
1448            _args: CommentMergeRequestListBodyArgs,
1449        ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
1450            todo!()
1451        }
1452    }
1453
1454    struct MockShellRunner {
1455        responses: Mutex<Vec<ShellResponse>>,
1456    }
1457
1458    impl MockShellRunner {
1459        pub fn new(response: Vec<ShellResponse>) -> MockShellRunner {
1460            MockShellRunner {
1461                responses: Mutex::new(response),
1462            }
1463        }
1464    }
1465
1466    impl TaskRunner for MockShellRunner {
1467        type Response = ShellResponse;
1468
1469        fn run<T>(&self, _cmd: T) -> Result<Self::Response>
1470        where
1471            T: IntoIterator,
1472            T::Item: AsRef<std::ffi::OsStr>,
1473        {
1474            let response = self.responses.lock().unwrap().pop().unwrap();
1475            Ok(ShellResponse::builder()
1476                .body(response.body)
1477                .build()
1478                .unwrap())
1479        }
1480    }
1481
1482    fn gen_cmd_responses() -> Vec<ShellResponse> {
1483        let responses = vec![
1484            ShellResponse::builder()
1485                .body("fetch cmd".to_string())
1486                .build()
1487                .unwrap(),
1488            ShellResponse::builder()
1489                .body("last commit message cmd".to_string())
1490                .build()
1491                .unwrap(),
1492            ShellResponse::builder()
1493                .body("title git cmd".to_string())
1494                .build()
1495                .unwrap(),
1496            ShellResponse::builder()
1497                .body("current branch cmd".to_string())
1498                .build()
1499                .unwrap(),
1500            ShellResponse::builder()
1501                .body("status cmd".to_string())
1502                .build()
1503                .unwrap(),
1504        ];
1505        responses
1506    }
1507
1508    #[test]
1509    fn test_cmds_gather_title_from_cli_arg() {
1510        let remote = Arc::new(MockRemoteProject::default());
1511        let cli_args = MergeRequestCliArgs::builder()
1512            .title(Some("title cli".to_string()))
1513            .body_from_commit(None)
1514            .description(None)
1515            .description_from_file(None)
1516            .target_branch(Some("target-branch".to_string()))
1517            .auto(false)
1518            .cache_args(CacheCliArgs::default())
1519            .open_browser(false)
1520            .accept_summary(false)
1521            .commit(Some("commit".to_string()))
1522            .draft(false)
1523            .force(false)
1524            .amend(false)
1525            .dry_run(false)
1526            .build()
1527            .unwrap();
1528
1529        let responses = gen_cmd_responses();
1530
1531        let task_runner = Arc::new(MockShellRunner::new(responses));
1532        let cmds = cmds(remote, &cli_args, task_runner, None::<Cursor<&str>>);
1533        assert_eq!(cmds.len(), 5);
1534        let cmds = cmds
1535            .into_iter()
1536            .map(|cmd| cmd())
1537            .collect::<Result<Vec<CmdInfo>>>()
1538            .unwrap();
1539        let title_result = cmds[3].clone();
1540        let title = match title_result {
1541            CmdInfo::CommitSummary(title) => title,
1542            _ => "".to_string(),
1543        };
1544        assert_eq!("title cli", title);
1545    }
1546
1547    #[test]
1548    fn test_cmds_gather_title_from_git_commit_summary() {
1549        let remote = Arc::new(MockRemoteProject::default());
1550        let cli_args = MergeRequestCliArgs::builder()
1551            .title(None)
1552            .body_from_commit(None)
1553            .description(None)
1554            .description_from_file(None)
1555            .target_branch(Some("target-branch".to_string()))
1556            .auto(false)
1557            .cache_args(CacheCliArgs::default())
1558            .open_browser(false)
1559            .accept_summary(false)
1560            .commit(None)
1561            .draft(false)
1562            .force(false)
1563            .amend(false)
1564            .dry_run(false)
1565            .build()
1566            .unwrap();
1567
1568        let responses = gen_cmd_responses();
1569        let task_runner = Arc::new(MockShellRunner::new(responses));
1570        let cmds = cmds(remote, &cli_args, task_runner, None::<Cursor<&str>>);
1571        let results = cmds
1572            .into_iter()
1573            .map(|cmd| cmd())
1574            .collect::<Result<Vec<CmdInfo>>>()
1575            .unwrap();
1576        let title_result = results[3].clone();
1577        let title = match title_result {
1578            CmdInfo::CommitSummary(title) => title,
1579            _ => "".to_string(),
1580        };
1581        assert_eq!("title git cmd", title);
1582    }
1583
1584    #[test]
1585    fn test_read_description_from_file() {
1586        let remote = Arc::new(MockRemoteProject::default());
1587        let cli_args = MergeRequestCliArgs::builder()
1588            .title(None)
1589            .body_from_commit(None)
1590            .description(None)
1591            .description_from_file(Some("description_file.txt".to_string()))
1592            .target_branch(Some("target-branch".to_string()))
1593            .auto(false)
1594            .cache_args(CacheCliArgs::default())
1595            .open_browser(false)
1596            .accept_summary(false)
1597            .commit(None)
1598            .draft(false)
1599            .force(false)
1600            .amend(false)
1601            .dry_run(false)
1602            .build()
1603            .unwrap();
1604
1605        let responses = gen_cmd_responses();
1606
1607        let task_runner = Arc::new(MockShellRunner::new(responses));
1608
1609        let description_contents = "This merge requests adds a new feature\n";
1610        let reader = Cursor::new(description_contents);
1611        let cmds = cmds(remote, &cli_args, task_runner, Some(reader));
1612        let results = cmds
1613            .into_iter()
1614            .map(|cmd| cmd())
1615            .collect::<Result<Vec<CmdInfo>>>()
1616            .unwrap();
1617        let description_result = results[4].clone();
1618        let description = match description_result {
1619            CmdInfo::CommitMessage(description) => description,
1620            _ => "".to_string(),
1621        };
1622        assert_eq!(description_contents, description);
1623    }
1624
1625    #[test]
1626    fn test_create_comment_on_a_merge_request_with_cli_comment_ok() {
1627        let remote = Arc::new(MockRemoteProject::default());
1628        let cli_args = CommentMergeRequestCliArgs::builder()
1629            .id(1)
1630            .comment(Some("All features complete, ship it".to_string()))
1631            .comment_from_file(None)
1632            .build()
1633            .unwrap();
1634        let reader = Cursor::new("comment");
1635        assert!(create_comment(remote.clone(), cli_args, Some(reader)).is_ok());
1636        assert!(remote.comment_called.lock().unwrap().clone());
1637        assert_eq!(
1638            "All features complete, ship it",
1639            remote.comment_argument.lock().unwrap().clone(),
1640        );
1641    }
1642
1643    #[test]
1644    fn test_create_comment_on_a_merge_request_with_comment_from_file_ok() {
1645        let remote = Arc::new(MockRemoteProject::default());
1646        let cli_args = CommentMergeRequestCliArgs::builder()
1647            .id(1)
1648            .comment(None)
1649            .comment_from_file(Some("comment_file.txt".to_string()))
1650            .build()
1651            .unwrap();
1652        let reader = Cursor::new("Just a long, long comment from a file");
1653        assert!(create_comment(remote.clone(), cli_args, Some(reader)).is_ok());
1654        assert!(remote.comment_called.lock().unwrap().clone());
1655        assert_eq!(
1656            "Just a long, long comment from a file",
1657            remote.comment_argument.lock().unwrap().clone(),
1658        );
1659    }
1660
1661    struct ErrorReader {}
1662
1663    impl Read for ErrorReader {
1664        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1665            Err(std::io::Error::new(
1666                std::io::ErrorKind::Other,
1667                "Error reading from reader",
1668            ))
1669        }
1670    }
1671
1672    impl BufRead for ErrorReader {
1673        fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
1674            Err(std::io::Error::new(
1675                std::io::ErrorKind::Other,
1676                "Error reading from reader",
1677            ))
1678        }
1679        fn consume(&mut self, _amt: usize) {}
1680    }
1681
1682    #[test]
1683    fn test_create_comment_on_a_merge_request_fail_to_read_comment_from_file() {
1684        let remote = Arc::new(MockRemoteProject::default());
1685        let cli_args = CommentMergeRequestCliArgs::builder()
1686            .id(1)
1687            .comment(None)
1688            .comment_from_file(Some("comment_file.txt".to_string()))
1689            .build()
1690            .unwrap();
1691        let reader = ErrorReader {};
1692        assert!(create_comment(remote.clone(), cli_args, Some(reader)).is_err());
1693    }
1694
1695    #[test]
1696    fn test_get_merge_request_details() {
1697        let cli_args = MergeRequestGetCliArgs::builder()
1698            .id(1)
1699            .get_args(
1700                GetRemoteCliArgs::builder()
1701                    .display_optional(true)
1702                    .build()
1703                    .unwrap(),
1704            )
1705            .build()
1706            .unwrap();
1707        let response = MergeRequestResponse::builder()
1708            .id(1)
1709            .title("New feature".to_string())
1710            .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1711            .description("Implement get merge request".to_string())
1712            .merged_at("2024-03-03T00:00:00Z".to_string())
1713            .pipeline_id(Some(1))
1714            .pipeline_url(Some(
1715                "https://gitlab.com/owner/repo/-/pipelines/1".to_string(),
1716            ))
1717            .build()
1718            .unwrap();
1719        let remote = Arc::new(
1720            MergeRequestRemoteMock::builder()
1721                .merge_requests(vec![response])
1722                .build()
1723                .unwrap(),
1724        );
1725        let mut writer = Vec::new();
1726        get_merge_request_details(remote, cli_args, &mut writer).unwrap();
1727        assert_eq!(
1728            "ID|Title|Source Branch|SHA|Description|Author|URL|Updated at|Merged at|Pipeline ID|Pipeline URL\n\
1729             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",
1730            String::from_utf8(writer).unwrap(),
1731        )
1732    }
1733
1734    #[test]
1735    fn test_approve_merge_request_ok() {
1736        let approve_response = MergeRequestResponse::builder()
1737            .id(1)
1738            .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1739            .build()
1740            .unwrap();
1741        let remote = Arc::new(
1742            MergeRequestRemoteMock::builder()
1743                .merge_requests(vec![approve_response])
1744                .build()
1745                .unwrap(),
1746        );
1747        let mut writer = Vec::new();
1748        approve(remote, 1, &mut writer).unwrap();
1749        assert_eq!(
1750            "Merge request approved: https://gitlab.com/owner/repo/-/merge_requests/1\n",
1751            String::from_utf8(writer).unwrap(),
1752        );
1753    }
1754
1755    #[test]
1756    fn test_cmds_fetch_cli_arg() {
1757        let remote = Arc::new(MockRemoteProject::default());
1758        let cli_args = MergeRequestCliArgs::builder()
1759            .title(Some("title cli".to_string()))
1760            .body_from_commit(None)
1761            .description(None)
1762            .description_from_file(None)
1763            .target_branch(Some("target-branch".to_string()))
1764            .fetch(Some("origin".to_string()))
1765            .auto(false)
1766            .cache_args(CacheCliArgs::default())
1767            .open_browser(false)
1768            .accept_summary(false)
1769            .commit(Some("commit".to_string()))
1770            .draft(false)
1771            .force(false)
1772            .amend(false)
1773            .dry_run(false)
1774            .build()
1775            .unwrap();
1776
1777        let responses = gen_cmd_responses();
1778
1779        let task_runner = Arc::new(MockShellRunner::new(responses));
1780        let cmds = cmds(remote, &cli_args, task_runner, None::<Cursor<&str>>);
1781        assert_eq!(cmds.len(), 6);
1782        let cmds = cmds
1783            .into_iter()
1784            .map(|cmd| cmd())
1785            .collect::<Result<Vec<CmdInfo>>>()
1786            .unwrap();
1787        let fetch_result = cmds[5].clone();
1788        match fetch_result {
1789            CmdInfo::Ignore => {}
1790            _ => panic!("Expected ignore cmdinfo variant on fetch"),
1791        };
1792    }
1793
1794    #[test]
1795    fn test_list_merge_request_comments() {
1796        let comments = vec![
1797            Comment::builder()
1798                .id(1)
1799                .body("Great work!".to_string())
1800                .author("user1".to_string())
1801                .created_at("2021-01-01".to_string())
1802                .build()
1803                .unwrap(),
1804            Comment::builder()
1805                .id(2)
1806                .body("Keep it up!".to_string())
1807                .author("user2".to_string())
1808                .created_at("2021-01-02".to_string())
1809                .build()
1810                .unwrap(),
1811        ];
1812        let remote = Arc::new(MockRemoteProject::new(comments));
1813        let body_args = CommentMergeRequestListBodyArgs::builder()
1814            .id(1)
1815            .list_args(None)
1816            .build()
1817            .unwrap();
1818        let cli_args = CommentMergeRequestListCliArgs::builder()
1819            .id(1)
1820            .list_args(ListRemoteCliArgs::builder().build().unwrap())
1821            .build()
1822            .unwrap();
1823        let mut buf = Vec::new();
1824        list_comments(remote, body_args, cli_args, &mut buf).unwrap();
1825        assert_eq!(
1826            "ID|Body|Author|Created at\n\
1827             1|Great work!|user1|2021-01-01\n\
1828             2|Keep it up!|user2|2021-01-02\n",
1829            String::from_utf8(buf).unwrap(),
1830        );
1831    }
1832
1833    #[test]
1834    fn test_gather_member_from_members_list() {
1835        let members = vec![
1836            Member::builder()
1837                .id(1)
1838                .username("user1".to_string())
1839                .name("User 1".to_string())
1840                .build()
1841                .unwrap(),
1842            Member::builder()
1843                .id(2)
1844                .username("user2".to_string())
1845                .name("User 2".to_string())
1846                .build()
1847                .unwrap(),
1848        ];
1849        let assignee_username = "user2";
1850        let member = get_member(&members, assignee_username).unwrap();
1851        assert_eq!(member.username, assignee_username);
1852        assert_eq!(member.id, 2);
1853    }
1854}