use async_trait::async_trait;
use base64::{Engine, prelude::BASE64_STANDARD};
use chrono::DateTime;
use color_eyre::eyre::ContextCompat;
use gitlab::{
AsyncGitlab,
api::{
AsyncQuery, Pagination,
common::SortOrder,
ignore,
merge_requests::MergeRequestState,
paged,
projects::{
Project,
labels::{CreateLabel, Labels},
merge_requests::{
CreateMergeRequest, EditMergeRequest, MergeRequests,
},
releases::{CreateRelease, ProjectReleaseByTag},
repository::{
commits::CompareCommits,
commits::{
CommitAction, CommitActionType, Commits, CommitsOrder,
CreateCommit,
},
files::File,
tags::{CreateTag, Tag as GitlabTagBuilder, Tags, TagsOrderBy},
},
},
},
};
use graphql_client::GraphQLQuery;
use regex::Regex;
use reqwest::StatusCode;
use secrecy::{ExposeSecret, SecretString};
use std::{cmp, sync::Arc};
use tokio::sync::Mutex;
use url::Url;
mod graphql;
mod types;
use crate::{
analyzer::release::Tag,
config::{Config, DEFAULT_CONFIG_FILE},
error::ReleasaurusError,
error::Result,
forge::{
config::{
DEFAULT_COMMIT_SEARCH_DEPTH, DEFAULT_LABEL_COLOR,
DEFAULT_PAGE_SIZE, DEFAULT_TAG_SEARCH_DEPTH, PENDING_LABEL,
RepoUrl, TokenVar, resolve_token,
},
gitlab::{
graphql::{CommitDiffQuery, CommitDiffQueryVars},
types::{
CreatedCommit, FileInfo, GitlabCommit, GitlabRelease,
GitlabTag, LabelInfo, MergeRequestInfo,
},
},
request::{
Commit, CreateCommitRequest, CreatePrRequest,
CreateReleaseBranchRequest, FileUpdateType, ForgeCommit,
GetFileContentRequest, GetPrRequest, PrLabelsRequest, PullRequest,
ReleaseByTagResponse, UpdatePrRequest,
},
traits::Forge,
},
};
pub struct Gitlab {
url: RepoUrl,
commit_search_depth: Arc<Mutex<u64>>,
gl: AsyncGitlab,
project_id: String,
default_branch: String,
release_link_base_url: Url,
compare_link_base_url: Url,
}
impl Gitlab {
pub async fn new(
url: RepoUrl,
token: Option<SecretString>,
) -> Result<Self> {
let token = resolve_token(token, url.token.as_ref(), TokenVar::Gitlab)?;
let link_base_url = url.link_base_url();
let release_link_base_url =
Url::parse(&format!("{}/{}/-/releases/", link_base_url, url.path))?;
let compare_link_base_url =
Url::parse(&format!("{}/{}/-/compare/", link_base_url, url.path))?;
let project_id = url
.path
.strip_prefix("/")
.map(|p| p.to_string())
.unwrap_or(url.path.clone());
let gl =
gitlab::GitlabBuilder::new(url.host.clone(), token.expose_secret())
.build_async()
.await?;
let endpoint = Project::builder().project(&project_id).build()?;
let gl_project: serde_json::Value = endpoint.query_async(&gl).await?;
let default_branch = gl_project["default_branch"]
.as_str()
.wrap_err("failed to find default branch")?
.to_string();
Ok(Self {
url,
commit_search_depth: Arc::new(Mutex::new(
DEFAULT_COMMIT_SEARCH_DEPTH,
)),
gl,
project_id,
default_branch,
release_link_base_url,
compare_link_base_url,
})
}
async fn get_repo_labels(&self) -> Result<Vec<LabelInfo>> {
let endpoint = Labels::builder().project(&self.project_id).build()?;
let labels: Vec<LabelInfo> = endpoint.query_async(&self.gl).await?;
Ok(labels)
}
async fn create_label(&self, label_name: String) -> Result<LabelInfo> {
let endpoint = CreateLabel::builder()
.project(&self.project_id)
.name(label_name)
.color(format!("#{}", DEFAULT_LABEL_COLOR))
.description("".to_string())
.build()?;
let label: LabelInfo = endpoint.query_async(&self.gl).await?;
Ok(label)
}
async fn is_tag_ancestor_of_branch(
&self,
tag_sha: &str,
branch: &str,
) -> Result<bool> {
let endpoint = CompareCommits::builder()
.project(&self.project_id)
.from(branch)
.to(tag_sha)
.build()
.map_err(|e| ReleasaurusError::forge(e.to_string()))?;
let result: serde_json::Value = endpoint.query_async(&self.gl).await?;
let commits = result["commits"].as_array();
Ok(commits.map(|c| c.is_empty()).unwrap_or(false))
}
}
#[async_trait]
impl Forge for Gitlab {
fn repo_name(&self) -> String {
self.url.name.clone()
}
fn release_link_base_url(&self) -> Url {
self.release_link_base_url.clone()
}
fn compare_link_base_url(&self) -> Url {
self.compare_link_base_url.clone()
}
fn default_branch(&self) -> String {
self.default_branch.clone()
}
async fn load_config(&self, branch: Option<String>) -> Result<Config> {
if let Some(content) = self
.get_file_content(GetFileContentRequest {
branch,
path: DEFAULT_CONFIG_FILE.into(),
})
.await?
{
let config: Config = toml::from_str(&content)?;
let mut config_search_depth = config.first_release_search_depth;
if config_search_depth == 0 {
config_search_depth = u64::MAX;
}
let mut search_depth = self.commit_search_depth.lock().await;
*search_depth = config_search_depth;
Ok(config)
} else {
Ok(Config::default())
}
}
async fn get_file_content(
&self,
req: GetFileContentRequest,
) -> Result<Option<String>> {
let r#ref = req.branch.unwrap_or("HEAD".into());
let endpoint = File::builder()
.project(&self.project_id)
.file_path(&req.path)
.ref_(&r#ref)
.build()?;
let result: std::result::Result<
FileInfo,
gitlab::api::ApiError<gitlab::RestError>,
> = endpoint.query_async(&self.gl).await;
match result {
Ok(file_info) => {
let decoded = BASE64_STANDARD.decode(file_info.content)?;
let content = String::from_utf8(decoded)?;
return Ok(Some(content));
}
Err(gitlab::api::ApiError::GitlabService { status, data }) => {
if status == StatusCode::NOT_FOUND {
return Ok(None);
}
let msg = format!(
"failed to file content from repo: status: {status}, data: {}",
String::from_utf8(data).unwrap()
);
Err(ReleasaurusError::forge(msg))
}
Err(gitlab::api::ApiError::GitlabWithStatus { status, msg }) => {
if status == StatusCode::NOT_FOUND {
return Ok(None);
}
let msg = format!(
"failed to file content from repo: status: {status}, msg: {}",
msg
);
Err(ReleasaurusError::forge(msg))
}
Err(err) => Err(ReleasaurusError::forge(format!(
"failed to get file from repo: {err}"
))),
}
}
async fn get_release_by_tag(
&self,
tag: &str,
) -> Result<ReleaseByTagResponse> {
let tag_endpoint = GitlabTagBuilder::builder()
.project(&self.project_id)
.tag_name(tag)
.build()?;
let result: std::result::Result<
GitlabTag,
gitlab::api::ApiError<gitlab::RestError>,
> = tag_endpoint.query_async(&self.gl).await;
let tag: GitlabTag = match result {
Ok(tag) => tag,
Err(gitlab::api::ApiError::GitlabWithStatus { status, msg }) => {
if status == StatusCode::NOT_FOUND {
return Err(ReleasaurusError::forge(format!(
"tag not found: {tag}"
)));
}
return Err(ReleasaurusError::forge(msg));
}
Err(err) => return Err(ReleasaurusError::forge(err.to_string())),
};
let endpoint = ProjectReleaseByTag::builder()
.project(&self.project_id)
.tag(tag.name.clone())
.build()
.map_err(|e| {
ReleasaurusError::Other(color_eyre::Report::msg(format!(
"Builder error: {}",
e
)))
})?;
let result: std::result::Result<
GitlabRelease,
gitlab::api::ApiError<gitlab::RestError>,
> = endpoint.query_async(&self.gl).await;
match result {
Ok(release) => Ok(ReleaseByTagResponse {
tag: tag.name,
sha: tag.commit.id,
notes: release.description,
}),
Err(gitlab::api::ApiError::GitlabWithStatus { status, msg }) => {
if status == StatusCode::NOT_FOUND {
return Err(ReleasaurusError::forge(format!(
"no release found for tag: {}",
tag.name
)));
}
Err(ReleasaurusError::forge(msg))
}
Err(err) => Err(ReleasaurusError::forge(err.to_string())),
}
}
async fn get_latest_tag_for_prefix(
&self,
prefix: &str,
branch: &str,
) -> Result<Option<Tag>> {
let re = Regex::new(format!(r"^{prefix}").as_str())?;
let endpoint = Tags::builder()
.project(&self.project_id)
.order_by(TagsOrderBy::Version)
.sort(SortOrder::Descending)
.build()?;
let tags: Vec<GitlabTag> =
paged(endpoint, Pagination::Limit(DEFAULT_TAG_SEARCH_DEPTH.into()))
.query_async(&self.gl)
.await?;
for t in tags.into_iter() {
if re.is_match(&t.name) {
let stripped = re.replace_all(&t.name, "").to_string();
if let Ok(sver) = semver::Version::parse(&stripped)
&& self
.is_tag_ancestor_of_branch(&t.commit.id, branch)
.await?
{
return Ok(Some(Tag {
name: t.name,
semver: sver,
sha: t.commit.id,
timestamp: DateTime::parse_from_rfc3339(
&t.commit.created_at,
)
.map(|t| t.timestamp())
.ok(),
}));
}
}
}
Ok(None)
}
async fn get_commits(
&self,
branch: Option<String>,
sha: Option<String>,
) -> Result<Vec<ForgeCommit>> {
let branch = branch.unwrap_or_else(|| self.default_branch());
let search_depth = self.commit_search_depth.lock().await;
let mut builder = Commits::builder();
builder
.project(&self.project_id)
.order(CommitsOrder::Default);
if let Some(sha) = sha.as_ref() {
let range = format!("{sha}..{branch}");
builder.ref_name(range);
} else {
builder.ref_name(branch);
}
let endpoint = builder.build()?;
let page_limit =
cmp::min(DEFAULT_PAGE_SIZE.into(), *search_depth) as usize;
let search_depth = *search_depth as usize;
let mut pagination = Pagination::AllPerPageLimit(page_limit);
if sha.is_none() {
pagination = Pagination::Limit(search_depth);
}
let result: Vec<GitlabCommit> =
paged(endpoint, pagination).query_async(&self.gl).await?;
let mut forge_commits = vec![];
for commit in result.iter() {
log::debug!("backfilling file list for commit: {}", commit.id);
let vars = CommitDiffQueryVars {
project_id: self.project_id.clone(),
commit_sha: commit.id.clone(),
};
let query = CommitDiffQuery::build_query(vars);
let resp = self.gl.graphql::<CommitDiffQuery>(&query).await?;
let diffs = resp.project.repository.commit.diffs;
let mut files = vec![];
for item in diffs.iter() {
if let Some(file_path) = item.new_path.clone() {
files.push(file_path.clone());
} else if let Some(file_path) = item.old_path.clone() {
files.push(file_path);
}
}
let timestamp =
DateTime::parse_from_rfc3339(&commit.created_at)?.timestamp();
forge_commits.push(ForgeCommit {
author_email: commit.author_email.clone(),
author_name: commit.author_name.clone(),
id: commit.id.clone(),
short_id: commit
.id
.clone()
.split("")
.take(8)
.collect::<Vec<&str>>()
.join(""),
link: commit.web_url.clone(),
merge_commit: commit.parent_ids.len() > 1,
message: commit.message.clone().trim().into(),
timestamp,
files,
})
}
Ok(forge_commits)
}
async fn create_release_branch(
&self,
req: CreateReleaseBranchRequest,
) -> Result<Commit> {
let mut actions: Vec<CommitAction> = vec![];
for change in req.file_changes {
let mut content = change.content;
let mut update_type = CommitActionType::Update;
let existing_content = self
.get_file_content(GetFileContentRequest {
branch: Some(req.base_branch.clone()),
path: change.path.to_string(),
})
.await?;
if existing_content.is_none() {
update_type = CommitActionType::Create;
}
if matches!(change.update_type, FileUpdateType::Prepend)
&& let Some(existing_content) = existing_content
{
content = format!("{content}{existing_content}");
}
let action = CommitAction::builder()
.action(update_type)
.content(content.as_bytes().to_owned())
.file_path(change.path.clone())
.build()?;
actions.push(action)
}
let endpoint = CreateCommit::builder()
.project(&self.project_id)
.start_branch(req.base_branch)
.branch(req.release_branch)
.actions(actions)
.commit_message(req.message)
.force(true)
.build()?;
let commit: CreatedCommit = endpoint.query_async(&self.gl).await?;
Ok(Commit { sha: commit.id })
}
async fn create_commit(&self, req: CreateCommitRequest) -> Result<Commit> {
let mut actions: Vec<CommitAction> = vec![];
for change in req.file_changes {
let mut content = change.content;
let mut update_type = CommitActionType::Update;
let existing_content = self
.get_file_content(GetFileContentRequest {
branch: Some(req.target_branch.clone()),
path: change.path.to_string(),
})
.await?;
if existing_content.is_none() {
update_type = CommitActionType::Create;
}
if matches!(change.update_type, FileUpdateType::Prepend)
&& let Some(existing_content) = existing_content.clone()
{
content = format!("{content}{existing_content}");
}
if content == existing_content.unwrap_or_default() {
log::warn!(
"skipping file update content matches existing state: {}",
change.path
);
continue;
}
let action = CommitAction::builder()
.action(update_type)
.content(content.as_bytes().to_owned())
.file_path(change.path.clone())
.build()?;
actions.push(action)
}
if actions.is_empty() {
log::warn!(
"commit would result in no changes: target_branch: {}, message: {}",
req.target_branch,
req.message,
);
return Ok(Commit { sha: "None".into() });
}
let endpoint = CreateCommit::builder()
.project(&self.project_id)
.branch(&req.target_branch)
.actions(actions)
.commit_message(req.message)
.force(false)
.build()?;
let commit: CreatedCommit = endpoint.query_async(&self.gl).await?;
Ok(Commit { sha: commit.id })
}
async fn tag_commit(&self, tag_name: &str, sha: &str) -> Result<()> {
let endpoint = CreateTag::builder()
.project(&self.project_id)
.message(tag_name)
.tag_name(tag_name)
.ref_(sha)
.build()?;
ignore(endpoint).query_async(&self.gl).await?;
Ok(())
}
async fn get_open_release_pr(
&self,
req: GetPrRequest,
) -> Result<Option<PullRequest>> {
let endpoint = MergeRequests::builder()
.project(&self.project_id)
.state(MergeRequestState::Opened)
.source_branch(&req.head_branch)
.target_branch(&req.base_branch)
.build()?;
let result: std::result::Result<
Vec<MergeRequestInfo>,
gitlab::api::ApiError<gitlab::RestError>,
> = paged(
endpoint,
Pagination::AllPerPageLimit(DEFAULT_PAGE_SIZE.into()),
)
.query_async(&self.gl)
.await;
match result {
Ok(merge_requests) => {
if merge_requests.is_empty() {
return Ok(None);
}
if merge_requests.len() > 1 {
return Err(ReleasaurusError::forge(format!(
"Found more than one open release PR with pending label for branch {}",
req.head_branch
)));
}
Ok(Some(PullRequest {
number: merge_requests[0].iid,
sha: merge_requests[0].sha.clone(),
body: merge_requests[0].description.clone(),
}))
}
Err(gitlab::api::ApiError::GitlabWithStatus { status, msg }) => {
if status == reqwest::StatusCode::NOT_FOUND {
Ok(None)
} else {
let msg = format!(
"request for pull request failed: status {status}, msg: {msg}"
);
Err(ReleasaurusError::forge(msg))
}
}
Err(err) => Err(ReleasaurusError::forge(format!(
"encountered error querying gitlab for merge request: {err}"
))),
}
}
async fn get_merged_release_pr(
&self,
req: GetPrRequest,
) -> Result<Option<PullRequest>> {
let endpoint = MergeRequests::builder()
.project(&self.project_id)
.state(MergeRequestState::Merged)
.source_branch(req.head_branch.clone())
.labels(vec![PENDING_LABEL])
.build()?;
let merge_requests: Vec<MergeRequestInfo> = paged(
endpoint,
Pagination::AllPerPageLimit(DEFAULT_PAGE_SIZE.into()),
)
.query_async(&self.gl)
.await?;
if merge_requests.is_empty() {
return Ok(None);
}
if merge_requests.len() > 1 {
return Err(ReleasaurusError::forge(format!(
"Found more than one closed release PR with pending label for branch {}. \
This means either release PRs were closed manually or releasaurus failed to remove tags. \
You must remove the {PENDING_LABEL} label from all closed release PRs except for the most recent.",
req.head_branch
)));
}
let merge_request = &merge_requests[0];
if merge_request.merged_at.is_none() {
return Err(ReleasaurusError::forge(format!(
"found release PR {} but it hasn't been merged yet",
merge_request.iid
)));
}
let sha = merge_request
.merge_commit_sha
.clone()
.unwrap_or(merge_request.sha.clone());
Ok(Some(PullRequest {
number: merge_request.iid,
sha,
body: merge_request.description.clone(),
}))
}
async fn create_pr(&self, req: CreatePrRequest) -> Result<PullRequest> {
let endpoint = CreateMergeRequest::builder()
.project(&self.project_id)
.source_branch(&req.head_branch)
.target_branch(&req.base_branch)
.title(&req.title)
.description(&req.body)
.build()?;
let merge_request: MergeRequestInfo =
endpoint.query_async(&self.gl).await?;
Ok(PullRequest {
number: merge_request.iid,
sha: merge_request.sha.clone(),
body: merge_request.description.clone(),
})
}
async fn update_pr(&self, req: UpdatePrRequest) -> Result<()> {
let endpoint = EditMergeRequest::builder()
.project(&self.project_id)
.merge_request(req.pr_number)
.title(&req.title)
.description(&req.body)
.build()?;
ignore(endpoint).query_async(&self.gl).await?;
Ok(())
}
async fn replace_pr_labels(&self, req: PrLabelsRequest) -> Result<()> {
let all_labels = self.get_repo_labels().await?;
let mut labels = vec![];
for name in req.labels {
if let Some(label) = all_labels.iter().find(|l| l.name == name) {
labels.push(label.name.clone());
} else {
let label = self.create_label(name).await?;
labels.push(label.name);
}
}
let endpoint = EditMergeRequest::builder()
.project(&self.project_id)
.merge_request(req.pr_number)
.labels(labels.iter())
.build()?;
ignore(endpoint).query_async(&self.gl).await?;
Ok(())
}
async fn create_release(
&self,
tag: &str,
sha: &str,
notes: &str,
) -> Result<()> {
let endpoint = CreateRelease::builder()
.project(&self.project_id)
.tag_name(tag)
.name(tag)
.description(notes)
.ref_sha(sha)
.build()?;
ignore(endpoint).query_async(&self.gl).await?;
Ok(())
}
}