15 Commits

Author SHA1 Message Date
7288fa68cc chore(release): v0.2.0 (#2)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.2.0

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/2
2024-09-14 21:22:50 +02:00
e4d5d5302e feat: add cache get
All checks were successful
continuous-integration/drone/push Build is passing
This now introduces the `settings.cache.duration` key, which can either be false, true (default)

or a map `{days, hours, minutes}` for how long the cache should last. If the cache is expired an eager load of the repositories will be executed

Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-14 21:17:20 +02:00
0c8cf7211c feat: send out wait
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-14 20:46:38 +02:00
6c94f02428 feat: add cache
Some checks failed
continuous-integration/drone/push Build is failing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-14 20:45:49 +02:00
1cc771be1e feat: add settings config
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-14 15:22:35 +02:00
6a0900e190 feat: add github fetch prs refactoring
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-14 15:10:46 +02:00
f1b9a373d5 chore: removed unused code
All checks were successful
continuous-integration/drone/push Build is passing
get_config for gitea no longer requires a user for basic auth

Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-13 20:57:51 +02:00
a62fbe70fb fix: don't have to use user for basic auth
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-13 20:56:28 +02:00
02845e5e11 refactor: move projects list into separate file
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-13 20:09:19 +02:00
6ab02860b3 refactor: separate files
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-13 19:52:14 +02: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
d969f799b0 chore: move config out
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-12 20:15:49 +02:00
b46ddb2d6a feat: add config
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-12 20:14:44 +02:00
e7b4c8e819 chore: remove unused libraries
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-12 19:28:44 +02:00
018e0ece7e docs: add readme
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-12 19:27:35 +02:00
23 changed files with 2195 additions and 816 deletions

View File

@@ -6,6 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.2.0] - 2024-09-14
### Added
- add cache get
- send out wait
- add cache
- add settings config
- add github fetch prs refactoring
- gitea able to pull repositories
- add config
### Docs
- add readme
### Fixed
- don't have to use user for basic auth
### Other
- removed unused code
- move projects list into separate file
- separate files
- move config out
- remove unused libraries
## [0.1.0] - 2024-09-12 ## [0.1.0] - 2024-09-12
### Added ### Added

1687
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.2.0"
[workspace.dependencies] [workspace.dependencies]
gitnow = { path = "crates/gitnow" } gitnow = { path = "crates/gitnow" }
@@ -14,4 +14,3 @@ tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3.18" } tracing-subscriber = { version = "0.3.18" }
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }
dotenv = { version = "0.15" } dotenv = { version = "0.15" }
axum = { version = "0.7" }

View File

@@ -1,4 +1,20 @@
# Git Now # Git Now
Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type.
How many steps do you normally do to download a project?
1. Navigate to github.com
2. Search in your org for the project
3. Find the clone url
4. Navigate to your local github repositories path
5. Git clone `<project>`
6. Enter new project directory
A power user can of course use `gh repo clone` to skip a few steps.
With gitnow
1. `git now`
2. Enter parts of the project name and press enter
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.

10
buf.gen.yaml Normal file
View File

@@ -0,0 +1,10 @@
version: v2
managed:
enabled: true
plugins:
# dependencies
- remote: buf.build/community/neoeinstein-prost
out: crates/gitnow/src/gen
inputs:
- directory: crates/gitnow/proto

4
buf.yaml Normal file
View File

@@ -0,0 +1,4 @@
version: v2
modules:
- path: proto
name: buf.build/noschemaplz/gitnow

View File

@@ -11,9 +11,19 @@ tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
clap.workspace = true clap.workspace = true
dotenv.workspace = true dotenv.workspace = true
axum.workspace = true
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "time"] }
uuid = { version = "1.7.0", features = ["v4"] } uuid = { version = "1.7.0", features = ["v4"] }
tower-http = { version = "0.5.2", features = ["cors", "trace"] } async-trait = "0.1.82"
toml = "0.8.19"
gitea-rs = { git = "https://git.front.kjuulh.io/kjuulh/gitea-rs", version = "1.22.1" }
url = "2.5.2"
octocrab = "0.39.0"
dirs = "5.0.1"
prost = "0.13.2"
prost-types = "0.13.2"
bytes = "1.7.1"
[dev-dependencies]
pretty_assertions = "1.4.0"

View File

@@ -0,0 +1,14 @@
syntax = "proto3";
package gitnow.v1;
message Repositories {
repeated Repository repositories = 1;
}
message Repository {
string provider = 1;
string owner = 2;
string repo_name= 3;
string ssh_url = 4;
}

12
crates/gitnow/src/app.rs Normal file
View File

@@ -0,0 +1,12 @@
use crate::config::Config;
#[derive(Debug)]
pub struct App {
pub config: Config,
}
impl App {
pub async fn new_static(config: Config) -> anyhow::Result<&'static App> {
Ok(Box::leak(Box::new(App { config })))
}
}

126
crates/gitnow/src/cache.rs Normal file
View File

@@ -0,0 +1,126 @@
use std::path::PathBuf;
use anyhow::Context;
use tokio::io::AsyncWriteExt;
use crate::{app::App, cache_codec::CacheCodecApp, config::Config, git_provider::Repository};
pub struct Cache {
app: &'static App,
}
impl Cache {
pub fn new(app: &'static App) -> Self {
Self { app }
}
pub async fn update(&self, repositories: &[Repository]) -> anyhow::Result<()> {
tracing::debug!(repository_len = repositories.len(), "storing repositories");
let location = self.app.config.get_cache_file_location()?;
tracing::trace!("found cache location: {}", location.display());
if let Some(parent) = location.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let cache_content = self
.app
.cache_codec()
.serialize_repositories(repositories)?;
let mut cache_file = tokio::fs::File::create(location)
.await
.context("failed to create cache file")?;
cache_file
.write_all(&cache_content)
.await
.context("failed to write cache content to file")?;
Ok(())
}
pub async fn get(&self) -> anyhow::Result<Option<Vec<Repository>>> {
tracing::debug!("fetching repositories");
let location = self.app.config.get_cache_file_location()?;
if !location.exists() {
tracing::debug!(
location = location.display().to_string(),
"cache doesn't exist"
);
return Ok(None);
}
if let Some(cache_duration) = self.app.config.settings.cache.duration.get_duration() {
let metadata = tokio::fs::metadata(&location).await?;
if let Ok(file_modified_last) = metadata
.modified()
.context("failed to get modified date")
.inspect_err(|e| {
tracing::warn!(
"could not get valid metadata from file, cache will be reused: {}",
e
);
})
.and_then(|m| {
m.elapsed()
.context("failed to get elapsed from file")
.inspect_err(|e| tracing::warn!("failed to get elapsed from system: {}", e))
})
{
tracing::trace!(
cache = file_modified_last.as_secs(),
expiry = cache_duration.as_secs(),
"checking if cache is valid"
);
if file_modified_last > cache_duration {
tracing::debug!("cache has expired");
return Ok(None);
}
}
}
let file = tokio::fs::read(&location).await?;
if file.is_empty() {
tracing::debug!("cache file appears to be empty");
return Ok(None);
}
let repos = match self.app.cache_codec().deserialize_repositories(file) {
Ok(repos) => repos,
Err(e) => {
tracing::warn!(error = e.to_string(), "failed to deserialize repositories");
return Ok(None);
}
};
Ok(Some(repos))
}
}
pub trait CacheApp {
fn cache(&self) -> Cache;
}
impl CacheApp for &'static App {
fn cache(&self) -> Cache {
Cache::new(self)
}
}
pub trait CacheConfig {
fn get_cache_location(&self) -> anyhow::Result<PathBuf>;
fn get_cache_file_location(&self) -> anyhow::Result<PathBuf>;
}
impl CacheConfig for Config {
fn get_cache_location(&self) -> anyhow::Result<PathBuf> {
Ok(self.settings.cache.location.clone().into())
}
fn get_cache_file_location(&self) -> anyhow::Result<PathBuf> {
Ok(self.get_cache_location()?.join("cache.proto"))
}
}

View File

@@ -0,0 +1,61 @@
use std::io::Cursor;
use anyhow::Context;
use prost::Message;
use crate::{app::App, git_provider::Repository};
mod proto_codec {
include!("gen/gitnow.v1.rs");
}
pub struct CacheCodec {}
impl CacheCodec {
pub fn new() -> Self {
Self {}
}
pub fn serialize_repositories(&self, repositories: &[Repository]) -> anyhow::Result<Vec<u8>> {
let mut codec_repos = proto_codec::Repositories::default();
for repo in repositories.iter().cloned() {
codec_repos.repositories.push(proto_codec::Repository {
provider: repo.provider,
owner: repo.owner,
repo_name: repo.repo_name,
ssh_url: repo.ssh_url,
});
}
Ok(codec_repos.encode_to_vec())
}
pub fn deserialize_repositories(&self, content: Vec<u8>) -> anyhow::Result<Vec<Repository>> {
let codex_repos = proto_codec::Repositories::decode(&mut Cursor::new(content))
.context("failed to decode protobuf repositories")?;
let mut repos = Vec::new();
for codec_repo in codex_repos.repositories {
repos.push(Repository {
provider: codec_repo.provider,
owner: codec_repo.owner,
repo_name: codec_repo.repo_name,
ssh_url: codec_repo.ssh_url,
});
}
Ok(repos)
}
}
pub trait CacheCodecApp {
fn cache_codec(&self) -> CacheCodec;
}
impl CacheCodecApp for &'static App {
fn cache_codec(&self) -> CacheCodec {
CacheCodec::new()
}
}

View File

@@ -0,0 +1 @@
pub mod root;

View File

@@ -0,0 +1,37 @@
use crate::{app::App, cache::CacheApp, projects_list::ProjectsListApp};
#[derive(Debug, Clone)]
pub struct RootCommand {
app: &'static App,
}
impl RootCommand {
pub fn new(app: &'static App) -> Self {
Self { app }
}
#[tracing::instrument(skip(self))]
pub async fn execute(&mut self) -> anyhow::Result<()> {
tracing::debug!("executing");
let repositories = match self.app.cache().get().await? {
Some(repos) => repos,
None => {
tracing::info!("finding repositories...");
let repositories = self.app.projects_list().get_projects().await?;
self.app.cache().update(&repositories).await?;
repositories
}
};
for repo in &repositories {
//tracing::info!("repo: {}", repo.to_rel_path().display());
}
tracing::info!("amount of repos fetched {}", repositories.len());
Ok(())
}
}

351
crates/gitnow/src/config.rs Normal file
View File

@@ -0,0 +1,351 @@
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
#[serde(default)]
pub providers: Providers,
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Settings {
#[serde(default)]
pub cache: Cache,
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Cache {
#[serde(default)]
pub location: CacheLocation,
#[serde(default)]
pub duration: CacheDuration,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CacheLocation(PathBuf);
impl From<PathBuf> for CacheLocation {
fn from(value: PathBuf) -> Self {
Self(value)
}
}
impl From<CacheLocation> for PathBuf {
fn from(value: CacheLocation) -> Self {
value.0
}
}
impl Default for CacheLocation {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_default();
Self(home.join(".cache/gitnow"))
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum CacheDuration {
Enabled(bool),
Precise {
#[serde(default)]
days: u64,
#[serde(default)]
hours: u64,
#[serde(default)]
minutes: u64,
},
}
impl CacheDuration {
pub fn get_duration(&self) -> Option<std::time::Duration> {
match self {
CacheDuration::Enabled(true) => CacheDuration::default().get_duration(),
CacheDuration::Enabled(false) => None,
CacheDuration::Precise {
days,
hours,
minutes,
} => Some(
std::time::Duration::from_days(*days)
+ std::time::Duration::from_hours(*hours)
+ std::time::Duration::from_mins(*minutes),
),
}
}
}
impl Default for CacheDuration {
fn default() -> Self {
Self::Precise {
days: 1,
hours: 0,
minutes: 0,
}
}
}
#[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 url: Option<String>,
pub access_token: GitHubAccessToken,
#[serde(default)]
pub current_user: Option<String>,
#[serde(default)]
pub users: Vec<GitHubUser>,
#[serde(default)]
pub organisations: Vec<GitHubOrganisation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubUser(String);
impl From<GitHubUser> for String {
fn from(value: GitHubUser) -> Self {
value.0
}
}
impl<'a> From<&'a GitHubUser> for &'a str {
fn from(value: &'a GitHubUser) -> Self {
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubOrganisation(String);
impl From<GitHubOrganisation> for String {
fn from(value: GitHubOrganisation) -> Self {
value.0
}
}
impl<'a> From<&'a GitHubOrganisation> for &'a str {
fn from(value: &'a GitHubOrganisation) -> Self {
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Gitea {
pub url: String,
#[serde(default)]
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)]
#[serde(untagged)]
pub enum GitHubAccessToken {
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 From<GiteaOrganisation> for String {
fn from(value: GiteaOrganisation) -> Self {
value.0
}
}
impl<'a> From<&'a GiteaOrganisation> for &'a str {
fn from(value: &'a GiteaOrganisation) -> Self {
value.0.as_str()
}
}
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#"
[settings.cache]
location = ".cache/gitnow"
duration = { days = 2 }
[[providers.github]]
current_user = "kjuulh"
access_token = "some-token"
users = ["kjuulh"]
organisations = ["lunarway"]
[[providers.github]]
access_token = { env = "something" }
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)?;
pretty_assertions::assert_eq!(
Config {
providers: Providers {
github: vec![
GitHub {
users: vec![GitHubUser("kjuulh".into())],
organisations: vec![GitHubOrganisation("lunarway".into())],
url: None,
access_token: GitHubAccessToken::Direct("some-token".into()),
current_user: Some("kjuulh".into())
},
GitHub {
users: vec![GitHubUser("other".into())],
organisations: vec![GitHubOrganisation("org".into())],
url: None,
access_token: GitHubAccessToken::Env {
env: "something".into()
},
current_user: None
}
],
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
},
]
},
settings: Settings {
cache: Cache {
location: PathBuf::from(".cache/gitnow").into(),
duration: CacheDuration::Precise {
days: 2,
hours: 0,
minutes: 0
}
}
}
},
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![]
},
settings: Settings {
cache: Cache::default()
}
},
config
);
Ok(())
}
}

View File

@@ -0,0 +1,21 @@
// @generated
// This file is @generated by prost-build.
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Repositories {
#[prost(message, repeated, tag="1")]
pub repositories: ::prost::alloc::vec::Vec<Repository>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Repository {
#[prost(string, tag="1")]
pub provider: ::prost::alloc::string::String,
#[prost(string, tag="2")]
pub owner: ::prost::alloc::string::String,
#[prost(string, tag="3")]
pub repo_name: ::prost::alloc::string::String,
#[prost(string, tag="4")]
pub ssh_url: ::prost::alloc::string::String,
}
// @@protoc_insertion_point(module)

View File

@@ -0,0 +1,33 @@
use std::path::PathBuf;
#[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
}
}
pub mod gitea;
pub mod github;

View File

@@ -0,0 +1,232 @@
use anyhow::Context;
use gitea_rs::apis::configuration::Configuration;
use url::Url;
use crate::{app::App, config::GiteaAccessToken};
#[derive(Debug)]
pub struct GiteaProvider {
#[allow(dead_code)]
app: &'static App,
}
impl GiteaProvider {
pub fn new(app: &'static App) -> GiteaProvider {
GiteaProvider { app }
}
pub async fn list_repositories_for_current_user(
&self,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching gitea repositories for current user");
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new();
let mut page = 1;
loop {
let mut repos = self
.list_repositories_for_current_user_with_page(&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())
}
async fn list_repositories_for_current_user_with_page(
&self,
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)
}
pub async fn list_repositories_for_user(
&self,
user: &str,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(user = user, "fetching gitea repositories for user");
let config = self.get_config(api, access_token)?;
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())
}
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)
}
pub async fn list_repositories_for_organisation(
&self,
organisation: &str,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(
organisation = organisation,
"fetching gitea repositories for organisation"
);
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new();
let mut page = 1;
loop {
let mut repos = self
.list_repositories_for_organisation_with_page(organisation, &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())
}
pub async fn list_repositories_for_organisation_with_page(
&self,
organisation: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> {
let repos = gitea_rs::apis::organization_api::org_list_repos(
config,
organisation,
Some(page as i32),
None,
)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
fn get_config(
&self,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Configuration> {
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(("".into(), Some(token)));
}
Some(GiteaAccessToken::Direct(var)) => {
config.bearer_access_token = Some(var.to_owned());
}
None => {}
}
Ok(config)
}
}
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,184 @@
use anyhow::Context;
use octocrab::{
auth::Auth,
models::{hooks::Config, Repository},
params::repos::Sort,
NoSvc, Octocrab, Page,
};
use crate::{app::App, config::GitHubAccessToken};
pub struct GitHubProvider {
#[allow(dead_code)]
app: &'static App,
}
impl GitHubProvider {
pub fn new(app: &'static App) -> GitHubProvider {
GitHubProvider { app }
}
pub async fn list_repositories_for_current_user(
&self,
url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching github repositories for current user");
let client = self.get_client(url, access_token)?;
let current_page = client
.current()
.list_repos_for_authenticated_user()
.type_("all")
.per_page(100)
.sort("full_name")
.send()
.await?;
let repos = self.unfold_pages(client, current_page).await?;
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
}
pub async fn list_repositories_for_user(
&self,
user: &str,
url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(user = user, "fetching github repositories for user");
let client = self.get_client(url, access_token)?;
let current_page = client
.users(user)
.repos()
.r#type(octocrab::params::users::repos::Type::All)
.sort(Sort::FullName)
.per_page(100)
.send()
.await?;
let repos = self.unfold_pages(client, current_page).await?;
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
}
pub async fn list_repositories_for_organisation(
&self,
organisation: &str,
url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(
organisation = organisation,
"fetching github repositories for organisation"
);
let client = self.get_client(url, access_token)?;
let current_page = client
.orgs(organisation)
.list_repos()
.repo_type(Some(octocrab::params::repos::Type::All))
.sort(Sort::FullName)
.per_page(100)
.send()
.await?;
let repos = self.unfold_pages(client, current_page).await?;
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
}
async fn unfold_pages(
&self,
client: octocrab::Octocrab,
page: Page<Repository>,
) -> anyhow::Result<Vec<Repository>> {
let mut current_page = page;
let mut repos = current_page.take_items();
while let Ok(Some(mut new_page)) = client.get_page(&current_page.next).await {
repos.extend(new_page.take_items());
current_page = new_page;
}
Ok(repos)
}
fn get_url(&self, url: Option<&String>) -> String {
let default_domain = "github.com".to_string();
if let Some(url) = url {
let Some(url) = url::Url::parse(url).ok() else {
return default_domain;
};
let Some(domain) = url.domain().map(|d| d.to_string()) else {
return default_domain;
};
domain
} else {
default_domain
}
}
fn get_client(
&self,
url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Octocrab> {
let client = octocrab::Octocrab::builder()
.personal_token(match access_token {
GitHubAccessToken::Direct(token) => token.to_owned(),
GitHubAccessToken::Env { env } => std::env::var(env)?,
})
.build()?;
Ok(client)
}
}
pub trait GitHubProviderApp {
fn github_provider(&self) -> GitHubProvider;
}
impl GitHubProviderApp for &'static App {
fn github_provider(&self) -> GitHubProvider {
GitHubProvider::new(self)
}
}

View File

@@ -1,8 +1,22 @@
#![feature(duration_constructors)]
use std::path::PathBuf;
use anyhow::Context; use anyhow::Context;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use commands::root::RootCommand;
use config::Config;
mod app;
mod cache;
mod cache_codec;
mod commands;
mod config;
mod git_provider;
mod projects_list;
#[derive(Parser)] #[derive(Parser)]
#[command(author, version, about, long_about = None, subcommand_required = true)] #[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
struct Command { struct Command {
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
@@ -13,16 +27,32 @@ enum Commands {
Hello {}, Hello {},
} }
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok(); dotenv::dotenv().ok();
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
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(); let cli = Command::parse();
tracing::debug!("Starting cli"); tracing::debug!("Starting cli");
if let Some(Commands::Hello {}) = cli.command { match cli.command {
println!("Hello!") Some(_) => todo!(),
None => {
RootCommand::new(app).execute().await?;
}
} }
Ok(()) Ok(())

View File

@@ -0,0 +1,119 @@
use crate::{
app::App,
git_provider::{
gitea::GiteaProviderApp, github::GitHubProviderApp, Repository, VecRepositoryExt,
},
};
pub struct ProjectsList {
app: &'static App,
}
impl ProjectsList {
pub fn new(app: &'static App) -> Self {
Self { app }
}
pub async fn get_projects(&self) -> anyhow::Result<Vec<Repository>> {
let mut repositories = Vec::new();
repositories.extend(self.get_gitea_projects().await?);
repositories.extend(self.get_github_projects().await?);
repositories.collect_unique();
Ok(repositories)
}
async fn get_gitea_projects(&self) -> anyhow::Result<Vec<Repository>> {
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(&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);
}
for gitea_org in gitea.organisations.iter() {
let mut repos = gitea_provider
.list_repositories_for_organisation(
gitea_org.into(),
&gitea.url,
gitea.access_token.as_ref(),
)
.await?;
repositories.append(&mut repos);
}
}
Ok(repositories)
}
async fn get_github_projects(&self) -> anyhow::Result<Vec<Repository>> {
let github_provider = self.app.github_provider();
let mut repositories = Vec::new();
for github in self.app.config.providers.github.iter() {
if let Some(_user) = &github.current_user {
let mut repos = github_provider
.list_repositories_for_current_user(github.url.as_ref(), &github.access_token)
.await?;
repositories.append(&mut repos);
}
for github_user in github.users.iter() {
let mut repos = github_provider
.list_repositories_for_user(
github_user.into(),
github.url.as_ref(),
&github.access_token,
)
.await?;
repositories.append(&mut repos);
}
for github_org in github.organisations.iter() {
let mut repos = github_provider
.list_repositories_for_organisation(
github_org.into(),
github.url.as_ref(),
&github.access_token,
)
.await?;
repositories.append(&mut repos);
}
}
Ok(repositories)
}
}
pub trait ProjectsListApp {
fn projects_list(&self) -> ProjectsList;
}
impl ProjectsListApp for &'static App {
fn projects_list(&self) -> ProjectsList {
ProjectsList::new(self)
}
}

View File

@@ -13,3 +13,5 @@ please:
branch: "main" branch: "main"
settings: settings:
api_url: "https://git.front.kjuulh.io" api_url: "https://git.front.kjuulh.io"
actions:
rust:

View File

@@ -0,0 +1,7 @@
[[providers.github]]
users = ["kjuulh"]
organisations = ["lunarway"]
[[providers.gitea]]
users = ["kjuulh"]
organisation = ["noorgplease"]

View File

@@ -1,15 +0,0 @@
version: "3"
services:
crdb:
restart: 'always'
image: 'cockroachdb/cockroach:v23.1.14'
command: 'start-single-node --advertise-addr 0.0.0.0 --insecure'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"]
interval: '10s'
timeout: '30s'
retries: 5
start_period: '20s'
ports:
- 8080:8080
- '26257:26257'