commands/list: init

This commit is contained in:
2026-01-19 09:39:56 +09:00
parent 9479d5acb2
commit a365891b77
8 changed files with 328 additions and 75 deletions

3
src/commands.rs Normal file
View File

@@ -0,0 +1,3 @@
mod list;
pub use list::list;

255
src/commands/list.rs Normal file
View File

@@ -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<HashSet<RepositoryInfo>> {
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<HashSet<RepositoryInfo>> {
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<HashSet<RepositoryInfo>> {
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<SourceHostConfig> {
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),
}
}

View File

@@ -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<String>,
}
impl SourceHostConfig {
pub fn get_token(&self) -> anyhow::Result<Option<String>> {
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 {
}
}
}

View File

@@ -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!();
}
}
}

View File

@@ -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<Output = anyhow::Result<HashSet<RepositoryInfo>>> + 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<Output = anyhow::Result<HashSet<RepositoryInfo>>> + Send;
fn get_repositories_by_stars(
&self,
owner: &str,
token: Option<&str>,
filter: &RepositoryFilter,
filter: &RepositoryFilterConfig,
) -> impl std::future::Future<Output = anyhow::Result<HashSet<RepositoryInfo>>> + 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<RegexFilter>,
pub include_forks: bool,
pub include_archived: bool,
pub min_stars: Option<u32>,
pub max_stars: Option<u32>,
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,

View File

@@ -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<HashSet<RepositoryInfo>> {
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<HashSet<RepositoryInfo>> {
Ok(HashSet::new())
}
@@ -45,7 +45,7 @@ impl KagamiSource for KagamiSourceGit {
&self,
_owner: &str,
_token: Option<&str>,
_filter: &RepositoryFilter,
_filter: &RepositoryFilterConfig,
) -> anyhow::Result<HashSet<RepositoryInfo>> {
Ok(HashSet::new())
}

View File

@@ -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<HashSet<RepositoryInfo>> {
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<HashSet<RepositoryInfo>> {
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<HashSet<RepositoryInfo>> {
let base = self.api_base();

View File

@@ -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<String>,
@@ -32,7 +35,7 @@ impl KagamiSource for KagamiSourceGithub {
&self,
owner: &str,
token: Option<&str>,
_filter: &RepositoryFilter,
_filter: &RepositoryFilterConfig,
) -> anyhow::Result<HashSet<RepositoryInfo>> {
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 &current.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<HashSet<RepositoryInfo>> {
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<HashSet<RepositoryInfo>> {
let octocrab = {
let mut builder = Octocrab::builder();