patchy/commands/
pr_fetch.rs

1use std::process;
2
3use crate::commands::help;
4use crate::fail;
5use crate::flags::{is_valid_flag, Flag};
6use crate::git_commands::{
7    fetch_pull_request, is_valid_branch_name, GIT, GITHUB_REMOTE_PREFIX, GITHUB_REMOTE_SUFFIX,
8};
9use crate::success;
10use crate::types::CommandArgs;
11use crate::utils::display_link;
12use anyhow::anyhow;
13use colored::Colorize as _;
14
15use super::help::{HELP_FLAG, VERSION_FLAG};
16use super::run::parse_if_maybe_hash;
17
18/// Allow users to prefix their PRs with octothorpe, e.g. #12345 instead of 12345.
19/// This is just a QOL addition since some people may use it due to habit
20pub fn ignore_octothorpe(arg: &str) -> String {
21    if arg.starts_with('#') {
22        arg.get(1..).unwrap_or_default()
23    } else {
24        arg
25    }
26    .into()
27}
28
29pub static PR_FETCH_BRANCH_NAME_FLAG: Flag<'static> = Flag {
30    short: "-b=",
31    long: "--branch-name=",
32    description: "Choose local name for the branch belonging to the preceding pull request",
33};
34
35pub static PR_FETCH_CHECKOUT_FLAG: Flag<'static> = Flag {
36    short: "-c",
37    long: "--checkout",
38    description: "Check out the branch belonging to the first pull request",
39};
40
41pub static PR_FETCH_REPO_NAME_FLAG: Flag<'static> = Flag {
42    short: "-r=",
43    long: "--repo-name=",
44    description:
45        "Choose a github repository, using the `origin` remote of the current repository by default",
46};
47
48pub static PR_FETCH_FLAGS: &[&Flag<'static>; 5] = &[
49    &PR_FETCH_BRANCH_NAME_FLAG,
50    &PR_FETCH_CHECKOUT_FLAG,
51    &PR_FETCH_REPO_NAME_FLAG,
52    &HELP_FLAG,
53    &VERSION_FLAG,
54];
55
56pub async fn pr_fetch(args: &CommandArgs) -> anyhow::Result<()> {
57    if args.is_empty() {
58        let _ = help(Some("pr-fetch"));
59        process::exit(1);
60    }
61
62    let has_checkout_flag = PR_FETCH_CHECKOUT_FLAG.is_in(args);
63
64    let mut args = args.iter().peekable();
65
66    let mut pull_requests_with_maybe_custom_branch_names = vec![];
67
68    let mut remote_name: Option<String> = None;
69
70    let mut no_more_flags = false;
71
72    while let Some(arg) = args.next() {
73        // After "--", each argument is interpreted literally. This way, we can e.g. use filenames that are named exactly the same as flags
74        if arg == "--" {
75            no_more_flags = true;
76            continue;
77        };
78
79        if let Some(flag) = PR_FETCH_REPO_NAME_FLAG.extract_from_arg(arg) {
80            remote_name = Some(flag);
81            continue;
82        }
83
84        if arg.starts_with('-') && !no_more_flags {
85            if !is_valid_flag(arg, PR_FETCH_FLAGS) {
86                fail!("Invalid flag: {arg}");
87                let _ = help(Some("pr-fetch"));
88                process::exit(1);
89            }
90
91            // Do not consider flags as arguments
92            continue;
93        }
94
95        let arg = ignore_octothorpe(arg);
96
97        let (pull_request, hash) = parse_if_maybe_hash(&arg, "@");
98
99        if !pull_request.chars().all(char::is_numeric) {
100            fail!(
101                "The following argument couldn't be parsed as a pull request number: {arg}
102  Examples of valid pull request numbers (with custom commit hashes supported): 1154, 500, '1001@0b36296f67a80309243ea5c8892c79798c6dcf93'"
103            );
104            continue;
105        }
106
107        let next_arg = args.peek();
108        let maybe_custom_branch_name: Option<String> = next_arg.and_then(|next_arg| {
109            PR_FETCH_BRANCH_NAME_FLAG
110                .extract_from_arg(next_arg)
111                .filter(|branch_name| is_valid_branch_name(branch_name))
112        });
113
114        if maybe_custom_branch_name.is_some() {
115            args.next();
116        };
117
118        pull_requests_with_maybe_custom_branch_names.push((
119            pull_request,
120            maybe_custom_branch_name,
121            hash,
122        ));
123    }
124
125    // The user hasn't provided a custom remote, so we're going to try `origin`
126    if remote_name.is_none() {
127        let remote = GIT(&["remote", "get-url", "origin"])?;
128        if remote.starts_with(GITHUB_REMOTE_PREFIX) && remote.ends_with(GITHUB_REMOTE_SUFFIX) {
129            let start = GITHUB_REMOTE_PREFIX.len();
130            let end = remote.len() - GITHUB_REMOTE_SUFFIX.len();
131            remote_name = remote.get(start..end).map(Into::into);
132        };
133    }
134
135    let Some(remote_name) = remote_name else {
136        return Err(anyhow!(
137            "Could not get the remote, it should be in the form e.g. helix-editor/helix.",
138        ));
139    };
140
141    let client = reqwest::Client::new();
142
143    for (i, (pull_request, maybe_custom_branch_name, hash)) in
144        pull_requests_with_maybe_custom_branch_names
145            .iter()
146            .enumerate()
147    {
148        match fetch_pull_request(
149            &remote_name,
150            pull_request,
151            &client,
152            maybe_custom_branch_name.as_deref(),
153            hash.as_deref(),
154        )
155        .await
156        {
157            Ok((response, info)) => {
158                success!(
159                    "Fetched pull request {} available at branch {}{}",
160                    display_link(
161                        &format!(
162                            "{}{}{}{}",
163                            "#".bright_blue(),
164                            pull_request.bright_blue(),
165                            " ".bright_blue(),
166                            response.title.bright_blue().italic()
167                        ),
168                        &response.html_url
169                    ),
170                    info.branch.local_branch_name.bright_cyan(),
171                    hash.clone()
172                        .map(|commit_hash| format!(", at commit {}", commit_hash.bright_yellow()))
173                        .unwrap_or_default()
174                );
175
176                // Attempt to cleanup after ourselves
177                let _ = GIT(&["remote", "remove", &info.remote.local_remote_alias]);
178
179                // If user uses --checkout flag, we're going to checkout the first PR only
180                if i == 0 && has_checkout_flag {
181                    if let Err(cant_checkout) = GIT(&["checkout", &info.branch.local_branch_name]) {
182                        fail!(
183                            "Could not check out branch {}:\n{cant_checkout}",
184                            info.branch.local_branch_name
185                        );
186                    } else {
187                        success!(
188                            "Automatically checked out the first branch: {}",
189                            info.branch.local_branch_name
190                        );
191                    }
192                }
193            }
194            Err(err) => {
195                fail!("{err}");
196                continue;
197            }
198        };
199    }
200
201    Ok(())
202}