use async_stream::stream as async_stream;
use futures::{Stream, StreamExt, stream};
use reqwest_middleware::ClientWithMiddleware;
use serde::{Deserialize, Serialize};
use super::*;
use crate::config::Remote;
use crate::error::*;
pub const START_FETCHING_MSG: &str = "Retrieving data from Azure DevOps...";
pub const FINISHED_FETCHING_MSG: &str = "Done fetching Azure DevOps data.";
pub(crate) const TEMPLATE_VARIABLES: &[&str] = &[
"azure_devops",
"commit.azure_devops",
"commit.remote",
"remote.azure_devops",
];
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AzureDevOpsCommit {
#[serde(rename = "commitId")]
pub commit_id: String,
pub author: Option<AzureDevOpsCommitAuthor>,
pub committer: Option<AzureDevOpsCommitAuthor>,
}
impl RemoteCommit for AzureDevOpsCommit {
fn id(&self) -> String {
self.commit_id.clone()
}
fn username(&self) -> Option<String> {
self.author.clone().and_then(|v| v.name)
}
fn timestamp(&self) -> Option<i64> {
self.author
.clone()
.and_then(|v| v.date)
.map(|date| self.convert_to_unix_timestamp(&date))
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AzureDevOpsCommitsResponse {
pub value: Vec<AzureDevOpsCommit>,
pub count: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AzureDevOpsCommitAuthor {
pub name: Option<String>,
pub email: Option<String>,
pub date: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AzureDevOpsPullRequest {
#[serde(rename = "pullRequestId")]
pub pull_request_id: i64,
pub title: Option<String>,
pub status: String,
#[serde(rename = "createdBy")]
pub created_by: Option<AzureDevOpsUser>,
#[serde(rename = "lastMergeCommit")]
pub last_merge_commit: Option<AzureDevOpsCommitRef>,
#[serde(default)]
pub labels: Vec<AzureDevOpsPullRequestLabel>,
}
impl RemotePullRequest for AzureDevOpsPullRequest {
fn number(&self) -> i64 {
self.pull_request_id
}
fn title(&self) -> Option<String> {
self.title.clone()
}
fn labels(&self) -> Vec<String> {
self.labels.iter().map(|v| v.name.clone()).collect()
}
fn merge_commit(&self) -> Option<String> {
self.last_merge_commit.clone().and_then(|v| v.commit_id)
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AzureDevOpsPullRequestsResponse {
pub value: Vec<AzureDevOpsPullRequest>,
pub count: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AzureDevOpsPullRequestLabel {
pub name: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AzureDevOpsCommitRef {
#[serde(rename = "commitId")]
pub commit_id: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AzureDevOpsUser {
#[serde(rename = "displayName")]
pub display_name: Option<String>,
#[serde(rename = "uniqueName")]
pub unique_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AzureDevOpsClient {
remote: Remote,
client: ClientWithMiddleware,
}
impl TryFrom<Remote> for AzureDevOpsClient {
type Error = Error;
fn try_from(remote: Remote) -> Result<Self> {
Ok(Self {
client: remote.create_client("application/json")?,
remote,
})
}
}
impl RemoteClient for AzureDevOpsClient {
const API_URL: &'static str = "https://dev.azure.com";
const API_URL_ENV: &'static str = "AZURE_DEVOPS_API_URL";
fn remote(&self) -> Remote {
self.remote.clone()
}
fn client(&self) -> ClientWithMiddleware {
self.client.clone()
}
}
impl AzureDevOpsClient {
fn commits_url(api_url: &str, remote: &Remote, ref_name: Option<&str>, page: i32) -> String {
let skip = page * MAX_PAGE_SIZE as i32;
let mut url = format!(
"{}/{}/_apis/git/repositories/{}/commits?api-version=7.1&$top={}&$skip={}",
api_url,
urlencoding::encode(&remote.owner),
urlencoding::encode(&remote.repo),
MAX_PAGE_SIZE,
skip
);
if let Some(ref_name) = ref_name {
url.push_str(&format!(
"&searchCriteria.itemVersion.versionType=tag&searchCriteria.itemVersion.version={}",
urlencoding::encode(ref_name)
));
}
url
}
fn pull_requests_url(api_url: &str, remote: &Remote, page: i32) -> String {
let skip = page * MAX_PAGE_SIZE as i32;
format!(
"{}/{}/_apis/git/repositories/{}/pullrequests?api-version=7.1&searchCriteria.\
status=completed&$top={}&$skip={}",
api_url,
urlencoding::encode(&remote.owner),
urlencoding::encode(&remote.repo),
MAX_PAGE_SIZE,
skip
)
}
pub async fn get_commits(&self, ref_name: Option<&str>) -> Result<Vec<Box<dyn RemoteCommit>>> {
use futures::TryStreamExt;
self.get_commit_stream(ref_name).try_collect().await
}
pub async fn get_pull_requests(&self) -> Result<Vec<Box<dyn RemotePullRequest>>> {
use futures::TryStreamExt;
self.get_pull_request_stream().try_collect().await
}
fn get_commit_stream<'a>(
&'a self,
ref_name: Option<&str>,
) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + 'a {
let ref_name = ref_name.map(ToString::to_string);
async_stream! {
let page_stream = stream::iter(0..)
.map(|page| {
let ref_name = ref_name.clone();
async move {
let url = Self::commits_url(&self.api_url(), &self.remote(), ref_name.as_deref(), page);
self.get_json::<AzureDevOpsCommitsResponse>(&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(response) => {
if response.value.is_empty() {
break;
}
for commit in response.value {
yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
}
}
Err(e) => {
yield Err(e);
break;
}
}
}
}
}
fn get_pull_request_stream<'a>(
&'a self,
) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + 'a {
async_stream! {
let page_stream = stream::iter(0..)
.map(|page| async move {
let url = Self::pull_requests_url(&self.api_url(), &self.remote(), page);
self.get_json::<AzureDevOpsPullRequestsResponse>(&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(response) => {
if response.value.is_empty() {
break;
}
for pr in response.value {
yield Ok(Box::new(pr) as Box<dyn RemotePullRequest>);
}
}
Err(e) => {
yield Err(e);
break;
}
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use pretty_assertions::assert_eq;
use super::*;
use crate::config::Remote;
use crate::remote::RemotePullRequest;
#[test]
fn commits_url() {
let remote = Remote {
owner: String::from("myorg/myproject"),
repo: String::from("myrepo"),
token: None,
is_custom: false,
api_url: None,
native_tls: None,
};
let url = AzureDevOpsClient::commits_url("https://dev.azure.com", &remote, None, 0);
assert_eq!(
"https://dev.azure.com/myorg%2Fmyproject/_apis/git/repositories/myrepo/commits?api-version=7.1&$top=100&$skip=0",
url
);
}
#[test]
fn commits_url_with_tag() {
let remote = Remote {
owner: String::from("myorg/myproject"),
repo: String::from("myrepo"),
token: None,
is_custom: false,
api_url: None,
native_tls: None,
};
let url =
AzureDevOpsClient::commits_url("https://dev.azure.com", &remote, Some("v1.0.0"), 0);
assert!(url.contains("searchCriteria.itemVersion.versionType=tag"));
assert!(url.contains("searchCriteria.itemVersion.version=v1.0.0"));
}
#[test]
fn commits_url_pagination() {
let remote = Remote {
owner: String::from("org/proj"),
repo: String::from("repo"),
token: None,
is_custom: false,
api_url: None,
native_tls: None,
};
let url = AzureDevOpsClient::commits_url("https://dev.azure.com", &remote, None, 2);
assert!(url.contains("$skip=200"));
assert!(url.contains("$top=100"));
}
#[test]
fn pull_requests_url() {
let remote = Remote {
owner: String::from("myorg/myproject"),
repo: String::from("myrepo"),
token: None,
is_custom: false,
api_url: None,
native_tls: None,
};
let url = AzureDevOpsClient::pull_requests_url("https://dev.azure.com", &remote, 0);
assert!(url.contains("pullrequests"));
assert!(url.contains("searchCriteria.status=completed"));
assert!(url.contains("$top=100"));
assert!(url.contains("$skip=0"));
}
#[test]
fn client_try_from_remote() {
let remote = Remote {
owner: String::from("myorg/myproject"),
repo: String::from("myrepo"),
token: None,
is_custom: false,
api_url: None,
native_tls: None,
};
let client = AzureDevOpsClient::try_from(remote.clone());
assert!(client.is_ok());
let client = client.unwrap();
assert_eq!(remote.owner, client.remote().owner);
assert_eq!(remote.repo, client.remote().repo);
}
#[test]
fn pull_request_with_commit_ref_no_commit_id() {
let pr = AzureDevOpsPullRequest {
pull_request_id: 1,
title: Some(String::from("test")),
status: String::from("completed"),
created_by: None,
last_merge_commit: Some(AzureDevOpsCommitRef { commit_id: None }),
labels: vec![],
};
assert_eq!(None, pr.merge_commit());
}
}