Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
7288fa68cc | |||
e4d5d5302e
|
|||
0c8cf7211c
|
|||
6c94f02428
|
|||
1cc771be1e
|
|||
6a0900e190
|
|||
f1b9a373d5
|
|||
a62fbe70fb
|
|||
02845e5e11
|
|||
6ab02860b3
|
|||
ca989486d4
|
|||
d969f799b0
|
|||
b46ddb2d6a
|
|||
e7b4c8e819
|
|||
018e0ece7e
|
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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
1687
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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" }
|
|
||||||
|
16
README.md
16
README.md
@@ -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
10
buf.gen.yaml
Normal 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
4
buf.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
version: v2
|
||||||
|
modules:
|
||||||
|
- path: proto
|
||||||
|
name: buf.build/noschemaplz/gitnow
|
@@ -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"
|
||||||
|
14
crates/gitnow/proto/gitnow/v1/gitnow.proto
Normal file
14
crates/gitnow/proto/gitnow/v1/gitnow.proto
Normal 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
12
crates/gitnow/src/app.rs
Normal 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
126
crates/gitnow/src/cache.rs
Normal 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"))
|
||||||
|
}
|
||||||
|
}
|
61
crates/gitnow/src/cache_codec.rs
Normal file
61
crates/gitnow/src/cache_codec.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
1
crates/gitnow/src/commands.rs
Normal file
1
crates/gitnow/src/commands.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod root;
|
37
crates/gitnow/src/commands/root.rs
Normal file
37
crates/gitnow/src/commands/root.rs
Normal 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
351
crates/gitnow/src/config.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
21
crates/gitnow/src/gen/gitnow.v1.rs
Normal file
21
crates/gitnow/src/gen/gitnow.v1.rs
Normal 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)
|
33
crates/gitnow/src/git_provider.rs
Normal file
33
crates/gitnow/src/git_provider.rs
Normal 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;
|
232
crates/gitnow/src/git_provider/gitea.rs
Normal file
232
crates/gitnow/src/git_provider/gitea.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
184
crates/gitnow/src/git_provider/github.rs
Normal file
184
crates/gitnow/src/git_provider/github.rs
Normal 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(¤t_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)
|
||||||
|
}
|
||||||
|
}
|
@@ -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(())
|
||||||
|
119
crates/gitnow/src/projects_list.rs
Normal file
119
crates/gitnow/src/projects_list.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@@ -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:
|
||||||
|
7
examples/config/gitnow.toml
Normal file
7
examples/config/gitnow.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[[providers.github]]
|
||||||
|
users = ["kjuulh"]
|
||||||
|
organisations = ["lunarway"]
|
||||||
|
|
||||||
|
[[providers.gitea]]
|
||||||
|
users = ["kjuulh"]
|
||||||
|
organisation = ["noorgplease"]
|
@@ -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'
|
|
Reference in New Issue
Block a user