cuddle-please/crates/cuddle-please-misc/src/gitea_client.rs
kjuulh 675947ed1e
All checks were successful
continuous-integration/drone/push Build is passing
feat: allow v in start of versions
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-08-08 16:15:49 +02:00

637 lines
17 KiB
Rust

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<Vec<Tag>>;
fn get_commits_since(
&self,
owner: &str,
repo: &str,
since_sha: Option<&str>,
branch: &str,
) -> anyhow::Result<Vec<Commit>>;
fn get_pull_request(&self, owner: &str, repo: &str) -> anyhow::Result<Option<usize>>;
fn create_pull_request(
&self,
owner: &str,
repo: &str,
version: &str,
body: &str,
base: &str,
) -> anyhow::Result<usize>;
fn update_pull_request(
&self,
owner: &str,
repo: &str,
version: &str,
body: &str,
index: usize,
) -> anyhow::Result<usize>;
fn create_release(
&self,
owner: &str,
repo: &str,
version: &str,
body: &str,
prerelease: bool,
) -> anyhow::Result<Release>;
}
pub type DynRemoteGitClient = Box<dyn RemoteGitEngine>;
#[allow(dead_code)]
pub struct GiteaClient {
url: String,
token: Option<String>,
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<reqwest::blocking::Client> {
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<F>(
&self,
owner: &str,
repo: &str,
since_sha: Option<&str>,
branch: &str,
get_commits: F,
) -> anyhow::Result<Vec<Commit>>
where
F: Fn(&str, &str, &str, usize) -> anyhow::Result<(Vec<Commit>, bool)>,
{
let mut commits = Vec::new();
let mut page = 1;
let owner: String = owner.into();
let repo: String = repo.into();
let since_sha: Option<String> = 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<F>(
&self,
owner: &str,
repo: &str,
request_pull_request: F,
) -> anyhow::Result<Option<usize>>
where
F: Fn(&str, &str, usize) -> anyhow::Result<(Vec<PullRequest>, 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<Vec<Tag>> {
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<Tag> = resp.json()?;
Ok(tags)
}
fn get_commits_since(
&self,
owner: &str,
repo: &str,
since_sha: Option<&str>,
branch: &str,
) -> anyhow::Result<Vec<Commit>> {
let get_commits_since_page = |owner: &str,
repo: &str,
branch: &str,
page: usize|
-> anyhow::Result<(Vec<Commit>, 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<Commit> = 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<Option<usize>> {
let request_pull_request =
|owner: &str, repo: &str, page: usize| -> anyhow::Result<(Vec<PullRequest>, 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<PullRequest> = 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<usize> {
#[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<usize> {
#[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<Release> {
#[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::<Vec<&str>>()
.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<Tag> for Version {
type Error = anyhow::Error;
fn try_from(value: Tag) -> Result<Self, Self::Error> {
tracing::trace!(name = &value.name, "parsing tag into version");
value
.name
.trim_start_matches("v")
.parse::<Version>()
.context("could not get version from tag")
}
}
impl TryFrom<&Tag> for Version {
type Error = anyhow::Error;
fn try_from(value: &Tag) -> Result<Self, Self::Error> {
tracing::trace!(name = &value.name, "parsing tag into version");
value
.name
.parse::<Version>()
.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<Vec<Commit>> {
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<Commit>>, Vec<Commit>)> {
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<Commit>, 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()
);
}
}