Compare commits

...

2 Commits

Author SHA1 Message Date
cuddle-please
fbef5f34e7 chore(release): 0.2.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-09-12 20:48:45 +00:00
ca989486d4
feat: gitea able to pull repositories
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-12 22:45:36 +02:00
9 changed files with 1510 additions and 25 deletions

View File

@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.0] - 2024-09-12
### Added
- gitea able to pull repositories
- add config
### Docs
- add readme
### Other
- move config out
- remove unused libraries
## [0.1.0] - 2024-09-12
### Added

968
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.0"
version = "0.2.0"
[workspace.dependencies]
gitnow = { path = "crates/gitnow" }

View File

@ -16,3 +16,6 @@ serde = { version = "1.0.197", features = ["derive"] }
uuid = { version = "1.7.0", features = ["v4"] }
async-trait = "0.1.82"
toml = "0.8.19"
gitea-rs = { git = "https://git.front.kjuulh.io/kjuulh/gitea-rs", ref = "main", version = "1.22.1" }
url = "2.5.2"

View File

@ -0,0 +1,188 @@
use std::path::Path;
use anyhow::Context;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Config {
#[serde(default)]
pub providers: Providers,
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Providers {
#[serde(default)]
pub github: Vec<GitHub>,
#[serde(default)]
pub gitea: Vec<Gitea>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHub {
#[serde(default)]
pub users: Vec<GitHubUser>,
#[serde(default)]
pub organisations: Vec<GitHubOrganisation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubUser(String);
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubOrganisation(String);
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Gitea {
pub url: String,
pub access_token: Option<GiteaAccessToken>,
#[serde(default)]
pub current_user: Option<String>,
#[serde(default)]
pub users: Vec<GiteaUser>,
#[serde(default)]
pub organisations: Vec<GiteaOrganisation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum GiteaAccessToken {
Direct(String),
Env { env: String },
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GiteaUser(String);
impl From<GiteaUser> for String {
fn from(value: GiteaUser) -> Self {
value.0
}
}
impl<'a> From<&'a GiteaUser> for &'a str {
fn from(value: &'a GiteaUser) -> Self {
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GiteaOrganisation(String);
impl Config {
pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> {
if !file_path.exists() {
if let Some(parent) = file_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::File::create(file_path).await?;
}
let file_content = tokio::fs::read_to_string(file_path).await?;
Self::from_string(&file_content)
}
pub fn from_string(content: &str) -> anyhow::Result<Config> {
toml::from_str(content).context("failed to deserialize config file")
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_can_parse_config() -> anyhow::Result<()> {
let content = r#"
[[providers.github]]
users = ["kjuulh"]
organisations = ["lunarway"]
[[providers.github]]
users = ["other"]
organisations = ["org"]
[[providers.gitea]]
url = "https://git.front.kjuulh.io/api/v1"
current_user = "kjuulh"
users = ["kjuulh"]
organisations = ["lunarway"]
[[providers.gitea]]
url = "https://git.front.kjuulh.io/api/v1"
users = ["other"]
organisations = ["org"]
[[providers.gitea]]
url = "https://git.front.kjuulh.io/api/v1"
"#;
let config = Config::from_string(content)?;
assert_eq!(
Config {
providers: Providers {
github: vec![
GitHub {
users: vec![GitHubUser("kjuulh".into())],
organisations: vec![GitHubOrganisation("lunarway".into())]
},
GitHub {
users: vec![GitHubUser("other".into())],
organisations: vec![GitHubOrganisation("org".into())]
}
],
gitea: vec![
Gitea {
url: "https://git.front.kjuulh.io/api/v1".into(),
users: vec![GiteaUser("kjuulh".into())],
organisations: vec![GiteaOrganisation("lunarway".into())],
access_token: None,
current_user: Some("kjuulh".into())
},
Gitea {
url: "https://git.front.kjuulh.io/api/v1".into(),
users: vec![GiteaUser("other".into())],
organisations: vec![GiteaOrganisation("org".into())],
access_token: None,
current_user: None
},
Gitea {
url: "https://git.front.kjuulh.io/api/v1".into(),
users: vec![],
organisations: vec![],
access_token: None,
current_user: None
},
]
}
},
config
);
Ok(())
}
#[test]
fn test_can_parse_empty_config() -> anyhow::Result<()> {
let content = r#"
# empty file
"#;
let config = Config::from_string(content)?;
assert_eq!(
Config {
providers: Providers {
github: vec![],
gitea: vec![]
}
},
config
);
Ok(())
}
}

View File

@ -0,0 +1,44 @@
use std::{collections::HashMap, path::PathBuf, str::FromStr};
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, PartialOrd)]
pub struct Repository {
pub provider: String,
pub owner: String,
pub repo_name: String,
pub ssh_url: String,
}
impl Repository {
pub fn to_rel_path(&self) -> PathBuf {
PathBuf::from(&self.provider)
.join(&self.owner)
.join(&self.repo_name)
}
}
pub trait VecRepositoryExt {
fn collect_unique(&mut self) -> &mut Self;
}
impl VecRepositoryExt for Vec<Repository> {
fn collect_unique(&mut self) -> &mut Self {
self.sort_by_key(|a| a.to_rel_path());
self.dedup_by_key(|a| a.to_rel_path());
self
}
}
#[async_trait]
pub trait GitProvider {
async fn list_repositories_for_user(&self, user: &str) -> anyhow::Result<Vec<Repository>>;
async fn list_repositories_for_organisation(
&self,
organisation: &str,
) -> anyhow::Result<Vec<Repository>>;
}
pub mod gitea;
pub mod github;

View File

@ -0,0 +1,185 @@
use anyhow::Context;
use gitea_rs::apis::configuration::{ApiKey, Configuration};
use url::Url;
use crate::{app::App, config::GiteaAccessToken};
#[derive(Debug)]
pub struct GiteaProvider {
app: &'static App,
}
impl GiteaProvider {
pub fn new(app: &'static App) -> GiteaProvider {
GiteaProvider { app }
}
#[tracing::instrument(skip(self))]
pub async fn list_repositories_for_current_user(
&self,
user: &str,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching gitea repositories for user");
let mut config = gitea_rs::apis::configuration::Configuration::new();
config.base_path = api.into();
match access_token {
Some(GiteaAccessToken::Env { env }) => {
let token =
std::env::var(env).context(format!("{env} didn't have a valid value"))?;
config.basic_auth = Some((user.into(), Some(token)));
}
Some(GiteaAccessToken::Direct(var)) => {
config.bearer_access_token = Some(var.to_owned());
}
None => {}
}
let mut repositories = Vec::new();
let mut page = 1;
loop {
let mut repos = self
.list_repositories_for_current_user_with_page(user, &config, page)
.await?;
if repos.is_empty() {
break;
}
repositories.append(&mut repos);
page += 1;
}
let provider = &Self::get_domain(api)?;
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for a gitea repository"),
})
.collect())
}
fn get_domain(api: &str) -> anyhow::Result<String> {
let url = Url::parse(api)?;
let provider = url.domain().unwrap_or("gitea");
Ok(provider.into())
}
#[tracing::instrument(skip(self))]
async fn list_repositories_for_current_user_with_page(
&self,
user: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> {
let repos =
gitea_rs::apis::user_api::user_current_list_repos(config, Some(page as i32), None)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
#[tracing::instrument(skip(self))]
pub async fn list_repositories_for_user(
&self,
user: &str,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching gitea repositories for user");
let mut config = gitea_rs::apis::configuration::Configuration::new();
config.base_path = api.into();
match access_token {
Some(GiteaAccessToken::Env { env }) => {
let token =
std::env::var(env).context(format!("{env} didn't have a valid value"))?;
config.basic_auth = Some((user.into(), Some(token)));
}
Some(GiteaAccessToken::Direct(var)) => {
config.bearer_access_token = Some(var.to_owned());
}
None => {}
}
let mut repositories = Vec::new();
let mut page = 1;
loop {
let mut repos = self
.list_repositories_for_user_with_page(user, &config, page)
.await?;
if repos.is_empty() {
break;
}
repositories.append(&mut repos);
page += 1;
}
let provider = &Self::get_domain(api)?;
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for gitea repository"),
})
.collect())
}
#[tracing::instrument(skip(self))]
pub async fn list_repositories_for_user_with_page(
&self,
user: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> {
let repos =
gitea_rs::apis::user_api::user_list_repos(config, user, Some(page as i32), None)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
#[tracing::instrument]
pub async fn list_repositories_for_organisation(
&self,
organisation: &str,
) -> anyhow::Result<Vec<super::Repository>> {
todo!()
}
}
pub trait GiteaProviderApp {
fn gitea_provider(&self) -> GiteaProvider;
}
impl GiteaProviderApp for &'static App {
fn gitea_provider(&self) -> GiteaProvider {
GiteaProvider::new(self)
}
}

View File

@ -0,0 +1,42 @@
use async_trait::async_trait;
use crate::app::App;
use super::GitProvider;
pub struct GitHubProvider {
app: &'static App,
}
impl GitHubProvider {
pub fn new(app: &'static App) -> GitHubProvider {
GitHubProvider { app }
}
}
#[async_trait]
impl GitProvider for GitHubProvider {
async fn list_repositories_for_user(
&self,
user: &str,
) -> anyhow::Result<Vec<super::Repository>> {
todo!()
}
async fn list_repositories_for_organisation(
&self,
organisation: &str,
) -> anyhow::Result<Vec<super::Repository>> {
todo!()
}
}
pub trait GitHubProviderApp {
fn github_provider(&self) -> GitHubProvider;
}
impl GitHubProviderApp for &'static App {
fn github_provider(&self) -> GitHubProvider {
GitHubProvider::new(self)
}
}

View File

@ -1,6 +1,12 @@
use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, Subcommand};
use commands::root::RootCommand;
use config::Config;
mod config;
mod git_provider;
#[derive(Parser)]
#[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
@ -14,11 +20,23 @@ enum Commands {
Hello {},
}
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
tracing_subscriber::fmt::init();
let app = app::App::new_static();
let home =
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
let config_path = std::env::var("GITNOW_CONFIG")
.map(PathBuf::from)
.unwrap_or(default_config_path);
let config = Config::from_file(&config_path).await?;
let app = app::App::new_static(config).await?;
let cli = Command::parse();
tracing::debug!("Starting cli");
@ -33,33 +51,29 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
mod config;
mod git_provider {
use async_trait::async_trait;
pub struct Repository {}
#[async_trait]
pub trait GitProvider {
async fn list_repositories(&self) -> anyhow::Result<Vec<Repository>>;
}
}
mod app {
use crate::config::Config;
#[derive(Debug)]
pub struct App {}
pub struct App {
pub config: Config,
}
impl App {
pub fn new_static() -> &'static App {
Box::leak(Box::new(App {}))
pub async fn new_static(config: Config) -> anyhow::Result<&'static App> {
Ok(Box::leak(Box::new(App { config })))
}
}
}
mod commands {
pub mod root {
use crate::app::App;
use crate::{
app::App,
git_provider::{
gitea::GiteaProviderApp, github::GitHubProviderApp, GitProvider, VecRepositoryExt,
},
};
#[derive(Debug, Clone)]
pub struct RootCommand {
@ -71,10 +85,48 @@ mod commands {
Self { app }
}
#[tracing::instrument]
#[tracing::instrument(skip(self))]
pub async fn execute(&mut self) -> anyhow::Result<()> {
tracing::debug!("executing");
//let github_provider = self.app.github_provider();
let gitea_provider = self.app.gitea_provider();
let mut repositories = Vec::new();
for gitea in self.app.config.providers.gitea.iter() {
if let Some(user) = &gitea.current_user {
let mut repos = gitea_provider
.list_repositories_for_current_user(
user,
&gitea.url,
gitea.access_token.as_ref(),
)
.await?;
repositories.append(&mut repos);
}
for gitea_user in gitea.users.iter() {
let mut repos = gitea_provider
.list_repositories_for_user(
gitea_user.into(),
&gitea.url,
gitea.access_token.as_ref(),
)
.await?;
repositories.append(&mut repos);
}
}
repositories.collect_unique();
for repo in &repositories {
tracing::info!("repo: {}", repo.to_rel_path().display());
}
tracing::info!("amount of repos fetched {}", repositories.len());
Ok(())
}
}