git_workspace/providers/
gitlab.rs

1use crate::providers::{
2    create_exclude_regex_set, create_include_regex_set, Provider, APP_USER_AGENT,
3};
4use crate::repository::Repository;
5use anyhow::{anyhow, Context};
6use console::style;
7use graphql_client::{GraphQLQuery, Response};
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use std::env;
11use std::fmt;
12
13// GraphQL queries we use to fetch user and group repositories.
14// Right now, annoyingly, Gitlab has a bug around GraphQL pagination:
15// https://gitlab.com/gitlab-org/gitlab/issues/33419
16// So, we don't paginate at all in these queries. I'll fix this once
17// the issue is closed.
18
19#[derive(GraphQLQuery)]
20#[graphql(
21    schema_path = "src/providers/graphql/gitlab/schema.json",
22    query_path = "src/providers/graphql/gitlab/projects.graphql",
23    response_derives = "Debug"
24)]
25pub struct Repositories;
26
27struct ProjectNode {
28    archived: bool,
29    full_path: String,
30    ssh_url: String,
31    http_url: String,
32    root_ref: Option<String>,
33}
34
35impl From<repositories::RepositoriesGroupProjectsEdgesNode> for ProjectNode {
36    fn from(item: repositories::RepositoriesGroupProjectsEdgesNode) -> Self {
37        Self {
38            archived: item.archived.unwrap(),
39            root_ref: item.repository.and_then(|r| r.root_ref),
40            ssh_url: item.ssh_url_to_repo.expect("Unknown SSH URL"),
41            http_url: item.http_url_to_repo.expect("Unknown HTTP URL"),
42            full_path: item.full_path,
43        }
44    }
45}
46
47impl From<repositories::RepositoriesNamespaceProjectsEdgesNode> for ProjectNode {
48    fn from(item: repositories::RepositoriesNamespaceProjectsEdgesNode) -> Self {
49        Self {
50            archived: item.archived.unwrap(),
51            root_ref: item.repository.and_then(|r| r.root_ref),
52            ssh_url: item.ssh_url_to_repo.expect("Unknown SSH URL"),
53            http_url: item.http_url_to_repo.expect("Unknown HTTP URL"),
54            full_path: item.full_path,
55        }
56    }
57}
58
59static DEFAULT_GITLAB_URL: &str = "https://gitlab.com";
60
61fn public_gitlab_url() -> String {
62    DEFAULT_GITLAB_URL.to_string()
63}
64
65fn default_env_var() -> String {
66    String::from("GITHUB_TOKEN")
67}
68
69#[derive(Deserialize, Serialize, Default, Debug, Eq, Ord, PartialEq, PartialOrd, clap::Parser)]
70#[serde(rename_all = "lowercase")]
71#[command(about = "Add a Gitlab user or group by name")]
72pub struct GitlabProvider {
73    /// The name of the gitlab group or namespace to add. Can include slashes.
74    pub name: String,
75    #[serde(default = "public_gitlab_url")]
76    #[arg(long = "url", default_value = DEFAULT_GITLAB_URL)]
77    /// Gitlab instance URL
78    pub url: String,
79    #[arg(long = "path", default_value = "gitlab")]
80    /// Clone repos to a specific path
81    path: String,
82    #[arg(long = "env-name", short = 'e', default_value = "GITLAB_TOKEN")]
83    #[serde(default = "default_env_var")]
84    /// Environment variable containing the auth token
85    env_var: String,
86
87    #[arg(long = "include")]
88    #[serde(default)]
89    /// Only clone repositories that match these regular expressions. The repository name
90    /// includes the user or organisation name.
91    include: Vec<String>,
92
93    #[arg(long = "auth-http")]
94    #[serde(default)]
95    /// Use HTTP authentication instead of SSH
96    auth_http: bool,
97
98    #[arg(long = "exclude")]
99    #[serde(default)]
100    /// Don't clone repositories that match these regular expressions. The repository name
101    /// includes the user or organisation name.
102    exclude: Vec<String>,
103    // Currently does not work.
104    // https://gitlab.com/gitlab-org/gitlab/issues/121595
105    //    #[arg(long = "skip-forks")]
106    //    #[arg(about = "Don't clone forked repositories")]
107    //    #[serde(default = "default_forks")]
108    //    skip_forks: bool,
109}
110
111impl fmt::Display for GitlabProvider {
112    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
113        write!(
114            f,
115            "Gitlab user/group {} at {} in directory {}, using the token stored in {}",
116            style(&self.name.to_lowercase()).green(),
117            style(&self.url).green(),
118            style(&self.path).green(),
119            style(&self.env_var).green(),
120        )
121    }
122}
123
124impl Provider for GitlabProvider {
125    fn correctly_configured(&self) -> bool {
126        let token = env::var(&self.env_var);
127        if token.is_err() {
128            println!(
129                "{}",
130                style(format!(
131                    "Error: {} environment variable is not defined",
132                    self.env_var
133                ))
134                .red()
135            );
136            println!("Create a personal access token here:");
137            println!("{}/profile/personal_access_tokens", self.url);
138            println!(
139                "Set an environment variable called {} with the value",
140                self.env_var
141            );
142            return false;
143        }
144        if self.name.ends_with('/') {
145            println!(
146                "{}",
147                style("Error: Ensure that names do not end in forward slashes").red()
148            );
149            println!("You specified: {}", self.name);
150            return false;
151        }
152        true
153    }
154    fn fetch_repositories(&self) -> anyhow::Result<Vec<Repository>> {
155        let gitlab_token = env::var(&self.env_var)
156            .with_context(|| format!("Missing {} environment variable", self.env_var))?;
157        let mut repositories = vec![];
158        let mut after = Some("".to_string());
159        let name = self.name.to_string().to_lowercase();
160
161        let include_regex_set = create_include_regex_set(&self.include)?;
162        let exclude_regex_set = create_exclude_regex_set(&self.exclude)?;
163
164        let agent = ureq::AgentBuilder::new()
165            .https_only(true)
166            .user_agent(APP_USER_AGENT)
167            .build();
168
169        loop {
170            let q = Repositories::build_query(repositories::Variables {
171                name: name.clone(),
172                after,
173            });
174            let res = agent
175                .post(format!("{}/api/graphql", self.url).as_str())
176                .set("Authorization", format!("Bearer {}", gitlab_token).as_str())
177                .set("Content-Type", "application/json")
178                .send_json(json!(&q))?;
179            let json = res.into_json()?;
180
181            let response_body: Response<repositories::ResponseData> = serde_json::from_value(json)?;
182            let data = response_body.data.expect("Missing data");
183
184            let temp_repositories: Vec<ProjectNode>;
185            // This is annoying but I'm still not sure how to unify it.
186            if data.group.is_some() {
187                let group_data = data.group.expect("Missing group").projects;
188                temp_repositories = group_data
189                    .edges
190                    .expect("missing edges")
191                    .into_iter()
192                    // Some(T) -> T
193                    .flatten()
194                    // Extract the node, which is also Some(T)
195                    .filter_map(|x| x.node)
196                    .map(ProjectNode::from)
197                    .collect();
198                after = group_data.page_info.end_cursor;
199            } else if data.namespace.is_some() {
200                let namespace_data = data.namespace.expect("Missing namespace").projects;
201                temp_repositories = namespace_data
202                    .edges
203                    .expect("missing edges")
204                    .into_iter()
205                    // Some(T) -> T
206                    .flatten()
207                    // Extract the node, which is also Some(T)
208                    .filter_map(|x| x.node)
209                    .map(ProjectNode::from)
210                    .collect();
211                after = namespace_data.page_info.end_cursor;
212            } else {
213                return Err(anyhow!(
214                    "Gitlab group/user {} could not be found. Are you sure you have access?",
215                    name
216                ));
217            }
218
219            repositories.extend(
220                temp_repositories
221                    .into_iter()
222                    .filter(|r| !r.archived)
223                    .filter(|r| include_regex_set.is_match(&r.full_path))
224                    .filter(|r| !exclude_regex_set.is_match(&r.full_path))
225                    .map(|r| {
226                        Repository::new(
227                            format!("{}/{}", self.path, r.full_path),
228                            if self.auth_http {
229                                r.http_url
230                            } else {
231                                r.ssh_url
232                            },
233                            r.root_ref,
234                            None,
235                        )
236                    }),
237            );
238
239            if after.is_none() {
240                break;
241            }
242        }
243        Ok(repositories)
244    }
245}