Skip to main content

grm/provider/
mod.rs

1pub mod github;
2pub mod gitlab;
3
4use std::{borrow::Cow, collections::HashMap, fmt};
5
6pub use github::Github;
7pub use gitlab::Gitlab;
8use thiserror::Error;
9
10use super::{RemoteName, RemoteUrl, auth, config, repo};
11
12#[derive(Clone, Copy, PartialEq, Eq, Debug)]
13pub enum ProtocolConfig {
14    Default,
15    ForceSsh,
16}
17
18impl ProtocolConfig {
19    pub fn force_ssh(&self) -> bool {
20        *self == Self::ForceSsh
21    }
22}
23
24pub struct Url(Cow<'static, str>);
25
26impl Url {
27    pub fn new(from: String) -> Self {
28        Self(Cow::Owned(from))
29    }
30
31    pub const fn new_static(from: &'static str) -> Self {
32        Self(Cow::Borrowed(from))
33    }
34
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39#[derive(Clone)]
40pub struct User(String);
41
42impl User {
43    pub fn new(name: String) -> Self {
44        Self(name)
45    }
46}
47
48impl From<super::config::User> for User {
49    fn from(value: super::config::User) -> Self {
50        Self(value.into_username())
51    }
52}
53
54impl fmt::Display for User {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{}", self.0)
57    }
58}
59
60#[derive(Clone)]
61pub struct Group(String);
62
63impl Group {
64    pub fn new(name: String) -> Self {
65        Self(name)
66    }
67}
68
69impl From<super::config::Group> for Group {
70    fn from(value: super::config::Group) -> Self {
71        Self(value.into_groupname())
72    }
73}
74
75impl fmt::Display for Group {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "{}", self.0)
78    }
79}
80
81const DEFAULT_REMOTE_NAME: RemoteName = RemoteName::new_static("origin");
82
83#[derive(Debug, Error)]
84pub enum Error {
85    #[error("Response error: {0}")]
86    Response(String),
87    #[error("Provider error: {0}")]
88    Provider(String),
89}
90
91#[derive(Debug, clap::ValueEnum, Clone)]
92pub enum RemoteProvider {
93    Github,
94    Gitlab,
95}
96
97impl From<config::RemoteProvider> for RemoteProvider {
98    fn from(other: config::RemoteProvider) -> Self {
99        match other {
100            config::RemoteProvider::Github => Self::Github,
101            config::RemoteProvider::Gitlab => Self::Gitlab,
102        }
103    }
104}
105
106pub fn escape(s: &str) -> String {
107    url_escape::encode_component(s).to_string()
108}
109
110#[derive(PartialEq, Eq)]
111pub struct ProjectName(String);
112
113impl ProjectName {
114    pub fn new(from: String) -> Self {
115        Self(from)
116    }
117
118    pub fn into_string(self) -> String {
119        self.0
120    }
121}
122
123impl From<repo::RepoName> for ProjectName {
124    fn from(other: repo::RepoName) -> Self {
125        Self(other.into_string())
126    }
127}
128
129impl From<ProjectName> for repo::RepoName {
130    fn from(other: ProjectName) -> Self {
131        Self::new(other.into_string())
132    }
133}
134
135#[derive(PartialEq, Eq, Hash)]
136pub struct ProjectNamespace(String);
137
138impl ProjectNamespace {
139    pub fn new(from: String) -> Self {
140        Self(from)
141    }
142
143    pub fn into_string(self) -> String {
144        self.0
145    }
146
147    pub fn as_str(&self) -> &str {
148        &self.0
149    }
150}
151
152impl From<repo::RepoNamespace> for ProjectNamespace {
153    fn from(other: repo::RepoNamespace) -> Self {
154        Self(other.into_string())
155    }
156}
157
158impl From<ProjectNamespace> for repo::RepoNamespace {
159    fn from(other: ProjectNamespace) -> Self {
160        Self::new(other.into_string())
161    }
162}
163
164pub trait Project {
165    fn into_repo_config(
166        self,
167        remote_name: &RemoteName,
168        worktree_setup: repo::WorktreeSetup,
169        protocol_config: ProtocolConfig,
170    ) -> repo::Repo
171    where
172        Self: Sized,
173    {
174        repo::Repo {
175            name: self.name().into(),
176            namespace: self.namespace().map(Into::into),
177            worktree_setup,
178            remotes: vec![repo::Remote {
179                name: remote_name.clone(),
180                url: if protocol_config.force_ssh() || self.private() {
181                    self.ssh_url()
182                } else {
183                    self.http_url()
184                },
185                remote_type: if protocol_config.force_ssh() || self.private() {
186                    repo::RemoteType::Ssh
187                } else {
188                    repo::RemoteType::Https
189                },
190            }],
191        }
192    }
193
194    fn name(&self) -> ProjectName;
195    fn namespace(&self) -> Option<ProjectNamespace>;
196    fn ssh_url(&self) -> RemoteUrl;
197    fn http_url(&self) -> RemoteUrl;
198    fn private(&self) -> bool;
199}
200
201#[derive(Clone)]
202pub struct Filter {
203    users: Vec<User>,
204    groups: Vec<Group>,
205    owner: bool,
206    access: bool,
207}
208
209impl Filter {
210    pub fn new(users: Vec<User>, groups: Vec<Group>, owner: bool, access: bool) -> Self {
211        Self {
212            users,
213            groups,
214            owner,
215            access,
216        }
217    }
218
219    pub fn empty(&self) -> bool {
220        self.users.is_empty() && self.groups.is_empty() && !self.owner && !self.access
221    }
222}
223
224#[derive(Debug, Error)]
225pub enum ApiError<T>
226where
227    T: JsonError,
228{
229    Json(T),
230    String(String),
231}
232
233impl<T> From<String> for ApiError<T>
234where
235    T: JsonError,
236{
237    fn from(s: String) -> Self {
238        Self::String(s)
239    }
240}
241
242impl<T> From<ureq::http::header::ToStrError> for ApiError<T>
243where
244    T: JsonError,
245{
246    fn from(s: ureq::http::header::ToStrError) -> Self {
247        Self::String(s.to_string())
248    }
249}
250
251pub trait JsonError {
252    fn to_string(self) -> String;
253}
254
255pub trait Provider {
256    type Project: serde::de::DeserializeOwned + Project;
257    type Error: serde::de::DeserializeOwned + JsonError;
258
259    fn new(
260        filter: Filter,
261        secret_token: auth::AuthToken,
262        api_url_override: Option<Url>,
263    ) -> Result<Self, Error>
264    where
265        Self: Sized;
266
267    fn filter(&self) -> &Filter;
268    fn secret_token(&self) -> &auth::AuthToken;
269    fn auth_header_key() -> &'static str;
270
271    fn get_user_projects(&self, user: &User) -> Result<Vec<Self::Project>, ApiError<Self::Error>>;
272
273    fn get_group_projects(
274        &self,
275        group: &Group,
276    ) -> Result<Vec<Self::Project>, ApiError<Self::Error>>;
277
278    fn get_own_projects(&self) -> Result<Vec<Self::Project>, ApiError<Self::Error>> {
279        self.get_user_projects(&self.get_current_user()?)
280    }
281
282    fn get_accessible_projects(&self) -> Result<Vec<Self::Project>, ApiError<Self::Error>>;
283
284    fn get_current_user(&self) -> Result<User, ApiError<Self::Error>>;
285
286    ///
287    /// Calls the API at specific uri and expects a successful response of
288    /// `Vec<T>` back, or an error response U
289    ///
290    /// Handles paging with "link" HTTP headers properly and reads all pages to
291    /// the end.
292    fn call_list(
293        &self,
294        uri: &Url,
295        accept_header: Option<&str>,
296    ) -> Result<Vec<Self::Project>, ApiError<Self::Error>> {
297        match ureq::get(uri.as_str())
298            .config()
299            .http_status_as_error(false)
300            .build()
301            .header("accept", accept_header.unwrap_or("application/json"))
302            .header(
303                "authorization",
304                &format!(
305                    "{} {}",
306                    Self::auth_header_key(),
307                    &self.secret_token().access()
308                ),
309            )
310            .call()
311        {
312            Err(ureq::Error::Http(error)) => Err(format!("http error: {error}").into()),
313            Err(e) => Err(format!("unknown error: {e}").into()),
314            Ok(mut response) => {
315                if response.status().is_success() {
316                    let mut projects = vec![];
317
318                    if let Some(link_header) = response.headers().get("link") {
319                        let link_header = parse_link_header::parse(link_header.to_str()?)
320                            .map_err(|error| error.to_string())?;
321
322                        let next_page = link_header.get(&Some(String::from("next")));
323
324                        if let Some(page) = next_page {
325                            let following_repos =
326                                self.call_list(&Url::new(page.raw_uri.clone()), accept_header)?;
327                            projects.extend(following_repos);
328                        }
329                    }
330
331                    let result: Vec<Self::Project> = response
332                        .body_mut()
333                        .read_json()
334                        .map_err(|error| format!("Failed deserializing response: {error}"))?;
335
336                    projects.extend(result);
337                    Ok(projects)
338                } else {
339                    Err(ApiError::Json(response.body_mut().read_json().map_err(
340                        |error| format!("Failed deserializing error response: {error}"),
341                    )?))
342                }
343            }
344        }
345    }
346
347    fn get_repos(
348        &self,
349        worktree_setup: repo::WorktreeSetup,
350        protocol_config: ProtocolConfig,
351        remote_name: Option<RemoteName>,
352    ) -> Result<HashMap<Option<ProjectNamespace>, Vec<repo::Repo>>, Error> {
353        let mut repos = vec![];
354
355        if self.filter().owner {
356            repos.extend(self.get_own_projects().map_err(|error| {
357                Error::Response(match error {
358                    ApiError::Json(x) => x.to_string(),
359                    ApiError::String(s) => s,
360                })
361            })?);
362        }
363
364        if self.filter().access {
365            let accessible_projects = self.get_accessible_projects().map_err(|error| {
366                Error::Response(match error {
367                    ApiError::Json(x) => x.to_string(),
368                    ApiError::String(s) => s,
369                })
370            })?;
371
372            for accessible_project in accessible_projects {
373                let mut already_present = false;
374                for repo in &repos {
375                    if repo.name() == accessible_project.name()
376                        && repo.namespace() == accessible_project.namespace()
377                    {
378                        already_present = true;
379                    }
380                }
381                if !already_present {
382                    repos.push(accessible_project);
383                }
384            }
385        }
386
387        for user in &self.filter().users {
388            let user_projects = self.get_user_projects(user).map_err(|error| {
389                Error::Response(match error {
390                    ApiError::Json(x) => x.to_string(),
391                    ApiError::String(s) => s,
392                })
393            })?;
394
395            for user_project in user_projects {
396                let mut already_present = false;
397                for repo in &repos {
398                    if repo.name() == user_project.name()
399                        && repo.namespace() == user_project.namespace()
400                    {
401                        already_present = true;
402                    }
403                }
404                if !already_present {
405                    repos.push(user_project);
406                }
407            }
408        }
409
410        for group in &self.filter().groups {
411            let group_projects = self.get_group_projects(group).map_err(|error| {
412                Error::Response(format!(
413                    "group \"{}\": {}",
414                    group,
415                    match error {
416                        ApiError::Json(x) => x.to_string(),
417                        ApiError::String(s) => s,
418                    }
419                ))
420            })?;
421            for group_project in group_projects {
422                let mut already_present = false;
423                for repo in &repos {
424                    if repo.name() == group_project.name()
425                        && repo.namespace() == group_project.namespace()
426                    {
427                        already_present = true;
428                    }
429                }
430
431                if !already_present {
432                    repos.push(group_project);
433                }
434            }
435        }
436
437        let mut ret: HashMap<Option<ProjectNamespace>, Vec<repo::Repo>> = HashMap::new();
438
439        let remote_name = remote_name.unwrap_or(DEFAULT_REMOTE_NAME);
440
441        for repo in repos {
442            let namespace = repo.namespace();
443
444            let mut repo = repo.into_repo_config(&remote_name, worktree_setup, protocol_config);
445
446            // Namespace is already part of the hashmap key. I'm not too happy
447            // about the data exchange format here.
448            repo.remove_namespace();
449
450            ret.entry(namespace).or_default().push(repo);
451        }
452
453        Ok(ret)
454    }
455}
456
457fn call<T, U>(
458    uri: &str,
459    auth_header_key: &str,
460    secret_token: &auth::AuthToken,
461    accept_header: Option<&str>,
462) -> Result<T, ApiError<U>>
463where
464    T: serde::de::DeserializeOwned,
465    U: serde::de::DeserializeOwned + JsonError,
466{
467    match ureq::get(uri)
468        .header("accept", accept_header.unwrap_or("application/json"))
469        .header(
470            "authorization",
471            &format!("{} {}", &auth_header_key, &secret_token.access()),
472        )
473        .call()
474    {
475        Err(ureq::Error::Http(error)) => Err(format!("http error: {error}").into()),
476        Err(e) => Err(format!("unknown error: {e}").into()),
477        Ok(mut response) => {
478            if response.status().is_success() {
479                Ok(response
480                    .body_mut()
481                    .read_json()
482                    .map_err(|error| format!("Failed deserializing response: {error}"))?)
483            } else {
484                Err(ApiError::Json(response.body_mut().read_json().map_err(
485                    |error| format!("Failed deserializing error response: {error}"),
486                )?))
487            }
488        }
489    }
490}