use async_stream::stream as async_stream;
use futures::{Stream, StreamExt, stream};
use reqwest_middleware::ClientWithMiddleware;
use serde::{Deserialize, Serialize};
use super::{Debug, MAX_PAGE_SIZE, RemoteClient, RemoteCommit, RemotePullRequest};
use crate::config::Remote;
use crate::error::{Error, Result};
pub(crate) const TEMPLATE_VARIABLES: &[&str] = &["gitlab", "commit.gitlab", "commit.remote"];
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitLabProject {
pub id: Option<i64>,
pub description: Option<String>,
pub name: Option<String>,
pub name_with_namespace: Option<String>,
pub path_with_namespace: Option<String>,
pub created_at: Option<String>,
pub default_branch: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitLabCommit {
pub id: Option<String>,
pub short_id: Option<String>,
pub title: Option<String>,
pub author_name: Option<String>,
pub author_email: Option<String>,
pub authored_date: Option<String>,
pub committer_name: Option<String>,
pub committer_email: Option<String>,
pub committed_date: Option<String>,
pub created_at: Option<String>,
pub message: Option<String>,
pub parent_ids: Vec<String>,
pub web_url: Option<String>,
}
impl RemoteCommit for GitLabCommit {
fn id(&self) -> String {
self.id
.clone()
.expect("Commit id is required for git-cliff semantics")
}
fn username(&self) -> Option<String> {
self.author_name.clone()
}
fn timestamp(&self) -> Option<i64> {
self.committed_date
.as_deref()
.map(|d| self.convert_to_unix_timestamp(d))
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitLabMergeRequest {
pub id: Option<i64>,
pub iid: Option<i64>,
pub project_id: Option<i64>,
pub title: Option<String>,
pub description: Option<String>,
pub state: Option<String>,
pub created_at: Option<String>,
pub author: Option<GitLabUser>,
pub sha: Option<String>,
pub merge_commit_sha: Option<String>,
pub squash_commit_sha: Option<String>,
pub web_url: Option<String>,
pub labels: Vec<String>,
}
impl RemotePullRequest for GitLabMergeRequest {
fn number(&self) -> i64 {
self.iid
.expect("Merge request id is required for git-cliff semantics")
}
fn title(&self) -> Option<String> {
self.title.clone()
}
fn labels(&self) -> Vec<String> {
self.labels.clone()
}
fn merge_commit(&self) -> Option<String> {
self.merge_commit_sha
.clone()
.or_else(|| self.squash_commit_sha.clone().or_else(|| self.sha.clone()))
}
}
#[derive(Debug, Default, Clone, Hash, Eq, PartialEq, Deserialize, Serialize)]
pub struct GitLabUser {
pub id: Option<i64>,
pub name: Option<String>,
pub username: Option<String>,
pub state: Option<String>,
pub avatar_url: Option<String>,
pub web_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GitLabClient {
remote: Remote,
client: ClientWithMiddleware,
}
impl TryFrom<Remote> for GitLabClient {
type Error = Error;
fn try_from(remote: Remote) -> Result<Self> {
Ok(Self {
client: remote.create_client("application/json")?,
remote,
})
}
}
impl RemoteClient for GitLabClient {
const API_URL: &'static str = "https://gitlab.com/api/v4";
const API_URL_ENV: &'static str = "GITLAB_API_URL";
fn remote(&self) -> Remote {
self.remote.clone()
}
fn client(&self) -> ClientWithMiddleware {
self.client.clone()
}
}
impl GitLabClient {
fn project_url(api_url: &str, remote: &Remote) -> String {
format!(
"{}/projects/{}%2F{}",
api_url,
urlencoding::encode(remote.owner.as_str()),
remote.repo
)
}
fn commits_url(project_id: i64, api_url: &str, ref_name: Option<&str>, page: i32) -> String {
let mut url = format!(
"{api_url}/projects/{project_id}/repository/commits?per_page={MAX_PAGE_SIZE}&\
page={page}"
);
if let Some(ref_name) = ref_name {
url.push_str(&format!("&ref_name={ref_name}"));
}
url
}
fn pull_requests_url(project_id: i64, api_url: &str, page: i32) -> String {
format!(
"{api_url}/projects/{project_id}/merge_requests?per_page={MAX_PAGE_SIZE}&page={page}&\
state=merged"
)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
pub async fn get_project(&self) -> Result<GitLabProject> {
crate::set_progress_message!("Fetching the project details from GitLab");
let url = Self::project_url(&self.api_url(), &self.remote());
self.get_json::<GitLabProject>(&url).await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
pub async fn get_commits(
&self,
project_id: i64,
ref_name: Option<&str>,
) -> Result<Vec<Box<dyn RemoteCommit>>> {
use futures::TryStreamExt;
crate::set_progress_message!("Fetching all commits from GitLab");
self.get_commit_stream(project_id, ref_name)
.try_collect()
.await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
pub async fn get_pull_requests(
&self,
project_id: i64,
) -> Result<Vec<Box<dyn RemotePullRequest>>> {
use futures::TryStreamExt;
crate::set_progress_message!("Fetching all pull requests from GitLab");
self.get_pull_request_stream(project_id).try_collect().await
}
fn get_commit_stream(
&self,
project_id: i64,
ref_name: Option<&str>,
) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
let ref_name = ref_name.map(ToString::to_string);
async_stream! {
let page_stream = stream::iter(1..)
.map(move |page| {
let ref_name = ref_name.clone();
async move {
let url = Self::commits_url(project_id, &self.api_url(), ref_name.as_deref(), page);
self.get_json::<Vec<GitLabCommit>>(&url).await
}
})
.buffered(10);
let mut page_stream = Box::pin(page_stream);
while let Some(page_result) = page_stream.next().await {
match page_result {
Ok(commits) => {
if commits.is_empty() {
break;
}
for commit in commits {
yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
}
}
Err(e) => {
yield Err(e);
break;
}
}
}
}
}
fn get_pull_request_stream(
&self,
project_id: i64,
) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
async_stream! {
let page_stream = stream::iter(1..)
.map(move |page| async move {
let url = Self::pull_requests_url(project_id, &self.api_url(), page);
self.get_json::<Vec<GitLabMergeRequest>>(&url).await
})
.buffered(5);
let mut page_stream = Box::pin(page_stream);
while let Some(page_result) = page_stream.next().await {
match page_result {
Ok(mrs) => {
if mrs.is_empty() {
break;
}
for mr in mrs {
yield Ok(Box::new(mr) as Box<dyn RemotePullRequest>);
}
}
Err(e) => {
yield Err(e);
break;
}
}
}
}
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn gitlab_project_url_encodes_owner() {
let remote = Remote {
owner: "abc/def".to_string(),
repo: "xyz1".to_string(),
..Default::default()
};
let url = GitLabClient::project_url("https://gitlab.test.com/api/v4", &remote);
assert_eq!(
"https://gitlab.test.com/api/v4/projects/abc%2Fdef%2Fxyz1",
url
);
}
#[test]
fn timestamp() {
let remote_commit = GitLabCommit {
id: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
author_name: Some(String::from("orhun")),
committed_date: Some(String::from("2021-07-18T15:14:39+03:00")),
..Default::default()
};
assert_eq!(Some(1_626_610_479), remote_commit.timestamp());
}
#[test]
fn pull_request_no_merge_commit() {
let mr = GitLabMergeRequest {
sha: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
..Default::default()
};
assert!(mr.merge_commit().is_some());
}
#[test]
fn pull_request_squash_commit() {
let mr = GitLabMergeRequest {
squash_commit_sha: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
..Default::default()
};
assert!(mr.merge_commit().is_some());
}
}