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