use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::atlassian::client::{AgileBoardList, AtlassianClient, JiraSearchResult};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct BoardCommand {
#[command(subcommand)]
pub command: BoardSubcommands,
}
#[derive(Subcommand)]
pub enum BoardSubcommands {
List(ListCommand),
Issues(IssuesCommand),
}
impl BoardCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
BoardSubcommands::List(cmd) => cmd.execute().await,
BoardSubcommands::Issues(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[arg(long)]
pub project: Option<String>,
#[arg(long, value_name = "TYPE")]
pub r#type: Option<String>,
#[arg(long, default_value_t = 50)]
pub limit: u32,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ListCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_list_boards(
&client,
self.project.as_deref(),
self.r#type.as_deref(),
self.limit,
&self.output,
)
.await
}
}
#[derive(Parser)]
pub struct IssuesCommand {
#[arg(long)]
pub board_id: u64,
#[arg(long)]
pub jql: Option<String>,
#[arg(long, default_value_t = 50)]
pub limit: u32,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl IssuesCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_board_issues(
&client,
self.board_id,
self.jql.as_deref(),
self.limit,
&self.output,
)
.await
}
}
async fn run_list_boards(
client: &AtlassianClient,
project: Option<&str>,
board_type: Option<&str>,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let result = client.get_boards(project, board_type, limit).await?;
if output_as(&result, output)? {
return Ok(());
}
print_boards(&result);
Ok(())
}
async fn run_board_issues(
client: &AtlassianClient,
board_id: u64,
jql: Option<&str>,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let result = client.get_board_issues(board_id, jql, limit).await?;
if output_as(&result, output)? {
return Ok(());
}
print_board_issues(&result);
Ok(())
}
fn print_boards(result: &AgileBoardList) {
if result.boards.is_empty() {
println!("No boards found.");
return;
}
let id_width = result
.boards
.iter()
.map(|b| b.id.to_string().len())
.max()
.unwrap_or(2)
.max(2);
let type_width = result
.boards
.iter()
.map(|b| b.board_type.len())
.max()
.unwrap_or(4)
.max(4);
let proj_width = result
.boards
.iter()
.filter_map(|b| b.project_key.as_ref().map(String::len))
.max()
.unwrap_or(7)
.max(7);
println!(
"{:<id_width$} {:<type_width$} {:<proj_width$} NAME",
"ID", "TYPE", "PROJECT"
);
let name_sep = "-".repeat(4);
println!(
"{:<id_width$} {:<type_width$} {:<proj_width$} {name_sep}",
"-".repeat(id_width),
"-".repeat(type_width),
"-".repeat(proj_width),
);
for board in &result.boards {
let proj = board.project_key.as_deref().unwrap_or("-");
println!(
"{:<id_width$} {:<type_width$} {:<proj_width$} {}",
board.id, board.board_type, proj, board.name
);
}
if result.total > result.boards.len() as u32 {
println!(
"\nShowing {} of {} boards.",
result.boards.len(),
result.total
);
}
}
fn print_board_issues(result: &JiraSearchResult) {
if result.issues.is_empty() {
println!("No issues found.");
return;
}
let key_width = result
.issues
.iter()
.map(|i| i.key.len())
.max()
.unwrap_or(3)
.max(3);
let status_width = result
.issues
.iter()
.filter_map(|i| i.status.as_ref().map(String::len))
.max()
.unwrap_or(6)
.max(6);
let assignee_width = result
.issues
.iter()
.filter_map(|i| i.assignee.as_ref().map(String::len))
.max()
.unwrap_or(8)
.max(8);
let summary_sep = "-".repeat(7);
println!(
"{:<key_width$} {:<status_width$} {:<assignee_width$} SUMMARY",
"KEY", "STATUS", "ASSIGNEE"
);
println!(
"{:<key_width$} {:<status_width$} {:<assignee_width$} {summary_sep}",
"-".repeat(key_width),
"-".repeat(status_width),
"-".repeat(assignee_width),
);
for issue in &result.issues {
let status = issue.status.as_deref().unwrap_or("-");
let assignee = issue.assignee.as_deref().unwrap_or("-");
println!(
"{:<key_width$} {:<status_width$} {:<assignee_width$} {}",
issue.key, status, assignee, issue.summary
);
}
if result.total > result.issues.len() as u32 {
println!(
"\nShowing {} of {} issues.",
result.issues.len(),
result.total
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::atlassian::client::{AgileBoard, JiraIssue};
fn sample_board(id: u64, name: &str, board_type: &str, project: Option<&str>) -> AgileBoard {
AgileBoard {
id,
name: name.to_string(),
board_type: board_type.to_string(),
project_key: project.map(String::from),
}
}
fn sample_issue(
key: &str,
summary: &str,
status: Option<&str>,
assignee: Option<&str>,
) -> JiraIssue {
JiraIssue {
key: key.to_string(),
summary: summary.to_string(),
description_adf: None,
status: status.map(String::from),
issue_type: None,
assignee: assignee.map(String::from),
priority: None,
labels: vec![],
custom_fields: vec![],
}
}
#[test]
fn print_boards_empty() {
let result = AgileBoardList {
boards: vec![],
total: 0,
};
print_boards(&result);
}
#[test]
fn print_boards_with_data() {
let result = AgileBoardList {
boards: vec![
sample_board(1, "PROJ Board", "scrum", Some("PROJ")),
sample_board(2, "Kanban", "kanban", None),
],
total: 2,
};
print_boards(&result);
}
#[test]
fn print_boards_with_pagination() {
let result = AgileBoardList {
boards: vec![sample_board(1, "Board", "scrum", Some("PROJ"))],
total: 100,
};
print_boards(&result);
}
#[test]
fn print_board_issues_empty() {
let result = JiraSearchResult {
issues: vec![],
total: 0,
};
print_board_issues(&result);
}
#[test]
fn print_board_issues_with_data() {
let result = JiraSearchResult {
issues: vec![
sample_issue("PROJ-1", "Fix login", Some("Open"), Some("Alice")),
sample_issue("PROJ-2", "Add feature", None, None),
],
total: 2,
};
print_board_issues(&result);
}
#[test]
fn print_board_issues_with_pagination() {
let result = JiraSearchResult {
issues: vec![sample_issue("PROJ-1", "Issue", Some("Open"), None)],
total: 50,
};
print_board_issues(&result);
}
#[test]
fn board_command_list_variant() {
let cmd = BoardCommand {
command: BoardSubcommands::List(ListCommand {
project: None,
r#type: None,
limit: 50,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, BoardSubcommands::List(_)));
}
#[test]
fn board_command_issues_variant() {
let cmd = BoardCommand {
command: BoardSubcommands::Issues(IssuesCommand {
board_id: 1,
jql: None,
limit: 50,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, BoardSubcommands::Issues(_)));
}
#[test]
fn list_command_with_filters() {
let cmd = ListCommand {
project: Some("PROJ".to_string()),
r#type: Some("scrum".to_string()),
limit: 25,
output: OutputFormat::Table,
};
assert_eq!(cmd.project.as_deref(), Some("PROJ"));
assert_eq!(cmd.r#type.as_deref(), Some("scrum"));
}
#[test]
fn issues_command_with_jql() {
let cmd = IssuesCommand {
board_id: 42,
jql: Some("status = Open".to_string()),
limit: 10,
output: OutputFormat::Table,
};
assert_eq!(cmd.board_id, 42);
assert_eq!(cmd.jql.as_deref(), Some("status = Open"));
}
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[tokio::test]
async fn run_list_boards_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{
"id": 1, "name": "Board", "type": "scrum",
"location": {"projectKey": "PROJ"}
}],
"total": 1, "isLast": true
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(
run_list_boards(&client, None, None, 50, &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_list_boards_json_output() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": [], "total": 0, "isLast": true})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_list_boards(
&client,
Some("PROJ"),
Some("scrum"),
50,
&OutputFormat::Json
)
.await
.is_ok());
}
#[tokio::test]
async fn run_list_boards_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_list_boards(&client, None, None, 50, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn run_board_issues_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board/1/issue"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issues": [],
"total": 0
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_board_issues(&client, 1, None, 50, &OutputFormat::Table)
.await
.is_ok());
}
#[tokio::test]
async fn run_board_issues_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board/999/issue"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_board_issues(&client, 999, None, 50, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
}