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    fn is_fork(&self) -> bool;
200}
201
202#[derive(Clone)]
203pub struct Filter {
204    users: Vec<User>,
205    groups: Vec<Group>,
206    owner: bool,
207    access: bool,
208    fork: bool,
209}
210
211impl Filter {
212    pub fn new(
213        users: Vec<User>,
214        groups: Vec<Group>,
215        owner: bool,
216        access: bool,
217        fork: bool,
218    ) -> Self {
219        Self {
220            users,
221            groups,
222            owner,
223            access,
224            fork,
225        }
226    }
227
228    pub fn empty(&self) -> bool {
229        self.users.is_empty() && self.groups.is_empty() && !self.owner && !self.access
230    }
231}
232
233#[derive(Debug, Error)]
234pub enum ApiError<T>
235where
236    T: JsonError,
237{
238    Json(T),
239    String(String),
240}
241
242impl<T> From<String> for ApiError<T>
243where
244    T: JsonError,
245{
246    fn from(s: String) -> Self {
247        Self::String(s)
248    }
249}
250
251impl<T> From<ureq::http::header::ToStrError> for ApiError<T>
252where
253    T: JsonError,
254{
255    fn from(s: ureq::http::header::ToStrError) -> Self {
256        Self::String(s.to_string())
257    }
258}
259
260pub trait JsonError {
261    fn to_string(self) -> String;
262}
263
264pub trait Provider {
265    type Project: serde::de::DeserializeOwned + Project;
266    type Error: serde::de::DeserializeOwned + JsonError;
267
268    fn new(
269        filter: Filter,
270        secret_token: auth::AuthToken,
271        api_url_override: Option<Url>,
272    ) -> Result<Self, Error>
273    where
274        Self: Sized;
275
276    fn filter(&self) -> &Filter;
277    fn secret_token(&self) -> &auth::AuthToken;
278    fn auth_header_key() -> &'static str;
279
280    fn get_user_projects(&self, user: &User) -> Result<Vec<Self::Project>, ApiError<Self::Error>>;
281
282    fn get_group_projects(
283        &self,
284        group: &Group,
285    ) -> Result<Vec<Self::Project>, ApiError<Self::Error>>;
286
287    fn get_own_projects(&self) -> Result<Vec<Self::Project>, ApiError<Self::Error>> {
288        self.get_user_projects(&self.get_current_user()?)
289    }
290
291    fn get_accessible_projects(&self) -> Result<Vec<Self::Project>, ApiError<Self::Error>>;
292
293    fn get_current_user(&self) -> Result<User, ApiError<Self::Error>>;
294
295    ///
296    /// Calls the API at specific uri and expects a successful response of
297    /// `Vec<T>` back, or an error response U
298    ///
299    /// Handles paging with "link" HTTP headers properly and reads all pages to
300    /// the end.
301    fn call_list(
302        &self,
303        uri: &Url,
304        accept_header: Option<&str>,
305    ) -> Result<Vec<Self::Project>, ApiError<Self::Error>> {
306        match ureq::get(uri.as_str())
307            .config()
308            .http_status_as_error(false)
309            .build()
310            .header("accept", accept_header.unwrap_or("application/json"))
311            .header(
312                "authorization",
313                &format!(
314                    "{} {}",
315                    Self::auth_header_key(),
316                    &self.secret_token().access()
317                ),
318            )
319            .call()
320        {
321            Err(ureq::Error::Http(error)) => Err(format!("http error: {error}").into()),
322            Err(e) => Err(format!("unknown error: {e}").into()),
323            Ok(mut response) => {
324                if response.status().is_success() {
325                    let mut projects = vec![];
326
327                    if let Some(link_header) = response.headers().get("link") {
328                        let link_header = parse_link_header::parse(link_header.to_str()?)
329                            .map_err(|error| error.to_string())?;
330
331                        let next_page = link_header.get(&Some(String::from("next")));
332
333                        if let Some(page) = next_page {
334                            let following_repos =
335                                self.call_list(&Url::new(page.raw_uri.clone()), accept_header)?;
336                            projects.extend(following_repos);
337                        }
338                    }
339
340                    let result: Vec<Self::Project> = response
341                        .body_mut()
342                        .read_json()
343                        .map_err(|error| format!("Failed deserializing response: {error}"))?;
344
345                    projects.extend(result);
346                    Ok(projects)
347                } else {
348                    Err(ApiError::Json(response.body_mut().read_json().map_err(
349                        |error| format!("Failed deserializing error response: {error}"),
350                    )?))
351                }
352            }
353        }
354    }
355
356    fn get_repos(
357        &self,
358        worktree_setup: repo::WorktreeSetup,
359        protocol_config: ProtocolConfig,
360        remote_name: Option<RemoteName>,
361    ) -> Result<HashMap<Option<ProjectNamespace>, Vec<repo::Repo>>, Error> {
362        let mut repos = vec![];
363
364        if self.filter().owner {
365            repos.extend(self.get_own_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
373        if self.filter().access {
374            let accessible_projects = self.get_accessible_projects().map_err(|error| {
375                Error::Response(match error {
376                    ApiError::Json(x) => x.to_string(),
377                    ApiError::String(s) => s,
378                })
379            })?;
380
381            for accessible_project in accessible_projects {
382                let mut already_present = false;
383                for repo in &repos {
384                    if repo.name() == accessible_project.name()
385                        && repo.namespace() == accessible_project.namespace()
386                    {
387                        already_present = true;
388                    }
389                }
390                if !already_present {
391                    repos.push(accessible_project);
392                }
393            }
394        }
395
396        for user in &self.filter().users {
397            let user_projects = self.get_user_projects(user).map_err(|error| {
398                Error::Response(match error {
399                    ApiError::Json(x) => x.to_string(),
400                    ApiError::String(s) => s,
401                })
402            })?;
403
404            for user_project in user_projects {
405                let mut already_present = false;
406                for repo in &repos {
407                    if repo.name() == user_project.name()
408                        && repo.namespace() == user_project.namespace()
409                    {
410                        already_present = true;
411                    }
412                }
413                if !already_present {
414                    repos.push(user_project);
415                }
416            }
417        }
418
419        for group in &self.filter().groups {
420            let group_projects = self.get_group_projects(group).map_err(|error| {
421                Error::Response(format!(
422                    "group \"{}\": {}",
423                    group,
424                    match error {
425                        ApiError::Json(x) => x.to_string(),
426                        ApiError::String(s) => s,
427                    }
428                ))
429            })?;
430            for group_project in group_projects {
431                let mut already_present = false;
432                for repo in &repos {
433                    if repo.name() == group_project.name()
434                        && repo.namespace() == group_project.namespace()
435                    {
436                        already_present = true;
437                    }
438                }
439
440                if !already_present {
441                    repos.push(group_project);
442                }
443            }
444        }
445
446        let mut ret: HashMap<Option<ProjectNamespace>, Vec<repo::Repo>> = HashMap::new();
447
448        let remote_name = remote_name.unwrap_or(DEFAULT_REMOTE_NAME);
449
450        for repo in repos {
451            if !self.filter().fork && repo.is_fork() {
452                continue;
453            }
454
455            let namespace = repo.namespace();
456
457            let mut repo = repo.into_repo_config(&remote_name, worktree_setup, protocol_config);
458
459            // Namespace is already part of the hashmap key. I'm not too happy
460            // about the data exchange format here.
461            repo.remove_namespace();
462
463            ret.entry(namespace).or_default().push(repo);
464        }
465
466        Ok(ret)
467    }
468}
469
470fn call<T, U>(
471    uri: &str,
472    auth_header_key: &str,
473    secret_token: &auth::AuthToken,
474    accept_header: Option<&str>,
475) -> Result<T, ApiError<U>>
476where
477    T: serde::de::DeserializeOwned,
478    U: serde::de::DeserializeOwned + JsonError,
479{
480    match ureq::get(uri)
481        .header("accept", accept_header.unwrap_or("application/json"))
482        .header(
483            "authorization",
484            &format!("{} {}", &auth_header_key, &secret_token.access()),
485        )
486        .call()
487    {
488        Err(ureq::Error::Http(error)) => Err(format!("http error: {error}").into()),
489        Err(e) => Err(format!("unknown error: {e}").into()),
490        Ok(mut response) => {
491            if response.status().is_success() {
492                Ok(response
493                    .body_mut()
494                    .read_json()
495                    .map_err(|error| format!("Failed deserializing response: {error}"))?)
496            } else {
497                Err(ApiError::Json(response.body_mut().read_json().map_err(
498                    |error| format!("Failed deserializing error response: {error}"),
499                )?))
500            }
501        }
502    }
503}