From a365891b7733c8d3b91717cc148a59eae0c3b824 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Mon, 19 Jan 2026 09:39:56 +0900 Subject: [PATCH] commands/list: init --- src/commands.rs | 3 + src/commands/list.rs | 255 ++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 34 +++--- src/main.rs | 24 +++- src/sources.rs | 47 ++------ src/sources/git.rs | 8 +- src/sources/gitea.rs | 9 +- src/sources/github.rs | 23 ++-- 8 files changed, 328 insertions(+), 75 deletions(-) create mode 100644 src/commands.rs create mode 100644 src/commands/list.rs diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..19f2172 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,3 @@ +mod list; + +pub use list::list; diff --git a/src/commands/list.rs b/src/commands/list.rs new file mode 100644 index 0000000..485a48e --- /dev/null +++ b/src/commands/list.rs @@ -0,0 +1,255 @@ +use std::collections::HashSet; + +use crate::{ + config::{Config, RepositoryFilterConfig, SourceConfig, SourceHostConfig, SourceHostType}, + sources::{KagamiSource, KagamiSourceGitea, KagamiSourceGithub, RepositoryInfo}, +}; + +// TODO: implement json output option +pub async fn list(config: &Config) -> anyhow::Result<()> { + let mut to_clone: Vec<(String, RepositoryInfo)> = Vec::new(); + + for (source_name, source_cfg) in config.sources.iter() { + match source_cfg { + SourceConfig::User { + source_host, + name, + repository_filters, + clone, + .. + } => { + if !*clone { + continue; + } + let host_cfg = match resolve_host_cfg(config, source_host) { + Ok(h) => h, + Err(e) => { + eprintln!("warning: {} for source '{}'", e, source_name); + continue; + } + }; + + let provider_results = + fetch_for_user(&host_cfg, source_host, name, repository_filters).await; + match provider_results { + Ok(set) => { + for ri in set.into_iter() { + to_clone.push((source_name.clone(), ri)); + } + } + Err(e) => eprintln!("failed to fetch from source {}: {}", source_name, e), + } + } + SourceConfig::Organization { + source_host, + name, + recurse_groups, + repository_filters, + clone, + .. + } => { + if !*clone { + continue; + } + let host_cfg = match resolve_host_cfg(config, source_host) { + Ok(h) => h, + Err(e) => { + eprintln!("warning: {} for source '{}'", e, source_name); + continue; + } + }; + + let provider_results = fetch_for_org( + &host_cfg, + source_host, + name, + *recurse_groups, + repository_filters, + ) + .await; + match provider_results { + Ok(set) => { + for ri in set.into_iter() { + to_clone.push((source_name.clone(), ri)); + } + } + Err(e) => eprintln!("failed to fetch from source {}: {}", source_name, e), + } + } + SourceConfig::UserStars { + source_host, + name, + repository_filters, + clone, + .. + } => { + if !*clone { + continue; + } + let host_cfg = match resolve_host_cfg(config, source_host) { + Ok(h) => h, + Err(e) => { + eprintln!("warning: {} for source '{}'", e, source_name); + continue; + } + }; + + let provider_results = + fetch_for_stars(&host_cfg, source_host, name, repository_filters).await; + match provider_results { + Ok(set) => { + for ri in set.into_iter() { + to_clone.push((source_name.clone(), ri)); + } + } + Err(e) => eprintln!("failed to fetch from source {}: {}", source_name, e), + } + } + } + } + + use std::collections::HashSet as StdHashSet; + let mut uniq = StdHashSet::new(); + println!("Repositories to be cloned ({}):", to_clone.len()); + for (src, ri) in to_clone.into_iter() { + if uniq.insert(ri.clone()) { + println!("[{}] {} -> {}", src, ri.name, ri.url); + } + } + + Ok(()) +} + +async fn fetch_for_user( + host_cfg: &SourceHostConfig, + host_key: &str, + owner: &str, + repo_filters: &RepositoryFilterConfig, +) -> anyhow::Result> { + match host_cfg._type { + SourceHostType::Github => { + let host = host_cfg.host.as_deref(); + let token = host_cfg.get_token()?; + let provider = KagamiSourceGithub::new(host); + provider + .get_repositories_by_user(owner, token.as_deref(), repo_filters) + .await + } + SourceHostType::Gitea => { + let host = host_cfg.host.as_deref(); + let token = host_cfg.get_token()?; + let provider = KagamiSourceGitea::new(host); + provider + .get_repositories_by_user(owner, token.as_deref(), repo_filters) + .await + } + SourceHostType::Git => Ok(HashSet::new()), + _ => anyhow::bail!( + "source host type for '{}' not implemented in list", + host_key + ), + } +} + +async fn fetch_for_org( + host_cfg: &SourceHostConfig, + host_key: &str, + owner: &str, + recurse: bool, + repo_filters: &RepositoryFilterConfig, +) -> anyhow::Result> { + match host_cfg._type { + SourceHostType::Github => { + let host = host_cfg.host.as_deref(); + let token = host_cfg.get_token()?; + let provider = KagamiSourceGithub::new(host); + provider + .get_repositories_by_organization(owner, token.as_deref(), recurse, repo_filters) + .await + } + SourceHostType::Gitea => { + let host = host_cfg.host.as_deref(); + let token = host_cfg.get_token()?; + let provider = KagamiSourceGitea::new(host); + provider + .get_repositories_by_organization(owner, token.as_deref(), recurse, repo_filters) + .await + } + SourceHostType::Git => Ok(HashSet::new()), + _ => anyhow::bail!( + "source host type for '{}' not implemented in list", + host_key + ), + } +} + +async fn fetch_for_stars( + host_cfg: &SourceHostConfig, + host_key: &str, + owner: &str, + repo_filters: &RepositoryFilterConfig, +) -> anyhow::Result> { + match host_cfg._type { + SourceHostType::Github => { + let host = host_cfg.host.as_deref(); + let token = host_cfg.get_token()?; + let provider = KagamiSourceGithub::new(host); + provider + .get_repositories_by_stars(owner, token.as_deref(), repo_filters) + .await + } + SourceHostType::Gitea => { + let host = host_cfg.host.as_deref(); + let token = host_cfg.get_token()?; + let provider = KagamiSourceGitea::new(host); + provider + .get_repositories_by_stars(owner, token.as_deref(), repo_filters) + .await + } + SourceHostType::Git => Ok(HashSet::new()), + _ => anyhow::bail!( + "source host type for '{}' not implemented in list", + host_key + ), + } +} + +fn resolve_host_cfg(config: &Config, key: &str) -> anyhow::Result { + if let Some(h) = config.source_hosts.get(key) { + return Ok(h.clone()); + } + + match key.to_lowercase().as_str() { + "github" => Ok(SourceHostConfig { + _type: SourceHostType::Github, + host: None, + token_file: None, + token: None, + }), + "gitlab" => Ok(SourceHostConfig { + _type: SourceHostType::Gitlab, + host: None, + token_file: None, + token: None, + }), + "gitea" => Ok(SourceHostConfig { + _type: SourceHostType::Gitea, + host: None, + token_file: None, + token: None, + }), + "codeberg" | "forgejo" => Ok(SourceHostConfig { + _type: SourceHostType::Forgejo, + host: None, + token_file: None, + token: None, + }), + "git" => Ok(SourceHostConfig { + _type: SourceHostType::Git, + host: None, + token_file: None, + token: None, + }), + other => anyhow::bail!("unknown source_host '{}' and no default available", other), + } +} diff --git a/src/config.rs b/src/config.rs index 92a1809..9c76c0c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; @@ -18,6 +19,21 @@ pub struct SourceHostConfig { pub token: Option, } +impl SourceHostConfig { + pub fn get_token(&self) -> anyhow::Result> { + if let Some(ref t) = self.token { + return Ok(Some(t.clone())); + } + + if let Some(ref p) = self.token_file { + let s = std::fs::read_to_string(p).context("failed to read token file")?; + return Ok(Some(s.trim().to_string())); + } + + Ok(None) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SourceHostType { @@ -164,7 +180,7 @@ pub enum RegexFilter { Exclude(String), } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct IssueFilterConfig { // - Filter by label(s) // - Filter by assignee(s) @@ -172,14 +188,7 @@ pub struct IssueFilterConfig { // - Filter by state (open, closed, all) } -impl Default for IssueFilterConfig { - fn default() -> Self { - Self { - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CloneFilterConfig { // - Use native API if available // - Which branches to get @@ -188,10 +197,3 @@ pub struct CloneFilterConfig { // - Get Releases // - Get Pull Requests } - -impl Default for CloneFilterConfig { - fn default() -> Self { - Self { - } - } -} diff --git a/src/main.rs b/src/main.rs index 38ca70a..a7354a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +pub mod commands; pub mod config; pub mod sources; @@ -8,6 +9,7 @@ use anyhow::Context; use clap::{CommandFactory, Parser}; use clap_complete::{Shell, generate}; +use crate::commands::*; use config::Config; #[derive(Parser, Debug)] @@ -32,6 +34,9 @@ enum Command { /// Validate the configuration provided configuration file ValidateConfig, + /// List out the repositories that would be processed + List, + /// Generate shell completions #[command(hide = true)] GenerateCompletions(GenerateCompletionArgs), @@ -47,9 +52,10 @@ struct GenerateCompletionArgs { async fn main() -> anyhow::Result<()> { let args = Args::parse(); - if let Command::GenerateCompletions(args) = args.command { + // Handle completions early if requested + if let Command::GenerateCompletions(gen_args) = args.command.clone() { generate( - args.shell, + gen_args.shell, &mut Args::command(), "kagami", &mut std::io::stdout(), @@ -58,8 +64,18 @@ async fn main() -> anyhow::Result<()> { } let raw_config = fs::read_to_string(&args.config).context("failed to read config file")?; - let _config: Config = + let config: Config = serde_json::from_str(&raw_config).context("failed to parse config file")?; - unimplemented!() + match args.command { + Command::ValidateConfig => { + println!("configuration parsed successfully"); + return Ok(()); + } + Command::List => list(&config).await, + _ => { + // other commands not implemented yet + unimplemented!(); + } + } } diff --git a/src/sources.rs b/src/sources.rs index bc009fc..1b9f82b 100644 --- a/src/sources.rs +++ b/src/sources.rs @@ -4,8 +4,16 @@ pub mod gitea; pub mod github; pub mod gitlab; +// pub use forgejo::KagamiSourceForgejo; +pub use git::KagamiSourceGit; +pub use gitea::KagamiSourceGitea; +pub use github::KagamiSourceGithub; +// pub use gitlab::KagamiSourceGitLab; + use std::collections::HashSet; +use crate::config::RepositoryFilterConfig; + pub trait KagamiSource { const NAME: &'static str; const DEFAULT_HOST: &'static str; @@ -30,7 +38,7 @@ pub trait KagamiSource { &self, owner: &str, token: Option<&str>, - filter: &RepositoryFilter, + filter: &RepositoryFilterConfig, ) -> impl std::future::Future>> + Send; fn get_repositories_by_organization( @@ -38,14 +46,14 @@ pub trait KagamiSource { owner: &str, token: Option<&str>, recurse_groups: bool, - filter: &RepositoryFilter, + filter: &RepositoryFilterConfig, ) -> impl std::future::Future>> + Send; fn get_repositories_by_stars( &self, owner: &str, token: Option<&str>, - filter: &RepositoryFilter, + filter: &RepositoryFilterConfig, ) -> impl std::future::Future>> + Send; } @@ -56,39 +64,6 @@ pub enum RepositoryVisibility { Private, } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum RegexFilter { - Include(String), - Exclude(String), -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct RepositoryFilter { - pub filter_regex: Vec, - pub include_forks: bool, - pub include_archived: bool, - pub min_stars: Option, - pub max_stars: Option, - pub include_public: bool, - pub include_internal: bool, - pub include_private: bool, -} - -impl Default for RepositoryFilter { - fn default() -> Self { - Self { - filter_regex: Vec::new(), - include_forks: true, - include_archived: true, - min_stars: None, - max_stars: None, - include_public: true, - include_internal: true, - include_private: true, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RepositoryInfo { pub name: String, diff --git a/src/sources/git.rs b/src/sources/git.rs index f5bdf38..c9fc804 100644 --- a/src/sources/git.rs +++ b/src/sources/git.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use crate::sources::{KagamiSource, RepositoryFilter, RepositoryInfo}; +use crate::{config::RepositoryFilterConfig, sources::{KagamiSource, RepositoryInfo}}; pub struct KagamiSourceGit { host: String, @@ -26,7 +26,7 @@ impl KagamiSource for KagamiSourceGit { &self, _owner: &str, _token: Option<&str>, - _filter: &RepositoryFilter, + _filter: &RepositoryFilterConfig, ) -> anyhow::Result> { Ok(HashSet::new()) } @@ -36,7 +36,7 @@ impl KagamiSource for KagamiSourceGit { _owner: &str, _token: Option<&str>, _recurse_groups: bool, - _filter: &RepositoryFilter, + _filter: &RepositoryFilterConfig, ) -> anyhow::Result> { Ok(HashSet::new()) } @@ -45,7 +45,7 @@ impl KagamiSource for KagamiSourceGit { &self, _owner: &str, _token: Option<&str>, - _filter: &RepositoryFilter, + _filter: &RepositoryFilterConfig, ) -> anyhow::Result> { Ok(HashSet::new()) } diff --git a/src/sources/gitea.rs b/src/sources/gitea.rs index 23996ae..3e55bb2 100644 --- a/src/sources/gitea.rs +++ b/src/sources/gitea.rs @@ -2,7 +2,8 @@ use std::collections::HashSet; use anyhow::Context; -use crate::sources::{KagamiSource, RepositoryFilter, RepositoryInfo, RepositoryVisibility}; +use crate::config::RepositoryFilterConfig; +use crate::sources::{KagamiSource, RepositoryInfo, RepositoryVisibility}; use gitea_sdk; use gitea_sdk::Auth::Token; @@ -35,7 +36,7 @@ impl KagamiSource for KagamiSourceGitea { &self, owner: &str, token: Option<&str>, - _filter: &RepositoryFilter, + _filter: &RepositoryFilterConfig, ) -> anyhow::Result> { let base = self.api_base(); @@ -104,7 +105,7 @@ impl KagamiSource for KagamiSourceGitea { token: Option<&str>, // NOTE: Gitea does not have nested organizations/groups like GitLab, _recurse_groups: bool, - _filter: &RepositoryFilter, + _filter: &RepositoryFilterConfig, ) -> anyhow::Result> { let base = self.api_base(); @@ -171,7 +172,7 @@ impl KagamiSource for KagamiSourceGitea { &self, owner: &str, token: Option<&str>, - _filter: &RepositoryFilter, + _filter: &RepositoryFilterConfig, ) -> anyhow::Result> { let base = self.api_base(); diff --git a/src/sources/github.rs b/src/sources/github.rs index 5869e40..ec1e583 100644 --- a/src/sources/github.rs +++ b/src/sources/github.rs @@ -3,7 +3,10 @@ use std::collections::HashSet; use anyhow::Context; use octocrab::{Octocrab, Page, models}; -use crate::sources::{KagamiSource, RepositoryFilter, RepositoryInfo, RepositoryVisibility}; +use crate::{ + config::RepositoryFilterConfig, + sources::{KagamiSource, RepositoryInfo, RepositoryVisibility}, +}; pub struct KagamiSourceGithub { host: Option, @@ -32,7 +35,7 @@ impl KagamiSource for KagamiSourceGithub { &self, owner: &str, token: Option<&str>, - _filter: &RepositoryFilter, + _filter: &RepositoryFilterConfig, ) -> anyhow::Result> { let octocrab = { let mut builder = Octocrab::builder(); @@ -59,13 +62,11 @@ impl KagamiSource for KagamiSourceGithub { ) .await .context("failed to fetch user repositories")?, - Some(ref p) => { - match octocrab.get_page(&p.next).await { - Ok(Some(next_page)) => next_page, - Ok(None) => break, - Err(_) => break, - } - } + Some(ref p) => match octocrab.get_page(&p.next).await { + Ok(Some(next_page)) => next_page, + Ok(None) => break, + Err(_) => break, + }, }; for r in ¤t.items { @@ -89,7 +90,7 @@ impl KagamiSource for KagamiSourceGithub { token: Option<&str>, // NOTE: GitHub nested teams doesn't remove the organization repos; this flag is unused. _recurse_groups: bool, - _filter: &RepositoryFilter, + _filter: &RepositoryFilterConfig, ) -> anyhow::Result> { let octocrab = { let mut builder = Octocrab::builder(); @@ -142,7 +143,7 @@ impl KagamiSource for KagamiSourceGithub { &self, owner: &str, token: Option<&str>, - _filter: &RepositoryFilter, + _filter: &RepositoryFilterConfig, ) -> anyhow::Result> { let octocrab = { let mut builder = Octocrab::builder();