kjuulh
675947ed1e
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
637 lines
17 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|