All checks were successful
continuous-integration/drone/push Build is passing
Allows commit bodies to show up in release notes, this is something I'd prefer as my releases are usually short, and I'd like to see these as I don't use pull requests as often, and often miss the context, as I don't link to commits currently. Also fixes a lot of warnings and reintroduces failing tests, still not perfect, but better than before. Co-authored-by: kjuulh <contact@kjuulh.io> Co-committed-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.first().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()
|
|
);
|
|
}
|
|
}
|