From 6ca2e8b2f86419f5babc565f4ea0d08d0fd13ea8 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Fri, 16 Jan 2026 13:03:17 +0900 Subject: [PATCH] source/gitea: implement --- src/sources.rs | 37 +++++-- src/sources/gitea.rs | 235 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 259 insertions(+), 13 deletions(-) diff --git a/src/sources.rs b/src/sources.rs index 2822d63..8cf0b1e 100644 --- a/src/sources.rs +++ b/src/sources.rs @@ -8,7 +8,9 @@ use std::collections::HashSet; pub trait KagamiSource { const NAME: &'static str; - const DEFAULT_URI_PREFIX: &'static str; + const DEFAULT_HOST: &'static str; + + fn api_base(&self) -> String; // Get Repository // - Use native API if available @@ -47,28 +49,47 @@ pub trait KagamiSource { ) -> impl std::future::Future>> + Send; } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum RepositoryVisibility { Public, Internal, Private, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum RegexFilter { - Include(regex::Regex), - Exclude(regex::Regex), + Include(String), + Exclude(String), } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RepositoryFilter { pub filter_regex: Vec, pub include_forks: bool, pub include_archived: bool, - pub visibility: HashSet, + pub min_stars: Option, + pub max_stars: Option, + pub include_public: bool, + pub include_internal: bool, + pub include_private: bool, } -#[derive(Debug, Clone)] +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, pub full_name: String, diff --git a/src/sources/gitea.rs b/src/sources/gitea.rs index 0d6cf9f..4f3dd41 100644 --- a/src/sources/gitea.rs +++ b/src/sources/gitea.rs @@ -1,11 +1,236 @@ +use std::collections::HashSet; + +use anyhow::Context; + +use crate::sources::{KagamiSource, RepositoryFilter, RepositoryInfo, RepositoryVisibility}; + +use gitea_sdk; +use gitea_sdk::Auth::Token; +use gitea_sdk::Client as GiteaClient; + pub struct KagamiSourceGitea { - host: String, + host: Option, } impl KagamiSourceGitea { - pub fn new(host: &str) -> Self { - Self { - host: host.to_string(), + pub fn new(host: Option<&str>) -> Self { + Self { + host: host.map(|s| s.to_string()), + } + } +} + +impl KagamiSource for KagamiSourceGitea { + const NAME: &'static str = "gitea"; + const DEFAULT_HOST: &'static str = "gitea.com"; + + fn api_base(&self) -> String { + format!("https://{}", self.host.as_deref().unwrap_or(Self::DEFAULT_HOST)) + } + + async fn get_repositories_by_user( + &self, + owner: &str, + token: Option<&str>, + _filter: &RepositoryFilter, + ) -> anyhow::Result> { + let base = self.api_base(); + + let client: GiteaClient = match token { + Some(tok) if !tok.is_empty() => GiteaClient::new(&base, Token(tok.to_string())), + _ => GiteaClient::new(&base, gitea_sdk::Auth::::None), + }; + + let mut page = 1i64; + let mut set = HashSet::new(); + + loop { + let repos_resp = client + .users(owner) + .list_repos() + .limit(100) + .page(page) + .send(&client) + .await + .context("failed to list gitea user repos")?; + + if repos_resp.is_empty() { + break; + } + + for r in repos_resp.iter() { + let info = RepositoryInfo { + name: r.name.clone(), + full_name: r.full_name.clone(), + url: r.html_url.clone(), + icon_url: if r.avatar_url.is_empty() { + None + } else { + Some(r.avatar_url.clone()) + }, + is_fork: r.fork, + is_archived: r.archived, + visibility: if r.private { + RepositoryVisibility::Private + } else if r.internal { + RepositoryVisibility::Internal + } else { + RepositoryVisibility::Public + }, + stars: r.stars_count as u32, + description: if r.description.is_empty() { + None + } else { + Some(r.description.clone()) + }, + }; + set.insert(info); + } + + if repos_resp.len() < 100 { + break; + } + page += 1; + } + + Ok(set) + } + + async fn get_repositories_by_organization( + &self, + owner: &str, + token: Option<&str>, + // NOTE: Gitea does not have nested organizations/groups like GitLab, + _recurse_groups: bool, + _filter: &RepositoryFilter, + ) -> anyhow::Result> { + let base = self.api_base(); + + let client: GiteaClient = match token { + Some(tok) if !tok.is_empty() => GiteaClient::new(&base, Token(tok.to_string())), + _ => GiteaClient::new(&base, gitea_sdk::Auth::::None), + }; + + let mut page = 1i64; + let mut set = HashSet::new(); + + loop { + let repos_resp = client + .orgs(owner) + .list_repos() + .limit(100) + .page(page) + .send(&client) + .await + .context("failed to list gitea org repos")?; + + if repos_resp.is_empty() { + break; + } + + for r in repos_resp.iter() { + let info = RepositoryInfo { + name: r.name.clone(), + full_name: r.full_name.clone(), + url: r.html_url.clone(), + icon_url: if r.avatar_url.is_empty() { + None + } else { + Some(r.avatar_url.clone()) + }, + is_fork: r.fork, + is_archived: r.archived, + visibility: if r.private { + RepositoryVisibility::Private + } else if r.internal { + RepositoryVisibility::Internal + } else { + RepositoryVisibility::Public + }, + stars: r.stars_count as u32, + description: if r.description.is_empty() { + None + } else { + Some(r.description.clone()) + }, + }; + set.insert(info); + } + + if repos_resp.len() < 100 { + break; + } + page += 1; + } + + Ok(set) + } + + async fn get_repositories_by_stars( + &self, + owner: &str, + token: Option<&str>, + _filter: &RepositoryFilter, + ) -> anyhow::Result> { + let base = self.api_base(); + + let client: GiteaClient = match token { + Some(tok) if !tok.is_empty() => GiteaClient::new(&base, Token(tok.to_string())), + _ => GiteaClient::new(&base, gitea_sdk::Auth::::None), + }; + + let mut page = 1u64; + let mut set = HashSet::new(); + + loop { + let repos_resp = client + .users(owner) + .list_starred() + .limit(100u64) + .page(page) + .send(&client) + .await + .context("failed to list gitea starred repos")?; + + if repos_resp.is_empty() { + break; + } + + for r in repos_resp.iter() { + let info = RepositoryInfo { + name: r.name.clone(), + full_name: r.full_name.clone(), + url: r.html_url.clone(), + icon_url: if r.avatar_url.is_empty() { + None + } else { + Some(r.avatar_url.clone()) + }, + is_fork: r.fork, + is_archived: r.archived, + visibility: if r.private { + RepositoryVisibility::Private + } else if r.internal { + RepositoryVisibility::Internal + } else { + RepositoryVisibility::Public + }, + stars: r.stars_count as u32, + description: if r.description.is_empty() { + None + } else { + Some(r.description.clone()) + }, + }; + set.insert(info); + } + + if repos_resp.len() < 100 { + break; + } + page += 1; + } + + Ok(set) } - } }