Skip to main content

grm/provider/
mod.rs

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