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