cuddle_please_misc/
gitea_client.rs

1use anyhow::Context;
2use reqwest::header::{HeaderMap, HeaderValue};
3use semver::Version;
4use serde::{Deserialize, Serialize};
5
6pub trait RemoteGitEngine {
7    fn connect(&self, owner: &str, repo: &str) -> anyhow::Result<()>;
8
9    fn get_tags(&self, owner: &str, repo: &str) -> anyhow::Result<Vec<Tag>>;
10
11    fn get_commits_since(
12        &self,
13        owner: &str,
14        repo: &str,
15        since_sha: Option<&str>,
16        branch: &str,
17    ) -> anyhow::Result<Vec<Commit>>;
18
19    fn get_pull_request(&self, owner: &str, repo: &str) -> anyhow::Result<Option<usize>>;
20
21    fn create_pull_request(
22        &self,
23        owner: &str,
24        repo: &str,
25        version: &str,
26        body: &str,
27        base: &str,
28    ) -> anyhow::Result<usize>;
29
30    fn update_pull_request(
31        &self,
32        owner: &str,
33        repo: &str,
34        version: &str,
35        body: &str,
36        index: usize,
37    ) -> anyhow::Result<usize>;
38
39    fn create_release(
40        &self,
41        owner: &str,
42        repo: &str,
43        version: &str,
44        body: &str,
45        prerelease: bool,
46    ) -> anyhow::Result<Release>;
47}
48
49pub type DynRemoteGitClient = Box<dyn RemoteGitEngine>;
50
51#[allow(dead_code)]
52pub struct GiteaClient {
53    url: String,
54    token: Option<String>,
55    pub allow_insecure: bool,
56}
57
58const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
59
60impl GiteaClient {
61    pub fn new(url: &str, token: Option<&str>) -> Self {
62        Self {
63            url: url.into(),
64            token: token.map(|t| t.into()),
65            allow_insecure: false,
66        }
67    }
68
69    fn create_client(&self) -> anyhow::Result<reqwest::blocking::Client> {
70        let cb = reqwest::blocking::ClientBuilder::new();
71        let mut header_map = HeaderMap::new();
72        if let Some(token) = &self.token {
73            header_map.insert(
74                "Authorization",
75                HeaderValue::from_str(format!("token {}", token).as_str())?,
76            );
77        }
78
79        let client = cb
80            .user_agent(APP_USER_AGENT)
81            .default_headers(header_map)
82            .danger_accept_invalid_certs(self.allow_insecure)
83            .build()?;
84
85        Ok(client)
86    }
87
88    fn get_commits_since_inner<F>(
89        &self,
90        owner: &str,
91        repo: &str,
92        since_sha: Option<&str>,
93        branch: &str,
94        get_commits: F,
95    ) -> anyhow::Result<Vec<Commit>>
96    where
97        F: Fn(&str, &str, &str, usize) -> anyhow::Result<(Vec<Commit>, bool)>,
98    {
99        let mut commits = Vec::new();
100        let mut page = 1;
101
102        let owner: String = owner.into();
103        let repo: String = repo.into();
104        let since_sha: Option<String> = since_sha.map(|ss| ss.into());
105        let branch: String = branch.into();
106        let mut found_commit = false;
107        loop {
108            let (new_commits, has_more) = get_commits(&owner, &repo, &branch, page)?;
109
110            for commit in new_commits {
111                if let Some(since_sha) = &since_sha {
112                    if commit.sha.contains(since_sha) {
113                        found_commit = true;
114                    } else if !found_commit {
115                        commits.push(commit);
116                    }
117                } else {
118                    commits.push(commit);
119                }
120            }
121
122            if !has_more {
123                break;
124            }
125            page += 1;
126        }
127
128        if !found_commit && since_sha.is_some() {
129            return Err(anyhow::anyhow!(
130                "sha was not found in commit chain: {} on branch: {}",
131                since_sha.unwrap_or("".into()),
132                branch
133            ));
134        }
135
136        Ok(commits)
137    }
138
139    fn get_pull_request_inner<F>(
140        &self,
141        owner: &str,
142        repo: &str,
143        request_pull_request: F,
144    ) -> anyhow::Result<Option<usize>>
145    where
146        F: Fn(&str, &str, usize) -> anyhow::Result<(Vec<PullRequest>, bool)>,
147    {
148        let mut page = 1;
149
150        let owner: String = owner.into();
151        let repo: String = repo.into();
152        loop {
153            let (pull_requests, has_more) = request_pull_request(&owner, &repo, page)?;
154
155            for pull_request in pull_requests {
156                if pull_request.head.r#ref.contains("cuddle-please/release") {
157                    return Ok(Some(pull_request.number));
158                }
159            }
160
161            if !has_more {
162                break;
163            }
164            page += 1;
165        }
166
167        Ok(None)
168    }
169}
170
171impl RemoteGitEngine for GiteaClient {
172    fn connect(&self, owner: &str, repo: &str) -> anyhow::Result<()> {
173        let client = self.create_client()?;
174
175        tracing::trace!(owner = &owner, repo = &repo, "gitea connect");
176
177        let request = client
178            .get(format!(
179                "{}/api/v1/repos/{}/{}",
180                &self.url.trim_end_matches('/'),
181                owner,
182                repo
183            ))
184            .build()?;
185
186        let resp = client.execute(request)?;
187
188        if !resp.status().is_success() {
189            resp.error_for_status()?;
190            return Ok(());
191        }
192
193        Ok(())
194    }
195
196    fn get_tags(&self, owner: &str, repo: &str) -> anyhow::Result<Vec<Tag>> {
197        let client = self.create_client()?;
198
199        let request = client
200            .get(format!(
201                "{}/api/v1/repos/{}/{}/tags",
202                &self.url.trim_end_matches('/'),
203                owner,
204                repo
205            ))
206            .build()?;
207
208        let resp = client.execute(request)?;
209
210        if !resp.status().is_success() {
211            return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
212        }
213        let tags: Vec<Tag> = resp.json()?;
214
215        Ok(tags)
216    }
217
218    fn get_commits_since(
219        &self,
220        owner: &str,
221        repo: &str,
222        since_sha: Option<&str>,
223        branch: &str,
224    ) -> anyhow::Result<Vec<Commit>> {
225        let get_commits_since_page = |owner: &str,
226                                      repo: &str,
227                                      branch: &str,
228                                      page: usize|
229         -> anyhow::Result<(Vec<Commit>, bool)> {
230            let client = self.create_client()?;
231            tracing::trace!(
232                owner = owner,
233                repo = repo,
234                branch = branch,
235                page = page,
236                "fetching tags"
237            );
238            let request = client
239                .get(format!(
240                    "{}/api/v1/repos/{}/{}/commits?page={}&limit={}&sha={}&stat=false&verification=false&files=false",
241                    &self.url.trim_end_matches('/'),
242                    owner,
243                    repo,
244                    page,
245                    50,
246                    branch,
247                ))
248                .build()?;
249            let resp = client.execute(request)?;
250
251            let mut has_more = false;
252
253            if let Some(gitea_has_more) = resp.headers().get("X-HasMore") {
254                let gitea_has_more = gitea_has_more.to_str()?;
255                if gitea_has_more == "true" || gitea_has_more == "True" {
256                    has_more = true;
257                }
258            }
259
260            if !resp.status().is_success() {
261                return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
262            }
263            let commits: Vec<Commit> = resp.json()?;
264
265            Ok((commits, has_more))
266        };
267
268        let commits =
269            self.get_commits_since_inner(owner, repo, since_sha, branch, get_commits_since_page)?;
270
271        Ok(commits)
272    }
273
274    fn get_pull_request(&self, owner: &str, repo: &str) -> anyhow::Result<Option<usize>> {
275        let request_pull_request =
276            |owner: &str, repo: &str, page: usize| -> anyhow::Result<(Vec<PullRequest>, bool)> {
277                let client = self.create_client()?;
278                tracing::trace!(owner = owner, repo = repo, "fetching pull-requests");
279                let request = client
280                    .get(format!(
281                        "{}/api/v1/repos/{}/{}/pulls?state=open&sort=recentupdate&page={}&limit={}",
282                        &self.url.trim_end_matches('/'),
283                        owner,
284                        repo,
285                        page,
286                        50,
287                    ))
288                    .build()?;
289                let resp = client.execute(request)?;
290
291                let mut has_more = false;
292
293                if let Some(gitea_has_more) = resp.headers().get("X-HasMore") {
294                    let gitea_has_more = gitea_has_more.to_str()?;
295                    if gitea_has_more == "true" || gitea_has_more == "True" {
296                        has_more = true;
297                    }
298                }
299
300                if !resp.status().is_success() {
301                    return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
302                }
303                let commits: Vec<PullRequest> = resp.json()?;
304
305                Ok((commits, has_more))
306            };
307
308        self.get_pull_request_inner(owner, repo, request_pull_request)
309    }
310
311    fn create_pull_request(
312        &self,
313        owner: &str,
314        repo: &str,
315        version: &str,
316        body: &str,
317        base: &str,
318    ) -> anyhow::Result<usize> {
319        #[derive(Clone, Debug, Serialize, Deserialize)]
320        struct CreatePullRequestOption {
321            base: String,
322            body: String,
323            head: String,
324            title: String,
325        }
326
327        let client = self.create_client()?;
328
329        let request = CreatePullRequestOption {
330            base: base.into(),
331            body: body.into(),
332            head: "cuddle-please/release".into(),
333            title: format!("chore(release): {}", version),
334        };
335
336        tracing::trace!(
337            owner = owner,
338            repo = repo,
339            version = version,
340            base = base,
341            "create pull_request"
342        );
343        let request = client
344            .post(format!(
345                "{}/api/v1/repos/{}/{}/pulls",
346                &self.url.trim_end_matches('/'),
347                owner,
348                repo,
349            ))
350            .json(&request)
351            .build()?;
352        let resp = client.execute(request)?;
353
354        if !resp.status().is_success() {
355            return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
356        }
357        let commits: PullRequest = resp.json()?;
358
359        Ok(commits.number)
360    }
361
362    fn update_pull_request(
363        &self,
364        owner: &str,
365        repo: &str,
366        version: &str,
367        body: &str,
368        index: usize,
369    ) -> anyhow::Result<usize> {
370        #[derive(Clone, Debug, Serialize, Deserialize)]
371        struct CreatePullRequestOption {
372            body: String,
373            title: String,
374        }
375
376        let client = self.create_client()?;
377
378        let request = CreatePullRequestOption {
379            body: body.into(),
380            title: format!("chore(release): {}", version),
381        };
382
383        tracing::trace!(
384            owner = owner,
385            repo = repo,
386            version = version,
387            "update pull_request"
388        );
389        let request = client
390            .patch(format!(
391                "{}/api/v1/repos/{}/{}/pulls/{}",
392                &self.url.trim_end_matches('/'),
393                owner,
394                repo,
395                index
396            ))
397            .json(&request)
398            .build()?;
399        let resp = client.execute(request)?;
400
401        if !resp.status().is_success() {
402            return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
403        }
404        let commits: PullRequest = resp.json()?;
405
406        Ok(commits.number)
407    }
408
409    fn create_release(
410        &self,
411        owner: &str,
412        repo: &str,
413        version: &str,
414        body: &str,
415        prerelease: bool,
416    ) -> anyhow::Result<Release> {
417        #[derive(Clone, Debug, Serialize, Deserialize)]
418        struct CreateReleaseOption {
419            body: String,
420            draft: bool,
421            name: String,
422            prerelease: bool,
423            #[serde(alias = "tag_name")]
424            tag_name: String,
425        }
426
427        let client = self.create_client()?;
428
429        let request = CreateReleaseOption {
430            body: body.into(),
431            draft: false,
432            name: version.into(),
433            prerelease,
434            tag_name: version.into(),
435        };
436
437        tracing::trace!(
438            owner = owner,
439            repo = repo,
440            version = version,
441            "create release"
442        );
443        let request = client
444            .post(format!(
445                "{}/api/v1/repos/{}/{}/releases",
446                &self.url.trim_end_matches('/'),
447                owner,
448                repo,
449            ))
450            .json(&request)
451            .build()?;
452        let resp = client.execute(request)?;
453
454        if !resp.status().is_success() {
455            return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
456        }
457        let release: Release = resp.json()?;
458
459        Ok(release)
460    }
461}
462
463#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
464pub struct Release {
465    id: usize,
466    url: String,
467}
468
469#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
470pub struct PullRequest {
471    number: usize,
472    head: PRBranchInfo,
473}
474
475#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
476pub struct PRBranchInfo {
477    #[serde(alias = "ref")]
478    r#ref: String,
479    label: String,
480}
481
482#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
483pub struct Commit {
484    sha: String,
485    pub created: String,
486    pub commit: CommitDetails,
487}
488
489impl Commit {
490    pub fn get_title(&self) -> String {
491        self.commit
492            .message
493            .split('\n')
494            .take(1)
495            .collect::<Vec<&str>>()
496            .join("\n")
497    }
498}
499
500#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
501pub struct CommitDetails {
502    pub message: String,
503}
504
505#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
506pub struct Tag {
507    pub id: String,
508    pub message: String,
509    pub name: String,
510    pub commit: TagCommit,
511}
512
513impl TryFrom<Tag> for Version {
514    type Error = anyhow::Error;
515
516    fn try_from(value: Tag) -> Result<Self, Self::Error> {
517        tracing::trace!(name = &value.name, "parsing tag into version");
518        value
519            .name
520            .parse::<Version>()
521            .context("could not get version from tag")
522    }
523}
524impl TryFrom<&Tag> for Version {
525    type Error = anyhow::Error;
526
527    fn try_from(value: &Tag) -> Result<Self, Self::Error> {
528        tracing::trace!(name = &value.name, "parsing tag into version");
529        value
530            .name
531            .parse::<Version>()
532            .context("could not get version from tag")
533    }
534}
535
536#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
537pub struct TagCommit {
538    pub created: String,
539    pub sha: String,
540    pub url: String,
541}
542
543#[cfg(test)]
544mod test {
545    use tracing_test::traced_test;
546
547    use crate::gitea_client::{Commit, CommitDetails};
548
549    use super::GiteaClient;
550
551    fn get_api_res() -> Vec<Vec<Commit>> {
552        let api_results = vec![
553            vec![Commit {
554                sha: "first-sha".into(),
555                created: "".into(),
556                commit: CommitDetails {
557                    message: "first-message".into(),
558                },
559            }],
560            vec![Commit {
561                sha: "second-sha".into(),
562                created: "".into(),
563                commit: CommitDetails {
564                    message: "second-message".into(),
565                },
566            }],
567            vec![Commit {
568                sha: "third-sha".into(),
569                created: "".into(),
570                commit: CommitDetails {
571                    message: "third-message".into(),
572                },
573            }],
574        ];
575
576        api_results
577    }
578
579    fn get_commits(sha: String) -> anyhow::Result<(Vec<Vec<Commit>>, Vec<Commit>)> {
580        let api_res = get_api_res();
581        let client = GiteaClient::new("", Some(""));
582
583        let commits = client.get_commits_since_inner(
584            "owner",
585            "repo",
586            Some(&sha),
587            "some-branch",
588            |_, _, _, page| -> anyhow::Result<(Vec<Commit>, bool)> {
589                let commit_page = api_res.get(page - 1).unwrap();
590
591                Ok((commit_page.clone(), page != 3))
592            },
593        )?;
594
595        Ok((api_res, commits))
596    }
597
598    #[test]
599    #[traced_test]
600    fn finds_tag_in_list() {
601        let (expected, actual) = get_commits("second-sha".into()).unwrap();
602
603        assert_eq!(
604            expected.get(0).unwrap().clone().as_slice(),
605            actual.as_slice()
606        );
607    }
608
609    #[test]
610    #[traced_test]
611    fn finds_tag_in_list_already_newest_commit() {
612        let (_, actual) = get_commits("first-sha".into()).unwrap();
613
614        assert_eq!(0, actual.len());
615    }
616
617    #[test]
618    #[traced_test]
619    fn finds_tag_in_list_is_base() {
620        let (expected, actual) = get_commits("third-sha".into()).unwrap();
621
622        assert_eq!(expected[0..=1].concat().as_slice(), actual.as_slice());
623    }
624
625    #[test]
626    #[traced_test]
627    fn finds_didnt_find_tag_in_list() {
628        let error = get_commits("not-found-sha".into()).unwrap_err();
629
630        assert_eq!(
631            "sha was not found in commit chain: not-found-sha on branch: some-branch",
632            error.to_string()
633        );
634    }
635}