use crate::git::{gitea_client::Gitea, gitlab_client::GitLab};
use crate::{GitHub, GitReleaseInfo};
use std::collections::{HashMap, HashSet};
use crate::pr::Pr;
use crate::response_ext::ResponseExt;
use anyhow::Context;
use http::StatusCode;
use itertools::Itertools;
use reqwest::header::HeaderMap;
use reqwest::{Response, Url};
use reqwest_middleware::ClientBuilder;
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, info, instrument};
#[derive(Debug, Clone)]
pub enum GitForge {
Github(GitHub),
Gitea(Gitea),
Gitlab(GitLab),
}
impl GitForge {
fn default_headers(&self) -> anyhow::Result<HeaderMap> {
match self {
Self::Github(g) => g.default_headers(),
Self::Gitea(g) => g.default_headers(),
Self::Gitlab(g) => g.default_headers(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ForgeType {
Github,
Gitea,
Gitlab,
}
#[derive(Debug)]
pub struct GitClient {
pub forge: ForgeType,
pub remote: Remote,
pub client: reqwest_middleware::ClientWithMiddleware,
}
#[derive(Debug, Clone)]
pub struct Remote {
pub owner: String,
pub repo: String,
pub token: SecretString,
pub base_url: Url,
}
impl Remote {
pub fn owner_slash_repo(&self) -> String {
format!("{}/{}", self.owner, self.repo)
}
}
#[derive(Deserialize, Debug)]
pub struct PrCommit {
pub author: Option<Author>,
pub sha: String,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Author {
pub id: i32,
pub login: String,
}
#[derive(Deserialize, Clone, Debug)]
pub struct GitLabMrCommit {
pub id: String,
}
impl From<GitLabMrCommit> for PrCommit {
fn from(value: GitLabMrCommit) -> Self {
Self {
author: None,
sha: value.id,
}
}
}
#[derive(Serialize)]
pub struct CreateReleaseOption<'a> {
tag_name: &'a str,
body: &'a str,
name: &'a str,
draft: &'a bool,
prerelease: &'a bool,
#[serde(skip_serializing_if = "Option::is_none")]
make_latest: Option<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct GitPr {
pub user: Author,
pub number: u64,
pub html_url: Url,
pub head: Commit,
pub title: String,
pub body: Option<String>,
pub labels: Vec<Label>,
}
impl GitPr {
pub fn branch(&self) -> &str {
self.head.ref_field.as_str()
}
pub fn label_names(&self) -> Vec<&str> {
self.labels.iter().map(|l| l.name.as_str()).collect()
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Label {
pub name: String,
id: Option<u64>,
}
impl From<GitLabMr> for GitPr {
fn from(value: GitLabMr) -> Self {
let body = if value.description.is_empty() {
None
} else {
Some(value.description)
};
let labels = value
.labels
.into_iter()
.map(|l| Label { name: l, id: None })
.collect();
Self {
number: value.iid,
html_url: value.web_url,
head: Commit {
ref_field: value.source_branch,
sha: value.sha,
},
title: value.title,
body,
user: Author {
id: value.author.id,
login: value.author.username,
},
labels,
}
}
}
#[derive(Deserialize, Clone, Debug)]
pub struct GitLabMr {
pub author: GitLabAuthor,
pub iid: u64,
pub web_url: Url,
pub sha: String,
pub source_branch: String,
pub title: String,
pub description: String,
pub labels: Vec<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct GitLabAuthor {
pub id: i32,
pub username: String,
}
impl From<GitPr> for GitLabMr {
fn from(value: GitPr) -> Self {
let desc = value.body.unwrap_or_default();
let labels: Vec<String> = value.labels.into_iter().map(|l| l.name).collect();
Self {
author: GitLabAuthor {
id: value.user.id,
username: value.user.login,
},
iid: value.number,
web_url: value.html_url,
sha: value.head.sha,
source_branch: value.head.ref_field,
title: value.title,
description: desc,
labels,
}
}
}
#[derive(Deserialize, Clone, Debug)]
pub struct Commit {
#[serde(rename = "ref")]
pub ref_field: String,
pub sha: String,
}
#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
pub struct RemoteCommit {
pub username: Option<String>,
}
#[derive(Serialize, Default)]
pub struct GitLabMrEdit {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
state_event: Option<String>,
}
#[derive(Serialize, Default, Debug)]
pub struct PrEdit {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<String>,
}
impl From<PrEdit> for GitLabMrEdit {
fn from(value: PrEdit) -> Self {
Self {
title: value.title,
description: value.body,
state_event: value.state,
}
}
}
impl PrEdit {
pub fn new() -> Self {
Self::default()
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn with_state(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}
pub fn contains_edit(&self) -> bool {
self.title.is_some() || self.body.is_some() || self.state.is_some()
}
}
impl GitClient {
pub fn new(forge: GitForge) -> anyhow::Result<Self> {
let client = {
let headers = forge.default_headers()?;
let reqwest_client = crate::http_client::http_client_builder()
.default_headers(headers)
.build()
.context("can't build Git client")?;
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
ClientBuilder::new(reqwest_client)
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build()
};
let (forge, remote) = match forge {
GitForge::Github(g) => (ForgeType::Github, g.remote),
GitForge::Gitea(g) => (ForgeType::Gitea, g.remote),
GitForge::Gitlab(g) => (ForgeType::Gitlab, g.remote),
};
Ok(Self {
forge,
remote,
client,
})
}
pub fn per_page(&self) -> &str {
match self.forge {
ForgeType::Github | ForgeType::Gitlab => "per_page",
ForgeType::Gitea => "limit",
}
}
pub async fn create_release(&self, release_info: &GitReleaseInfo) -> anyhow::Result<()> {
match self.forge {
ForgeType::Github | ForgeType::Gitea => self.create_github_release(release_info).await,
ForgeType::Gitlab => self.create_gitlab_release(release_info).await,
}
.context("Failed to create release")
}
pub async fn create_github_release(&self, release_info: &GitReleaseInfo) -> anyhow::Result<()> {
if release_info.latest.is_some() && self.forge == ForgeType::Gitea {
anyhow::bail!("Gitea does not support the `git_release_latest` option");
}
let create_release_options = CreateReleaseOption {
tag_name: &release_info.git_tag,
body: &release_info.release_body,
name: &release_info.release_name,
draft: &release_info.draft,
prerelease: &release_info.pre_release,
make_latest: release_info.latest.map(|l| l.to_string()),
};
self.client
.post(format!("{}/releases", self.repo_url()))
.json(&create_release_options)
.send()
.await?
.error_for_status()
.map_err(|e| {
if let Some(status) = e.status()
&& status == reqwest::StatusCode::FORBIDDEN
{
return anyhow::anyhow!(e).context(
"Make sure your token has sufficient permissions. Learn more at https://release-plz.dev/docs/usage/release or https://release-plz.dev/docs/github/token",
);
}
anyhow::anyhow!(e)
})?;
Ok(())
}
pub async fn create_gitlab_release(&self, release_info: &GitReleaseInfo) -> anyhow::Result<()> {
#[derive(Serialize)]
pub struct GitlabReleaseOption<'a> {
name: &'a str,
tag_name: &'a str,
description: &'a str,
}
let gitlab_release_options = GitlabReleaseOption {
name: &release_info.release_name,
tag_name: &release_info.git_tag,
description: &release_info.release_body,
};
self.client
.post(format!("{}/releases", self.remote.base_url))
.json(&gitlab_release_options)
.send()
.await?
.error_for_status()
.map_err(|e| {
if let Some(status) = e.status()
&& status == reqwest::StatusCode::FORBIDDEN {
return anyhow::anyhow!(e).context(
"Make sure your token has sufficient permissions. Learn more at https://release-plz.dev/docs/usage/release#gitlab",
);
}
anyhow::anyhow!(e)
})?;
Ok(())
}
pub fn pulls_url(&self) -> String {
match self.forge {
ForgeType::Github | ForgeType::Gitea => {
format!("{}/pulls", self.repo_url())
}
ForgeType::Gitlab => {
format!("{}/merge_requests", self.repo_url())
}
}
}
pub fn issues_url(&self) -> String {
format!("{}/issues", self.repo_url())
}
pub fn param_value_pr_state_open(&self) -> &'static str {
match self.forge {
ForgeType::Github | ForgeType::Gitea => "open",
ForgeType::Gitlab => "opened",
}
}
fn repo_url(&self) -> String {
match self.forge {
ForgeType::Github | ForgeType::Gitea => {
format!(
"{}repos/{}",
self.remote.base_url,
self.remote.owner_slash_repo()
)
}
ForgeType::Gitlab => self.remote.base_url.to_string(),
}
}
pub async fn opened_prs(&self, branch_prefix: &str) -> anyhow::Result<Vec<GitPr>> {
let mut page = 1;
let page_size = 30;
let mut release_prs: Vec<GitPr> = vec![];
loop {
debug!(
"Loading prs from {}, page {page}",
self.remote.owner_slash_repo()
);
let prs: Vec<GitPr> = self
.opened_prs_page(page, page_size)
.await
.context("Failed to retrieve open PRs")?;
let prs_len = prs.len();
let current_release_prs: Vec<GitPr> = prs
.into_iter()
.filter(|pr| pr.head.ref_field.starts_with(branch_prefix))
.collect();
release_prs.extend(current_release_prs);
if prs_len < page_size {
break;
}
page += 1;
}
Ok(release_prs)
}
async fn opened_prs_page(&self, page: i32, page_size: usize) -> anyhow::Result<Vec<GitPr>> {
let mut url = Url::parse(&self.pulls_url()).context("invalid pulls URL")?;
{
let mut qp = url.query_pairs_mut();
qp.append_pair("state", self.param_value_pr_state_open());
qp.append_pair("page", &page.to_string());
qp.append_pair(self.per_page(), &page_size.to_string());
}
let resp = self
.client
.get(url)
.send()
.await?
.successful_status()
.await?;
self.prs_from_response(resp).await
}
async fn prs_from_response(&self, resp: Response) -> anyhow::Result<Vec<GitPr>> {
match self.forge {
ForgeType::Github | ForgeType::Gitea => resp.json().await.context("failed to parse pr"),
ForgeType::Gitlab => {
let gitlab_mrs: Vec<GitLabMr> =
resp.json().await.context("failed to parse gitlab mr")?;
let git_prs: Vec<GitPr> = gitlab_mrs.into_iter().map(|mr| mr.into()).collect();
Ok(git_prs)
}
}
}
async fn pr_from_response(&self, resp: Response) -> anyhow::Result<GitPr> {
match self.forge {
ForgeType::Github | ForgeType::Gitea => resp.json().await.context("failed to parse pr"),
ForgeType::Gitlab => {
let gitlab_mr: GitLabMr = resp.json().await.context("failed to parse gitlab mr")?;
Ok(gitlab_mr.into())
}
}
}
#[instrument(skip(self))]
pub async fn close_pr(&self, pr_number: u64) -> anyhow::Result<()> {
debug!("closing pr #{pr_number}");
let edit = PrEdit::new().with_state(self.closed_pr_state());
self.edit_pr(pr_number, edit)
.await
.with_context(|| format!("cannot close pr {pr_number}"))?;
info!("closed pr #{pr_number}");
Ok(())
}
fn closed_pr_state(&self) -> &'static str {
match self.forge {
ForgeType::Github | ForgeType::Gitea => "closed",
ForgeType::Gitlab => "close",
}
}
pub async fn edit_pr(&self, pr_number: u64, pr_edit: PrEdit) -> anyhow::Result<()> {
let req = match self.forge {
ForgeType::Github | ForgeType::Gitea => self
.client
.patch(format!("{}/{}", self.pulls_url(), pr_number))
.json(&pr_edit),
ForgeType::Gitlab => {
let edit_mr: GitLabMrEdit = pr_edit.into();
self.client
.put(format!("{}/merge_requests/{pr_number}", self.repo_url()))
.json(&edit_mr)
}
};
debug!("editing pr: {req:?}");
req.send()
.await
.with_context(|| format!("cannot edit pr {pr_number}"))?;
Ok(())
}
#[instrument(skip(self, pr))]
pub async fn open_pr(&self, pr: &Pr) -> anyhow::Result<GitPr> {
debug!("Opening PR in {}", self.remote.owner_slash_repo());
let json_body = match self.forge {
ForgeType::Github | ForgeType::Gitea => json!({
"title": pr.title,
"body": pr.body,
"base": pr.base_branch,
"head": pr.branch,
"draft": pr.draft,
}),
ForgeType::Gitlab => json!({
"title": pr.title,
"description": pr.body,
"target_branch": pr.base_branch,
"source_branch": pr.branch,
"draft": pr.draft,
"remove_source_branch": true
}),
};
let rep = self
.client
.post(self.pulls_url())
.json(&json_body)
.send()
.await
.context("failed when sending the response")?
.successful_status()
.await
.context("received unexpected response")?;
let git_pr: GitPr = match self.forge {
ForgeType::Github | ForgeType::Gitea => {
rep.json().await.context("Failed to parse PR")?
}
ForgeType::Gitlab => {
let gitlab_mr: GitLabMr = rep.json().await.context("Failed to parse Gitlab MR")?;
gitlab_mr.into()
}
};
info!("opened pr: {}", git_pr.html_url);
self.add_labels(&pr.labels, git_pr.number)
.await
.context("Failed to add labels")?;
Ok(git_pr)
}
#[instrument(skip(self))]
pub async fn add_labels(&self, labels: &[String], pr_number: u64) -> anyhow::Result<()> {
if labels.is_empty() {
return Ok(());
}
match self.forge {
ForgeType::Github => self.post_github_labels(labels, pr_number).await,
ForgeType::Gitlab => self.post_gitlab_labels(labels, pr_number).await,
ForgeType::Gitea => self.post_gitea_labels(labels, pr_number).await,
}
}
fn pr_labels_url(&self, pr_number: u64) -> String {
format!("{}/{}/labels", self.issues_url(), pr_number)
}
async fn post_github_labels(&self, labels: &[String], pr_number: u64) -> anyhow::Result<()> {
self.client
.post(self.pr_labels_url(pr_number))
.json(&json!({
"labels": labels
}))
.send()
.await?
.successful_status()
.await?;
Ok(())
}
async fn post_gitlab_labels(&self, labels: &[String], pr_number: u64) -> anyhow::Result<()> {
self.client
.put(format!("{}/{}", self.pulls_url(), pr_number))
.json(&json!({
"add_labels": labels.iter().join(",")
}))
.send()
.await?
.successful_status()
.await?;
Ok(())
}
async fn post_gitea_labels(&self, labels: &[String], pr_number: u64) -> anyhow::Result<()> {
let (labels_to_create, mut label_ids) = self
.get_labels_info_and_categorize_labels(labels, pr_number)
.await?;
let new_label_ids = self.create_gitea_labels(&labels_to_create).await?;
label_ids.extend(new_label_ids);
anyhow::ensure!(
!label_ids.is_empty(),
"The provided labels: {labels:?} \n
were not added to PR #{pr_number}",
);
self.client
.post(self.pr_labels_url(pr_number))
.json(&json!({ "labels": label_ids }))
.send()
.await?
.successful_status()
.await?;
Ok(())
}
async fn get_repository_labels(&self) -> anyhow::Result<Vec<Label>> {
self.client
.get(format!("{}/labels", self.repo_url()))
.send()
.await?
.successful_status()
.await?
.json()
.await
.context("failed to parse labels")
}
async fn get_labels_info_and_categorize_labels(
&self,
labels: &[String],
pr_number: u64,
) -> anyhow::Result<(Vec<String>, Vec<u64>)> {
let (existing_labels, pr_info) =
tokio::try_join!(self.get_repository_labels(), self.get_pr_info(pr_number))?;
let existing_label_map: HashMap<&str, &Label> = existing_labels
.iter()
.map(|l| (l.name.as_str(), l))
.collect();
let current_pr_labels: HashSet<&str> =
pr_info.labels.iter().map(|l| l.name.as_str()).collect();
let mut labels_to_create: Vec<String> = vec![];
let mut label_ids = Vec::new();
for label in labels {
match existing_label_map.get(label.as_str()) {
Some(l) => {
if !current_pr_labels.contains(label.as_str()) {
label_ids.push(l.id.with_context(|| {
format!("failed to extract id from existing label '{}'", l.name)
})?);
}
}
None => {
if !labels_to_create.contains(label) {
labels_to_create.push(label.clone());
}
}
}
}
Ok((labels_to_create, label_ids))
}
async fn create_gitea_labels(&self, labels_to_create: &[String]) -> anyhow::Result<Vec<u64>> {
let mut label_ids = Vec::new();
for label in labels_to_create {
let label_id = self.create_gitea_repository_label(label).await?;
label_ids.push(label_id);
}
Ok(label_ids)
}
async fn create_gitea_repository_label(&self, label: &str) -> anyhow::Result<u64> {
debug!("Forge Gitea creating label: {label}");
let res = self
.client
.post(format!("{}/labels", self.repo_url()))
.json(&json!({
"name": label.trim(),
"color": "#FFFFFF"
}))
.send()
.await?
.error_for_status()
.map_err(|err| {
let status = err.status();
let err = anyhow::anyhow!(err);
match status {
Some(StatusCode::NOT_FOUND) => {
err.context(format!(
"Please check if the repository URL '{}' is correct and the user has the necessary permissions to add labels",
self.repo_url()
))
}
Some(StatusCode::UNPROCESSABLE_ENTITY) => {
err.context("Please open a GitHub issue: https://github.com/release-plz/release-plz/issues")
}
_ => {
err.context("HTTP response contained no status code when creating label")
}
}
.context(format!("failed to create label '{label}'"))
})?;
let new_label: Label = res.json().await?;
let label_id = new_label
.id
.with_context(|| format!("failed to extract id from label {label}"))?;
Ok(label_id)
}
pub async fn pr_commits(&self, pr_number: u64) -> anyhow::Result<Vec<PrCommit>> {
let resp = self
.client
.get(format!("{}/{}/commits", self.pulls_url(), pr_number))
.send()
.await?
.successful_status()
.await?;
self.parse_pr_commits(resp).await
}
async fn parse_pr_commits(&self, resp: Response) -> anyhow::Result<Vec<PrCommit>> {
match self.forge {
ForgeType::Github | ForgeType::Gitea => {
resp.json().await.context("failed to parse pr commits")
}
ForgeType::Gitlab => {
let gitlab_commits: Vec<GitLabMrCommit> =
resp.json().await.context("failed to parse gitlab mr")?;
let pr_commits = gitlab_commits
.into_iter()
.map(|commit| commit.into())
.collect();
Ok(pr_commits)
}
}
}
pub async fn associated_prs(&self, commit: &str) -> anyhow::Result<Vec<GitPr>> {
let url = match self.forge {
ForgeType::Github => {
format!("{}/commits/{}/pulls", self.repo_url(), commit)
}
ForgeType::Gitea => {
format!("{}/commits/{}/pull", self.repo_url(), commit)
}
ForgeType::Gitlab => {
format!(
"{}/repository/commits/{}/merge_requests",
self.repo_url(),
commit
)
}
};
let response = self.client.get(url).send().await?;
if response.status() == StatusCode::NOT_FOUND
|| (response.status() == StatusCode::UNPROCESSABLE_ENTITY
&& self.forge == ForgeType::Github)
{
debug!(
"No associated PRs for commit {commit}. This can happen if the commit is not pushed to the remote repository."
);
return Ok(vec![]);
}
let response = response.successful_status().await?;
debug!("Associated PR found. Status: {}", response.status());
let prs = match self.forge {
ForgeType::Github => {
let prs: Vec<GitPr> = response
.json()
.await
.context("can't parse associated PRs")?;
prs
}
ForgeType::Gitea => {
let pr: GitPr = response.json().await.context("can't parse associated PR")?;
vec![pr]
}
ForgeType::Gitlab => {
let gitlab_mrs: Vec<GitLabMr> = response
.json()
.await
.context("can't parse associated Gitlab MR")?;
let git_prs: Vec<GitPr> = gitlab_mrs.into_iter().map(|mr| mr.into()).collect();
git_prs
}
};
let prs_numbers = prs.iter().map(|pr| pr.number).collect::<Vec<_>>();
debug!("Associated PRs for commit {commit}: {:?}", prs_numbers);
Ok(prs)
}
pub async fn get_pr_info(&self, pr_number: u64) -> anyhow::Result<GitPr> {
let response = self
.client
.get(format!("{}/{}", self.pulls_url(), pr_number))
.send()
.await?
.successful_status()
.await?;
self.pr_from_response(response).await
}
pub async fn get_prs_info(&self, pr_numbers: &[u64]) -> anyhow::Result<Vec<GitPr>> {
let mut prs = vec![];
for pr_number in pr_numbers {
let pr = self.get_pr_info(*pr_number).await?;
prs.push(pr);
}
Ok(prs)
}
pub async fn get_remote_commit(&self, commit: &str) -> Result<RemoteCommit, anyhow::Error> {
let api_path = self.commits_api_path(commit);
let response = self.client.get(api_path).send().await?;
if let Err(err) = response.error_for_status_ref()
&& let Some(StatusCode::NOT_FOUND | StatusCode::UNPROCESSABLE_ENTITY) = err.status()
{
return Ok(RemoteCommit { username: None });
}
let remote_commit: GitHubCommit = response
.successful_status()
.await?
.json()
.await
.context("can't parse commits")?;
let username = remote_commit.author.and_then(|author| author.login);
Ok(RemoteCommit { username })
}
fn commits_api_path(&self, commit: &str) -> String {
let commits_path = "commits/";
let commits_api_path = match self.forge {
ForgeType::Gitea => {
format!("git/{commits_path}")
}
ForgeType::Github => commits_path.to_string(),
ForgeType::Gitlab => {
unimplemented!("Gitlab support for `release-plz release-pr is not implemented yet")
}
};
format!("{}/{commits_api_path}{commit}", self.repo_url())
}
pub async fn create_branch(&self, branch_name: &str, sha: &str) -> anyhow::Result<()> {
match self.forge {
ForgeType::Github => {
self.post_github_ref(&format!("refs/heads/{branch_name}"), sha)
.await
}
ForgeType::Gitlab => self.post_gitlab_branch(branch_name, sha).await,
ForgeType::Gitea => self.post_gitea_branch(branch_name, sha).await,
}
}
async fn post_github_ref(&self, ref_name: &str, sha: &str) -> anyhow::Result<()> {
let response = self
.client
.post(format!("{}/git/refs", self.repo_url()))
.json(&json!({
"ref": ref_name,
"sha": sha
}))
.send()
.await?;
if response.status() == StatusCode::UNPROCESSABLE_ENTITY {
let body = response
.text()
.await
.unwrap_or_else(|_| "<failed to read response body>".to_string());
anyhow::bail!(
"failed to create ref {ref_name} with sha {sha}. \
The commit {sha} likely hasn't been pushed to the remote repository yet. \
Please push your local commits and run release-plz again.\nResponse body: {body}"
);
}
response
.successful_status()
.await
.with_context(|| format!("failed to create ref {ref_name} with sha {sha}"))?;
Ok(())
}
async fn post_gitlab_branch(&self, branch_name: &str, sha: &str) -> anyhow::Result<()> {
self.client
.post(format!("{}/repository/branches", self.repo_url()))
.json(&json!({
"branch": branch_name,
"ref": sha
}))
.send()
.await?
.successful_status()
.await
.with_context(|| format!("failed to create branch {branch_name} with sha {sha}"))?;
Ok(())
}
async fn post_gitea_branch(&self, branch_name: &str, sha: &str) -> anyhow::Result<()> {
self.client
.post(format!("{}/branches", self.repo_url()))
.json(&json!({
"new_branch_name": branch_name,
"old_ref_name": sha
}))
.send()
.await?
.successful_status()
.await
.with_context(|| format!("failed to create branch {branch_name} with sha {sha}"))?;
Ok(())
}
pub async fn patch_github_ref(&self, ref_name: &str, sha: &str) -> anyhow::Result<()> {
self.client
.patch(format!("{}/git/refs/{}", self.repo_url(), ref_name))
.json(&json!({
"sha": sha,
"force": true
}))
.send()
.await?
.successful_status()
.await
.with_context(|| format!("failed to update ref {ref_name} with sha {sha}"))?;
Ok(())
}
pub async fn delete_branch(&self, branch_name: &str) -> anyhow::Result<()> {
let url = match self.forge {
ForgeType::Github => format!("{}/git/refs/heads/{}", self.repo_url(), branch_name),
ForgeType::Gitlab => format!(
"{}/repository/branches/{}",
self.repo_url(),
urlencoding::encode(branch_name)
),
ForgeType::Gitea => format!(
"{}/branches/{}",
self.repo_url(),
urlencoding::encode(branch_name)
),
};
self.client
.delete(url)
.send()
.await?
.successful_status()
.await
.context("failed to delete branch")?;
Ok(())
}
pub async fn create_tag(
&self,
tag_name: &str,
message: &str,
sha: &str,
) -> Result<(), anyhow::Error> {
match self.forge {
ForgeType::Github => self.create_github_tag(tag_name, message, sha).await,
ForgeType::Gitlab => self.create_gitlab_tag(tag_name, message, sha).await,
ForgeType::Gitea => self.create_gitea_tag(tag_name, message, sha).await,
}
}
async fn create_github_tag(
&self,
tag_name: &str,
message: &str,
sha: &str,
) -> Result<(), anyhow::Error> {
let tag_object_sha = self
.client
.post(format!("{}/git/tags", self.repo_url()))
.json(&json!({
"tag": tag_name,
"message": message,
"object": sha,
"type": "commit"
}))
.send()
.await?
.successful_status()
.await?
.json::<serde_json::Value>()
.await?
.get("sha")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.with_context(|| {
format!("failed to create git tag object for tag '{tag_name}' on '{sha}'")
})?;
self.post_github_ref(&format!("refs/tags/{tag_name}"), &tag_object_sha)
.await
}
async fn create_gitlab_tag(
&self,
tag_name: &str,
message: &str,
sha: &str,
) -> Result<(), anyhow::Error> {
self.client
.post(format!("{}/repository/tags", self.repo_url()))
.json(&json!({
"tag_name": tag_name,
"ref": sha,
"message": message
}))
.send()
.await?
.successful_status()
.await
.with_context(|| format!("failed to create git tag '{tag_name}' with ref '{sha}'"))?;
Ok(())
}
async fn create_gitea_tag(
&self,
tag_name: &str,
message: &str,
sha: &str,
) -> Result<(), anyhow::Error> {
self.client
.post(format!("{}/tags", self.repo_url()))
.json(&json!({
"tag_name": tag_name,
"target": sha,
"message": message
}))
.send()
.await?
.successful_status()
.await
.with_context(|| format!("failed to create git tag '{tag_name}' with ref '{sha}'"))?;
Ok(())
}
}
pub fn validate_labels(labels: &[String]) -> anyhow::Result<()> {
let mut unique_labels: HashSet<&str> = HashSet::new();
for l in labels {
let error_msg = || format!("Failed to add label `{l}`:");
if l.len() > 50 {
anyhow::bail!(
"{} it exceeds maximum length of 50 characters.",
error_msg()
);
}
if l.trim() != l {
anyhow::bail!(
"{} leading or trailing whitespace is not allowed.",
error_msg()
);
}
if l.is_empty() {
anyhow::bail!("{} empty labels are not allowed.", error_msg());
}
let is_label_new = unique_labels.insert(l.as_str());
if !is_label_new {
anyhow::bail!("{} duplicate labels are not allowed.", error_msg());
}
}
Ok(())
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitHubCommit {
pub sha: String,
pub author: Option<GitHubCommitAuthor>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitHubCommitAuthor {
pub login: Option<String>,
}
pub fn contributors_from_commits(commits: &[PrCommit], forge: ForgeType) -> Vec<String> {
let mut contributors = commits
.iter()
.skip(1) .flat_map(|commit| &commit.author)
.filter(|author| {
let is_gitea_actions_account = forge == ForgeType::Gitea && author.id == -2;
let is_bot = author.login.ends_with("[bot]") || is_gitea_actions_account;
!is_bot
})
.map(|author| author.login.clone())
.collect::<Vec<_>>();
contributors.dedup();
contributors
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contributors_are_extracted_from_commits() {
let commits = vec![
PrCommit {
author: Some(Author {
id: 1,
login: "bob".to_string(),
}),
sha: "abc".to_string(),
},
PrCommit {
author: Some(Author {
id: 2,
login: "marco".to_string(),
}),
sha: "abc".to_string(),
},
PrCommit {
author: Some(Author {
id: 3,
login: "release[bot]".to_string(),
}),
sha: "abc".to_string(),
},
PrCommit {
author: Some(Author {
id: -2,
login: "gitea-actions".to_string(),
}),
sha: "abc".to_string(),
},
PrCommit {
author: None,
sha: "abc".to_string(),
},
];
let contributors = contributors_from_commits(&commits, ForgeType::Gitea);
assert_eq!(contributors, vec!["marco"]);
}
}