Skip to main content

facto_core/forges/
codeberg.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use crate::forges::{Forge, ForgeError, ForgeResult};
5use crate::models::*;
6
7pub struct Codeberg {
8    client: reqwest::Client,
9}
10
11impl Codeberg {
12    pub fn new(client: reqwest::Client) -> Self {
13        Self { client }
14    }
15}
16
17#[derive(serde::Deserialize)]
18struct CodebergRelease {
19    tag_name: String,
20    name: Option<String>,
21    published_at: Option<String>,
22    prerelease: bool,
23    draft: bool,
24    html_url: Option<String>,
25    assets: Vec<CodebergAsset>,
26}
27
28#[derive(serde::Deserialize)]
29struct CodebergAsset {
30    name: String,
31    size: u64,
32    browser_download_url: String,
33    download_count: u64,
34    #[serde(default)]
35    content_type: String,
36}
37
38#[derive(serde::Deserialize)]
39struct CodebergSearchResponse {
40    data: Vec<CodebergRepo>,
41}
42
43#[derive(serde::Deserialize)]
44struct CodebergRepo {
45    full_name: String,
46    name: String,
47    owner: CodebergRepoOwner,
48    description: Option<String>,
49    stars_count: u64,
50    language: Option<String>,
51    #[serde(default)]
52    topics: Vec<String>,
53    html_url: String,
54    updated_at: Option<String>,
55}
56
57#[derive(serde::Deserialize)]
58struct CodebergRepoOwner {
59    login: String,
60}
61
62impl Forge for Codeberg {
63    fn id(&self) -> &str {
64        "codeberg"
65    }
66
67    fn display_name(&self) -> &str {
68        "Codeberg"
69    }
70
71    fn supports_releases(&self) -> bool {
72        true
73    }
74
75    fn list_releases<'a>(
76        &'a self,
77        owner: &'a str,
78        repo: &'a str,
79        limit: usize,
80    ) -> Pin<Box<dyn Future<Output = ForgeResult<Vec<ReleaseInfo>>> + Send + 'a>> {
81        Box::pin(async move {
82            let url = format!(
83                "https://codeberg.org/api/v1/repos/{}/{}/releases?limit={}",
84                urlencoding::encode(owner),
85                urlencoding::encode(repo),
86                limit
87            );
88            let resp = self.client.get(&url).send().await?;
89
90            match resp.status().as_u16() {
91                200 => {
92                    let releases: Vec<CodebergRelease> = crate::forges::bounded_json(resp).await?;
93                    Ok(releases
94                        .into_iter()
95                        .map(|r| ReleaseInfo {
96                            tag: r.tag_name,
97                            name: r.name,
98                            published_at: r.published_at.as_deref().and_then(|ts| ts.parse().ok()),
99                            prerelease: r.prerelease,
100                            draft: r.draft,
101                            html_url: r.html_url.unwrap_or_default(),
102                            assets: r
103                                .assets
104                                .into_iter()
105                                .map(|a| ReleaseAsset {
106                                    name: a.name,
107                                    size: a.size,
108                                    download_url: a.browser_download_url,
109                                    download_count: a.download_count,
110                                    content_type: a.content_type,
111                                })
112                                .collect(),
113                        })
114                        .collect())
115                }
116                404 => Ok(Vec::new()),
117                429 => Err(ForgeError::RateLimited),
118                status => Err(ForgeError::Parse(format!("unexpected status {}", status))),
119            }
120        })
121    }
122
123    fn supports_search(&self) -> bool {
124        true
125    }
126
127    fn search_repositories<'a>(
128        &'a self,
129        query: &'a str,
130        sort: RepoSort,
131        _language: Option<&'a str>,
132        limit: usize,
133    ) -> Pin<Box<dyn Future<Output = ForgeResult<Vec<RepositorySearchResult>>> + Send + 'a>> {
134        Box::pin(async move {
135            let mut url = format!(
136                "https://codeberg.org/api/v1/repos/search?q={}&limit={}",
137                urlencoding::encode(query),
138                limit.min(50),
139            );
140            // Forgejo (Codeberg) honours sort=stars/updated; verified live.
141            match sort {
142                RepoSort::Stars => url.push_str("&sort=stars&order=desc"),
143                RepoSort::Updated => url.push_str("&sort=updated&order=desc"),
144                RepoSort::Relevance => {}
145            }
146            let resp = self.client.get(&url).send().await?;
147            match resp.status().as_u16() {
148                200 => {}
149                429 => return Err(ForgeError::RateLimited),
150                status => return Err(ForgeError::Parse(format!("unexpected status {status}"))),
151            }
152            let data: CodebergSearchResponse = crate::forges::bounded_json(resp).await?;
153            Ok(data
154                .data
155                .into_iter()
156                .map(|r| RepositorySearchResult {
157                    forge: "codeberg".into(),
158                    full_name: r.full_name,
159                    owner: r.owner.login,
160                    name: r.name,
161                    description: r.description,
162                    stars: r.stars_count,
163                    language: r.language,
164                    topics: r.topics,
165                    url: r.html_url,
166                    updated_at: r.updated_at.as_deref().and_then(|ts| ts.parse().ok()),
167                })
168                .collect())
169        })
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[tokio::test]
178    #[ignore] // requires network
179    async fn test_codeberg_list_releases() {
180        let cb = Codeberg::new(crate::http::default_client().unwrap());
181        let releases = cb.list_releases("forgejo", "forgejo", 5).await.unwrap();
182        assert!(!releases.is_empty());
183        assert!(releases.len() <= 5);
184    }
185
186    #[tokio::test]
187    #[ignore] // requires network
188    async fn test_search_repositories() {
189        let cb = Codeberg::new(crate::http::default_client().unwrap());
190        let results = cb
191            .search_repositories("forgejo", RepoSort::Stars, None, 5)
192            .await
193            .unwrap();
194        assert!(!results.is_empty());
195        assert!(results.len() <= 5);
196        assert_eq!(results[0].forge, "codeberg");
197    }
198}