Skip to main content

git_cliff_core/remote/
gitea.rs

1use async_stream::stream as async_stream;
2use futures::{Stream, StreamExt, stream};
3use reqwest_middleware::ClientWithMiddleware;
4use serde::{Deserialize, Serialize};
5
6use super::{Debug, MAX_PAGE_SIZE, RemoteClient, RemoteCommit, RemotePullRequest};
7use crate::config::Remote;
8use crate::error::{Error, Result};
9
10/// Template variables related to this remote.
11pub(crate) const TEMPLATE_VARIABLES: &[&str] = &["gitea", "commit.gitea", "commit.remote"];
12
13/// Representation of a single commit.
14#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct GiteaCommit {
16    /// SHA.
17    pub sha: String,
18    /// Author of the commit.
19    pub author: Option<GiteaCommitAuthor>,
20    /// Timestamp of the commit.
21    pub created: String,
22}
23
24impl RemoteCommit for GiteaCommit {
25    fn id(&self) -> String {
26        self.sha.clone()
27    }
28
29    fn username(&self) -> Option<String> {
30        self.author.clone().and_then(|v| v.login)
31    }
32
33    fn timestamp(&self) -> Option<i64> {
34        Some(self.convert_to_unix_timestamp(self.created.clone().as_str()))
35    }
36}
37
38/// Author of the commit.
39#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct GiteaCommitAuthor {
41    /// Username.
42    pub login: Option<String>,
43}
44
45/// Label of the pull request.
46#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct PullRequestLabel {
49    /// Name of the label.
50    pub name: String,
51}
52
53/// Representation of a single pull request.
54#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct GiteaPullRequest {
56    /// Pull request number.
57    pub number: i64,
58    /// Pull request title.
59    pub title: Option<String>,
60    /// Account that opened the pull request.
61    pub user: Option<GiteaCommitAuthor>,
62    /// SHA of the merge commit.
63    pub merge_commit_sha: Option<String>,
64    /// Labels of the pull request.
65    pub labels: Vec<PullRequestLabel>,
66}
67
68impl RemotePullRequest for GiteaPullRequest {
69    fn number(&self) -> i64 {
70        self.number
71    }
72
73    fn title(&self) -> Option<String> {
74        self.title.clone()
75    }
76
77    fn author(&self) -> Option<String> {
78        self.user.clone().and_then(|v| v.login)
79    }
80
81    fn labels(&self) -> Vec<String> {
82        self.labels.iter().map(|v| v.name.clone()).collect()
83    }
84
85    fn merge_commit(&self) -> Option<String> {
86        self.merge_commit_sha.clone()
87    }
88}
89
90/// HTTP client for handling Gitea REST API requests.
91#[derive(Debug, Clone)]
92pub struct GiteaClient {
93    /// Remote.
94    remote: Remote,
95    /// HTTP client.
96    client: ClientWithMiddleware,
97}
98
99/// Constructs a Gitea client from the remote configuration.
100impl TryFrom<Remote> for GiteaClient {
101    type Error = Error;
102    fn try_from(remote: Remote) -> Result<Self> {
103        Ok(Self {
104            client: remote.create_client("application/json")?,
105            remote,
106        })
107    }
108}
109
110impl RemoteClient for GiteaClient {
111    const API_URL: &'static str = "https://codeberg.org";
112    const API_URL_ENV: &'static str = "GITEA_API_URL";
113
114    fn remote(&self) -> Remote {
115        self.remote.clone()
116    }
117
118    fn client(&self) -> ClientWithMiddleware {
119        self.client.clone()
120    }
121}
122
123impl GiteaClient {
124    /// Constructs the URL for Gitea commits API.
125    fn commits_url(api_url: &str, remote: &Remote, ref_name: Option<&str>, page: i32) -> String {
126        let mut url = format!(
127            "{}/api/v1/repos/{}/{}/commits?limit={MAX_PAGE_SIZE}&page={page}",
128            api_url, remote.owner, remote.repo
129        );
130
131        if let Some(ref_name) = ref_name {
132            url.push_str(&format!("&sha={ref_name}"));
133        }
134
135        url
136    }
137
138    /// Constructs the URL for Gitea pull requests API.
139    fn pull_requests_url(api_url: &str, remote: &Remote, page: i32) -> String {
140        format!(
141            "{}/api/v1/repos/{}/{}/pulls?limit={MAX_PAGE_SIZE}&page={page}&state=closed",
142            api_url, remote.owner, remote.repo
143        )
144    }
145
146    /// Fetches the complete list of commits.
147    /// This is inefficient for large repositories; consider using
148    /// `get_commit_stream` instead.
149    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
150    pub async fn get_commits(&self, ref_name: Option<&str>) -> Result<Vec<Box<dyn RemoteCommit>>> {
151        use futures::TryStreamExt;
152        crate::set_progress_message!("Fetching all commits from Gitea");
153        self.get_commit_stream(ref_name).try_collect().await
154    }
155
156    /// Fetches the complete list of pull requests.
157    /// This is inefficient for large repositories; consider using
158    /// `get_pull_request_stream` instead.
159    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
160    pub async fn get_pull_requests(&self) -> Result<Vec<Box<dyn RemotePullRequest>>> {
161        use futures::TryStreamExt;
162        crate::set_progress_message!("Fetching all pull requests from Gitea");
163        self.get_pull_request_stream().try_collect().await
164    }
165
166    fn get_commit_stream(
167        &self,
168        ref_name: Option<&str>,
169    ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
170        let ref_name = ref_name.map(ToString::to_string);
171        async_stream! {
172            let page_stream = stream::iter(0..)
173                .map(|page| {
174                    let ref_name = ref_name.clone();
175                    async move {
176                        let url = Self::commits_url(&self.api_url(), &self.remote(), ref_name.as_deref(), page);
177                        self.get_json::<Vec<GiteaCommit>>(&url).await
178                    }
179                })
180                .buffered(10);
181
182            let mut page_stream = Box::pin(page_stream);
183
184            while let Some(page_result) = page_stream.next().await {
185                match page_result {
186                    Ok(commits) => {
187                        if commits.is_empty() {
188                            break;
189                        }
190
191                        for commit in commits {
192                            yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
193                        }
194                    }
195                    Err(e) => {
196                        yield Err(e);
197                        break;
198                    }
199                }
200            }
201        }
202    }
203
204    fn get_pull_request_stream(
205        &self,
206    ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
207        async_stream! {
208        let page_stream = stream::iter(0..)
209            .map(|page| async move {
210                let url = Self::pull_requests_url(&self.api_url(), &self.remote(), page);
211                self.get_json::<Vec<GiteaPullRequest>>(&url).await
212            })
213            .buffered(5);
214
215        let mut page_stream = Box::pin(page_stream);
216
217        while let Some(page_result) = page_stream.next().await {
218            match page_result {
219                Ok(prs) => {
220                    if prs.is_empty() {
221                        break;
222                    }
223
224                    for pr in prs {
225                        yield Ok(Box::new(pr) as Box<dyn RemotePullRequest>);
226                    }
227                }
228                Err(e) => {
229                    yield Err(e);
230                    break;
231                }
232            }
233        }
234            }
235    }
236}
237
238#[cfg(test)]
239mod test {
240    use pretty_assertions::assert_eq;
241
242    use super::*;
243    use crate::remote::{RemoteCommit, RemotePullRequest};
244
245    #[test]
246    fn timestamp() {
247        let remote_commit = GiteaCommit {
248            sha: String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071"),
249            author: Some(GiteaCommitAuthor {
250                login: Some(String::from("orhun")),
251            }),
252            created: String::from("2021-07-18T15:14:39+03:00"),
253        };
254
255        assert_eq!(Some(1_626_610_479), remote_commit.timestamp());
256    }
257
258    #[test]
259    fn pull_request_author() {
260        let pull_request: GiteaPullRequest = serde_json::from_str(
261            r#"{
262                "number": 42,
263                "title": "feat: add pr_author",
264                "merge_commit_sha": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071",
265                "labels": [],
266                "user": { "login": "contributor" }
267            }"#,
268        )
269        .expect("failed to deserialize pull request");
270
271        assert_eq!(Some(String::from("contributor")), pull_request.author());
272    }
273
274    #[test]
275    fn pull_request_author_missing() {
276        let pull_request: GiteaPullRequest = serde_json::from_str(
277            r#"{
278                "number": 42,
279                "title": "feat: add pr_author",
280                "merge_commit_sha": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071",
281                "labels": []
282            }"#,
283        )
284        .expect("failed to deserialize pull request");
285
286        assert_eq!(None, pull_request.author());
287    }
288}