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 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 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}