use anyhow::Result;
use clap::{Parser, ValueEnum};
use crate::atlassian::client::{
AtlassianClient, JiraDevBranch, JiraDevCommit, JiraDevPullRequest, JiraDevRepository,
JiraDevStatus, JiraDevStatusSummary,
};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct DevCommand {
pub key: String,
#[arg(long)]
pub r#type: Option<DevDataType>,
#[arg(long)]
pub app: Option<String>,
#[arg(long)]
pub summary: bool,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
#[derive(Clone, ValueEnum)]
pub enum DevDataType {
Pullrequest,
Branch,
Repository,
}
impl DevDataType {
fn as_api_str(&self) -> &str {
match self {
Self::Pullrequest => "pullrequest",
Self::Branch => "branch",
Self::Repository => "repository",
}
}
}
impl DevCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let data_type = self.r#type.as_ref().map(DevDataType::as_api_str);
run_dev_status(
&client,
&self.key,
data_type,
self.app.as_deref(),
self.summary,
&self.output,
)
.await
}
}
async fn run_dev_status(
client: &AtlassianClient,
key: &str,
data_type: Option<&str>,
app_type: Option<&str>,
summary: bool,
output: &OutputFormat,
) -> Result<()> {
if summary {
let summary_data = client.get_dev_status_summary(key).await?;
if output_as(&summary_data, output)? {
return Ok(());
}
print_summary(key, &summary_data);
return Ok(());
}
let status = client.get_dev_status(key, data_type, app_type).await?;
if output_as(&status, output)? {
return Ok(());
}
print_dev_status(key, &status);
Ok(())
}
fn print_summary(key: &str, summary: &JiraDevStatusSummary) {
if summary.pullrequest.count == 0 && summary.branch.count == 0 && summary.repository.count == 0
{
println!("{key}: no development information.");
return;
}
println!("{key}:");
let rows = [
("pullrequest", &summary.pullrequest),
("branch", &summary.branch),
("repository", &summary.repository),
];
let type_w = rows.iter().map(|(t, _)| t.len()).max().unwrap_or(4).max(4);
println!(" {:<type_w$} {:>5} PROVIDERS", "TYPE", "COUNT");
println!(
" {:<type_w$} {:>5} {}",
"-".repeat(type_w),
"-----",
"-".repeat(9),
);
for (name, count) in &rows {
let providers = if count.providers.is_empty() {
"-".to_string()
} else {
count.providers.join(", ")
};
println!(" {:<type_w$} {:>5} {}", name, count.count, providers);
}
}
fn print_dev_status(key: &str, status: &JiraDevStatus) {
if status.pull_requests.is_empty()
&& status.branches.is_empty()
&& status.repositories.is_empty()
{
println!("{key}: no development information.");
return;
}
if !status.pull_requests.is_empty() {
print_pull_requests(&status.pull_requests);
}
if !status.branches.is_empty() {
if !status.pull_requests.is_empty() {
println!();
}
print_branches(&status.branches);
}
if !status.repositories.is_empty() {
if !status.pull_requests.is_empty() || !status.branches.is_empty() {
println!();
}
print_repositories(&status.repositories);
}
}
fn print_pull_requests(prs: &[JiraDevPullRequest]) {
let id_w = prs.iter().map(|p| p.id.len()).max().unwrap_or(2).max(2);
let status_w = prs.iter().map(|p| p.status.len()).max().unwrap_or(6).max(6);
let author_w = prs
.iter()
.map(|p| p.author.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(6)
.max(6);
let repo_w = prs
.iter()
.map(|p| p.repository_name.len())
.max()
.unwrap_or(4)
.max(4);
let src_w = prs
.iter()
.map(|p| p.source_branch.len())
.max()
.unwrap_or(6)
.max(6);
let dst_w = prs
.iter()
.map(|p| p.destination_branch.len())
.max()
.unwrap_or(6)
.max(6);
println!("Pull Requests:");
println!(
" {:<id_w$} {:<status_w$} {:<author_w$} {:<repo_w$} {:<src_w$} {:<dst_w$} NAME",
"ID", "STATUS", "AUTHOR", "REPO", "SOURCE", "TARGET"
);
println!(
" {:<id_w$} {:<status_w$} {:<author_w$} {:<repo_w$} {:<src_w$} {:<dst_w$} {}",
"-".repeat(id_w),
"-".repeat(status_w),
"-".repeat(author_w),
"-".repeat(repo_w),
"-".repeat(src_w),
"-".repeat(dst_w),
"-".repeat(4),
);
for pr in prs {
let author = pr.author.as_deref().unwrap_or("-");
println!(
" {:<id_w$} {:<status_w$} {:<author_w$} {:<repo_w$} {:<src_w$} {:<dst_w$} {}",
pr.id,
pr.status,
author,
pr.repository_name,
pr.source_branch,
pr.destination_branch,
pr.name
);
}
}
fn print_branches(branches: &[JiraDevBranch]) {
let repo_w = branches
.iter()
.map(|b| b.repository_name.len())
.max()
.unwrap_or(4)
.max(4);
let name_w = branches
.iter()
.map(|b| b.name.len())
.max()
.unwrap_or(6)
.max(6);
let commit_w = 7;
println!("Branches:");
println!(
" {:<repo_w$} {:<name_w$} {:<commit_w$} URL",
"REPO", "BRANCH", "COMMIT"
);
println!(
" {:<repo_w$} {:<name_w$} {:<commit_w$} {}",
"-".repeat(repo_w),
"-".repeat(name_w),
"-".repeat(commit_w),
"-".repeat(3),
);
for branch in branches {
let commit = branch
.last_commit
.as_ref()
.map_or("-", |c| c.display_id.as_str());
println!(
" {:<repo_w$} {:<name_w$} {:<commit_w$} {}",
branch.repository_name, branch.name, commit, branch.url
);
}
}
fn print_repositories(repos: &[JiraDevRepository]) {
let name_w = repos.iter().map(|r| r.name.len()).max().unwrap_or(4).max(4);
println!("Repositories:");
println!(" {:<name_w$} URL", "NAME");
println!(" {:<name_w$} {}", "-".repeat(name_w), "-".repeat(3));
for repo in repos {
println!(" {:<name_w$} {}", repo.name, repo.url);
if !repo.commits.is_empty() {
print_commits(&repo.commits);
}
}
}
fn print_commits(commits: &[JiraDevCommit]) {
let sha_w = 7; let author_w = commits
.iter()
.map(|c| c.author.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(6)
.max(6);
println!(" {:<sha_w$} {:<author_w$} MESSAGE", "SHA", "AUTHOR");
for commit in commits {
let author = commit.author.as_deref().unwrap_or("-");
let msg = commit.message.lines().next().unwrap_or("");
println!(
" {:<sha_w$} {:<author_w$} {}",
commit.display_id, author, msg
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::atlassian::client::JiraDevStatusCount;
fn sample_pr() -> JiraDevPullRequest {
JiraDevPullRequest {
id: "#42".to_string(),
name: "Fix login bug".to_string(),
status: "MERGED".to_string(),
url: "https://github.com/org/repo/pull/42".to_string(),
repository_name: "org/repo".to_string(),
source_branch: "fix-login".to_string(),
destination_branch: "main".to_string(),
author: Some("John Doe".to_string()),
reviewers: vec!["Jane Smith".to_string()],
comment_count: Some(3),
last_update: Some("2024-01-15T10:30:00.000+0000".to_string()),
}
}
fn sample_commit() -> JiraDevCommit {
JiraDevCommit {
id: "abc123def456789".to_string(),
display_id: "abc123d".to_string(),
message: "Fix issue PROJ-123".to_string(),
author: Some("John Doe".to_string()),
timestamp: Some("2024-01-15T12:42:57.000+0000".to_string()),
url: "https://github.com/org/repo/commit/abc123d".to_string(),
file_count: 3,
merge: false,
}
}
fn sample_branch() -> JiraDevBranch {
JiraDevBranch {
name: "feature/new-ui".to_string(),
url: "https://github.com/org/repo/tree/feature/new-ui".to_string(),
repository_name: "org/repo".to_string(),
create_pr_url: Some("https://github.com/org/repo/compare/feature/new-ui".to_string()),
last_commit: Some(sample_commit()),
}
}
fn sample_repo() -> JiraDevRepository {
JiraDevRepository {
name: "org/repo".to_string(),
url: "https://github.com/org/repo".to_string(),
commits: vec![sample_commit()],
}
}
#[test]
fn dev_data_type_as_api_str() {
assert_eq!(DevDataType::Pullrequest.as_api_str(), "pullrequest");
assert_eq!(DevDataType::Branch.as_api_str(), "branch");
assert_eq!(DevDataType::Repository.as_api_str(), "repository");
}
#[test]
fn print_dev_status_empty() {
let status = JiraDevStatus {
pull_requests: vec![],
branches: vec![],
repositories: vec![],
};
print_dev_status("PROJ-1", &status);
}
#[test]
fn print_dev_status_with_prs() {
let status = JiraDevStatus {
pull_requests: vec![sample_pr()],
branches: vec![],
repositories: vec![],
};
print_dev_status("PROJ-1", &status);
}
#[test]
fn print_dev_status_with_branches() {
let status = JiraDevStatus {
pull_requests: vec![],
branches: vec![sample_branch()],
repositories: vec![],
};
print_dev_status("PROJ-1", &status);
}
#[test]
fn print_dev_status_with_repositories() {
let status = JiraDevStatus {
pull_requests: vec![],
branches: vec![],
repositories: vec![sample_repo()],
};
print_dev_status("PROJ-1", &status);
}
#[test]
fn print_dev_status_all_sections() {
let status = JiraDevStatus {
pull_requests: vec![sample_pr()],
branches: vec![sample_branch()],
repositories: vec![sample_repo()],
};
print_dev_status("PROJ-1", &status);
}
#[test]
fn print_summary_empty() {
let summary = JiraDevStatusSummary {
pullrequest: JiraDevStatusCount {
count: 0,
providers: vec![],
},
branch: JiraDevStatusCount {
count: 0,
providers: vec![],
},
repository: JiraDevStatusCount {
count: 0,
providers: vec![],
},
};
print_summary("PROJ-1", &summary);
}
#[test]
fn print_summary_with_data() {
let summary = JiraDevStatusSummary {
pullrequest: JiraDevStatusCount {
count: 2,
providers: vec!["GitHub".to_string()],
},
branch: JiraDevStatusCount {
count: 1,
providers: vec!["GitHub".to_string()],
},
repository: JiraDevStatusCount {
count: 1,
providers: vec!["GitHub".to_string(), "bitbucket".to_string()],
},
};
print_summary("PROJ-1", &summary);
}
#[test]
fn print_commits_table() {
let commits = vec![sample_commit()];
print_commits(&commits);
}
#[test]
fn dev_command_fields() {
let cmd = DevCommand {
key: "PROJ-1".to_string(),
r#type: None,
app: None,
summary: false,
output: OutputFormat::Table,
};
assert_eq!(cmd.key, "PROJ-1");
assert!(cmd.r#type.is_none());
assert!(cmd.app.is_none());
assert!(!cmd.summary);
}
#[test]
fn dev_command_with_type_filter() {
let cmd = DevCommand {
key: "PROJ-1".to_string(),
r#type: Some(DevDataType::Pullrequest),
app: None,
summary: false,
output: OutputFormat::Json,
};
assert_eq!(cmd.key, "PROJ-1");
assert!(cmd.r#type.is_some());
}
#[test]
fn dev_command_with_app_filter() {
let cmd = DevCommand {
key: "PROJ-1".to_string(),
r#type: None,
app: Some("GitHub".to_string()),
summary: false,
output: OutputFormat::Table,
};
assert_eq!(cmd.app.as_deref(), Some("GitHub"));
}
#[test]
fn dev_command_summary_mode() {
let cmd = DevCommand {
key: "PROJ-1".to_string(),
r#type: None,
app: None,
summary: true,
output: OutputFormat::Table,
};
assert!(cmd.summary);
}
#[test]
fn pr_author_and_reviewers() {
let pr = sample_pr();
assert_eq!(pr.author.as_deref(), Some("John Doe"));
assert_eq!(pr.reviewers, vec!["Jane Smith"]);
assert_eq!(pr.comment_count, Some(3));
assert!(pr.last_update.is_some());
}
#[test]
fn commit_fields() {
let commit = sample_commit();
assert_eq!(commit.display_id, "abc123d");
assert_eq!(commit.file_count, 3);
assert!(!commit.merge);
}
#[test]
fn branch_last_commit() {
let branch = sample_branch();
assert!(branch.last_commit.is_some());
assert!(branch.create_pr_url.is_some());
}
#[test]
fn repo_with_commits() {
let repo = sample_repo();
assert_eq!(repo.commits.len(), 1);
assert_eq!(repo.commits[0].display_id, "abc123d");
}
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[tokio::test]
async fn run_dev_status_summary_mode() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/summary",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"summary": {
"pullrequest": {"overall": {"count": 0}, "byInstanceType": {}},
"branch": {"overall": {"count": 0}, "byInstanceType": {}},
"repository": {"overall": {"count": 0}, "byInstanceType": {}}
}
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(
run_dev_status(&client, "PROJ-1", None, None, true, &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_dev_status_detail_mode() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/summary",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"summary": {
"pullrequest": {
"overall": {"count": 0},
"byInstanceType": {"GitHub": {"count": 0, "name": "GitHub"}}
},
"branch": {"overall": {"count": 0}, "byInstanceType": {}},
"repository": {"overall": {"count": 0}, "byInstanceType": {}}
}
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/detail",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"detail": [{
"pullRequests": [],
"branches": [],
"repositories": []
}]
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(
run_dev_status(&client, "PROJ-1", None, None, false, &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_dev_status_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_dev_status(&client, "NOPE-1", None, None, true, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
}