use anyhow::Context; use reqwest::header::{HeaderMap, HeaderValue}; use semver::Version; use serde::{Deserialize, Serialize}; pub trait RemoteGitEngine { fn connect(&self, owner: &str, repo: &str) -> anyhow::Result<()>; fn get_tags(&self, owner: &str, repo: &str) -> anyhow::Result>; fn get_commits_since( &self, owner: &str, repo: &str, since_sha: Option<&str>, branch: &str, ) -> anyhow::Result>; fn get_pull_request(&self, owner: &str, repo: &str) -> anyhow::Result>; fn create_pull_request( &self, owner: &str, repo: &str, version: &str, body: &str, base: &str, ) -> anyhow::Result; fn update_pull_request( &self, owner: &str, repo: &str, version: &str, body: &str, index: usize, ) -> anyhow::Result; fn create_release( &self, owner: &str, repo: &str, version: &str, body: &str, prerelease: bool, ) -> anyhow::Result; } pub type DynRemoteGitClient = Box; #[allow(dead_code)] pub struct GiteaClient { url: String, token: Option, pub allow_insecure: bool, } const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); impl GiteaClient { pub fn new(url: &str, token: Option<&str>) -> Self { Self { url: url.into(), token: token.map(|t| t.into()), allow_insecure: false, } } fn create_client(&self) -> anyhow::Result { let cb = reqwest::blocking::ClientBuilder::new(); let mut header_map = HeaderMap::new(); if let Some(token) = &self.token { header_map.insert( "Authorization", HeaderValue::from_str(format!("token {}", token).as_str())?, ); } let client = cb .user_agent(APP_USER_AGENT) .default_headers(header_map) .danger_accept_invalid_certs(self.allow_insecure) .build()?; Ok(client) } fn get_commits_since_inner( &self, owner: &str, repo: &str, since_sha: Option<&str>, branch: &str, get_commits: F, ) -> anyhow::Result> where F: Fn(&str, &str, &str, usize) -> anyhow::Result<(Vec, bool)>, { let mut commits = Vec::new(); let mut page = 1; let owner: String = owner.into(); let repo: String = repo.into(); let since_sha: Option = since_sha.map(|ss| ss.into()); let branch: String = branch.into(); let mut found_commit = false; loop { let (new_commits, has_more) = get_commits(&owner, &repo, &branch, page)?; for commit in new_commits { if let Some(since_sha) = &since_sha { if commit.sha.contains(since_sha) { found_commit = true; } else if !found_commit { commits.push(commit); } } else { commits.push(commit); } } if !has_more { break; } page += 1; } if !found_commit && since_sha.is_some() { return Err(anyhow::anyhow!( "sha was not found in commit chain: {} on branch: {}", since_sha.unwrap_or("".into()), branch )); } Ok(commits) } fn get_pull_request_inner( &self, owner: &str, repo: &str, request_pull_request: F, ) -> anyhow::Result> where F: Fn(&str, &str, usize) -> anyhow::Result<(Vec, bool)>, { let mut page = 1; let owner: String = owner.into(); let repo: String = repo.into(); loop { let (pull_requests, has_more) = request_pull_request(&owner, &repo, page)?; for pull_request in pull_requests { if pull_request.head.r#ref.contains("cuddle-please/release") { return Ok(Some(pull_request.number)); } } if !has_more { break; } page += 1; } Ok(None) } } impl RemoteGitEngine for GiteaClient { fn connect(&self, owner: &str, repo: &str) -> anyhow::Result<()> { let client = self.create_client()?; tracing::trace!(owner = &owner, repo = &repo, "gitea connect"); let request = client .get(format!( "{}/api/v1/repos/{}/{}", &self.url.trim_end_matches('/'), owner, repo )) .build()?; let resp = client.execute(request)?; if !resp.status().is_success() { resp.error_for_status()?; return Ok(()); } Ok(()) } fn get_tags(&self, owner: &str, repo: &str) -> anyhow::Result> { let client = self.create_client()?; let request = client .get(format!( "{}/api/v1/repos/{}/{}/tags", &self.url.trim_end_matches('/'), owner, repo )) .build()?; let resp = client.execute(request)?; if !resp.status().is_success() { return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err())); } let tags: Vec = resp.json()?; Ok(tags) } fn get_commits_since( &self, owner: &str, repo: &str, since_sha: Option<&str>, branch: &str, ) -> anyhow::Result> { let get_commits_since_page = |owner: &str, repo: &str, branch: &str, page: usize| -> anyhow::Result<(Vec, bool)> { let client = self.create_client()?; tracing::trace!( owner = owner, repo = repo, branch = branch, page = page, "fetching tags" ); let request = client .get(format!( "{}/api/v1/repos/{}/{}/commits?page={}&limit={}&sha={}&stat=false&verification=false&files=false", &self.url.trim_end_matches('/'), owner, repo, page, 50, branch, )) .build()?; let resp = client.execute(request)?; let mut has_more = false; if let Some(gitea_has_more) = resp.headers().get("X-HasMore") { let gitea_has_more = gitea_has_more.to_str()?; if gitea_has_more == "true" || gitea_has_more == "True" { has_more = true; } } if !resp.status().is_success() { return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err())); } let commits: Vec = resp.json()?; Ok((commits, has_more)) }; let commits = self.get_commits_since_inner(owner, repo, since_sha, branch, get_commits_since_page)?; Ok(commits) } fn get_pull_request(&self, owner: &str, repo: &str) -> anyhow::Result> { let request_pull_request = |owner: &str, repo: &str, page: usize| -> anyhow::Result<(Vec, bool)> { let client = self.create_client()?; tracing::trace!(owner = owner, repo = repo, "fetching pull-requests"); let request = client .get(format!( "{}/api/v1/repos/{}/{}/pulls?state=open&sort=recentupdate&page={}&limit={}", &self.url.trim_end_matches('/'), owner, repo, page, 50, )) .build()?; let resp = client.execute(request)?; let mut has_more = false; if let Some(gitea_has_more) = resp.headers().get("X-HasMore") { let gitea_has_more = gitea_has_more.to_str()?; if gitea_has_more == "true" || gitea_has_more == "True" { has_more = true; } } if !resp.status().is_success() { return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err())); } let commits: Vec = resp.json()?; Ok((commits, has_more)) }; self.get_pull_request_inner(owner, repo, request_pull_request) } fn create_pull_request( &self, owner: &str, repo: &str, version: &str, body: &str, base: &str, ) -> anyhow::Result { #[derive(Clone, Debug, Serialize, Deserialize)] struct CreatePullRequestOption { base: String, body: String, head: String, title: String, } let client = self.create_client()?; let request = CreatePullRequestOption { base: base.into(), body: body.into(), head: "cuddle-please/release".into(), title: format!("chore(release): v{}", version), }; tracing::trace!( owner = owner, repo = repo, version = version, base = base, "create pull_request" ); let request = client .post(format!( "{}/api/v1/repos/{}/{}/pulls", &self.url.trim_end_matches('/'), owner, repo, )) .json(&request) .build()?; let resp = client.execute(request)?; if !resp.status().is_success() { return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err())); } let commits: PullRequest = resp.json()?; Ok(commits.number) } fn update_pull_request( &self, owner: &str, repo: &str, version: &str, body: &str, index: usize, ) -> anyhow::Result { #[derive(Clone, Debug, Serialize, Deserialize)] struct CreatePullRequestOption { body: String, title: String, } let client = self.create_client()?; let request = CreatePullRequestOption { body: body.into(), title: format!("chore(release): v{}", version), }; tracing::trace!( owner = owner, repo = repo, version = version, "update pull_request" ); let request = client .patch(format!( "{}/api/v1/repos/{}/{}/pulls/{}", &self.url.trim_end_matches('/'), owner, repo, index )) .json(&request) .build()?; let resp = client.execute(request)?; if !resp.status().is_success() { return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err())); } let commits: PullRequest = resp.json()?; Ok(commits.number) } fn create_release( &self, owner: &str, repo: &str, version: &str, body: &str, prerelease: bool, ) -> anyhow::Result { #[derive(Clone, Debug, Serialize, Deserialize)] struct CreateReleaseOption { body: String, draft: bool, name: String, prerelease: bool, #[serde(alias = "tag_name")] tag_name: String, } let client = self.create_client()?; let request = CreateReleaseOption { body: body.into(), draft: false, name: format!("v{version}"), prerelease, tag_name: format!("v{version}"), }; tracing::trace!( owner = owner, repo = repo, version = version, "create release" ); let request = client .post(format!( "{}/api/v1/repos/{}/{}/releases", &self.url.trim_end_matches('/'), owner, repo, )) .json(&request) .build()?; let resp = client.execute(request)?; if !resp.status().is_success() { return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err())); } let release: Release = resp.json()?; Ok(release) } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct Release { id: usize, url: String, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct PullRequest { number: usize, head: PRBranchInfo, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct PRBranchInfo { #[serde(alias = "ref")] r#ref: String, label: String, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct Commit { sha: String, pub created: String, pub commit: CommitDetails, } impl Commit { pub fn get_title(&self) -> String { self.commit .message .split('\n') .take(1) .collect::>() .join("\n") } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct CommitDetails { pub message: String, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct Tag { pub id: String, pub message: String, pub name: String, pub commit: TagCommit, } impl TryFrom for Version { type Error = anyhow::Error; fn try_from(value: Tag) -> Result { tracing::trace!(name = &value.name, "parsing tag into version"); value .name .trim_start_matches("v") .parse::() .context("could not get version from tag") } } impl TryFrom<&Tag> for Version { type Error = anyhow::Error; fn try_from(value: &Tag) -> Result { tracing::trace!(name = &value.name, "parsing tag into version"); value .name .parse::() .context("could not get version from tag") } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct TagCommit { pub created: String, pub sha: String, pub url: String, } #[cfg(test)] mod test { use tracing_test::traced_test; use crate::gitea_client::{Commit, CommitDetails}; use super::GiteaClient; fn get_api_res() -> Vec> { let api_results = vec![ vec![Commit { sha: "first-sha".into(), created: "".into(), commit: CommitDetails { message: "first-message".into(), }, }], vec![Commit { sha: "second-sha".into(), created: "".into(), commit: CommitDetails { message: "second-message".into(), }, }], vec![Commit { sha: "third-sha".into(), created: "".into(), commit: CommitDetails { message: "third-message".into(), }, }], ]; api_results } fn get_commits(sha: String) -> anyhow::Result<(Vec>, Vec)> { let api_res = get_api_res(); let client = GiteaClient::new("", Some("")); let commits = client.get_commits_since_inner( "owner", "repo", Some(&sha), "some-branch", |_, _, _, page| -> anyhow::Result<(Vec, bool)> { let commit_page = api_res.get(page - 1).unwrap(); Ok((commit_page.clone(), page != 3)) }, )?; Ok((api_res, commits)) } #[test] #[traced_test] fn finds_tag_in_list() { let (expected, actual) = get_commits("second-sha".into()).unwrap(); assert_eq!( expected.get(0).unwrap().clone().as_slice(), actual.as_slice() ); } #[test] #[traced_test] fn finds_tag_in_list_already_newest_commit() { let (_, actual) = get_commits("first-sha".into()).unwrap(); assert_eq!(0, actual.len()); } #[test] #[traced_test] fn finds_tag_in_list_is_base() { let (expected, actual) = get_commits("third-sha".into()).unwrap(); assert_eq!(expected[0..=1].concat().as_slice(), actual.as_slice()); } #[test] #[traced_test] fn finds_didnt_find_tag_in_list() { let error = get_commits("not-found-sha".into()).unwrap_err(); assert_eq!( "sha was not found in commit chain: not-found-sha on branch: some-branch", error.to_string() ); } }