use anyhow::Result;
use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Write};
use crate::atlassian::client::{AtlassianClient, JiraIssueLink, JiraLinkType, JiraRemoteIssueLink};
use crate::cli::atlassian::confirm::{guard_destructive_with_io, GuardOptions, GuardOutcome};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct LinkCommand {
#[command(subcommand)]
pub command: LinkSubcommands,
}
#[derive(Subcommand)]
pub enum LinkSubcommands {
List(ListLinksCommand),
Types(TypesCommand),
Create(CreateLinkCommand),
Remove(RemoveLinkCommand),
#[command(alias = "epic")]
Parent(ParentLinkCommand),
Remote(RemoteLinkCommand),
}
impl LinkCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
LinkSubcommands::List(cmd) => cmd.execute().await,
LinkSubcommands::Types(cmd) => cmd.execute().await,
LinkSubcommands::Create(cmd) => cmd.execute().await,
LinkSubcommands::Remove(cmd) => cmd.execute().await,
LinkSubcommands::Parent(cmd) => cmd.execute().await,
LinkSubcommands::Remote(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListLinksCommand {
pub key: String,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ListLinksCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_list_links(&client, &self.key, &self.output).await
}
}
#[derive(Parser)]
pub struct TypesCommand {
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl TypesCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_link_types(&client, &self.output).await
}
}
#[derive(Parser)]
pub struct CreateLinkCommand {
#[arg(long, value_name = "TYPE")]
pub r#type: String,
#[arg(long)]
pub inward: String,
#[arg(long)]
pub outward: String,
}
impl CreateLinkCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_create_link(&client, &self.r#type, &self.inward, &self.outward).await
}
}
#[derive(Parser)]
pub struct RemoveLinkCommand {
#[arg(long)]
pub link_id: String,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub dry_run: bool,
}
impl RemoveLinkCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let mut reader = io::BufReader::new(io::stdin());
let mut writer = io::stdout();
self.execute_with_io(&client, &mut reader, &mut writer)
.await
}
async fn execute_with_io(
self,
client: &AtlassianClient,
reader: &mut (dyn BufRead + Send),
writer: &mut (dyn Write + Send),
) -> Result<()> {
let prompt = format!("Remove link {}? [y/N] ", self.link_id);
let dry_run_message = format!("Would remove link {}.", self.link_id);
let outcome = guard_destructive_with_io(
&GuardOptions {
prompt: &prompt,
dry_run_message: &dry_run_message,
force: self.force,
dry_run: self.dry_run,
},
reader,
writer,
)?;
match outcome {
GuardOutcome::Proceed => {
client.remove_issue_link(&self.link_id).await?;
writeln!(writer, "Removed link {}.", self.link_id)?;
Ok(())
}
GuardOutcome::Cancelled | GuardOutcome::DryRun => Ok(()),
}
}
}
#[derive(Parser)]
pub struct ParentLinkCommand {
#[arg(long, alias = "epic")]
pub parent: String,
#[arg(long, alias = "issue")]
pub child: String,
}
impl ParentLinkCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_parent_link(&client, &self.parent, &self.child).await
}
}
#[derive(Parser)]
pub struct RemoteLinkCommand {
#[command(subcommand)]
pub command: RemoteLinkSubcommands,
}
#[derive(Subcommand)]
pub enum RemoteLinkSubcommands {
List(ListRemoteLinksCommand),
}
impl RemoteLinkCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
RemoteLinkSubcommands::List(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListRemoteLinksCommand {
pub key: String,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ListRemoteLinksCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_list_remote_links(&client, &self.key, &self.output).await
}
}
async fn run_list_links(client: &AtlassianClient, key: &str, output: &OutputFormat) -> Result<()> {
let links = client.get_issue_links(key).await?;
if output_as(&links, output)? {
return Ok(());
}
print_issue_links(key, &links);
Ok(())
}
async fn run_link_types(client: &AtlassianClient, output: &OutputFormat) -> Result<()> {
let types = client.get_link_types().await?;
if output_as(&types, output)? {
return Ok(());
}
print_link_types(&types);
Ok(())
}
async fn run_create_link(
client: &AtlassianClient,
link_type: &str,
inward: &str,
outward: &str,
) -> Result<()> {
client.create_issue_link(link_type, inward, outward).await?;
println!(
"Linked {} {} {} (type: {}).",
inward,
format_link_direction(link_type),
outward,
link_type
);
Ok(())
}
async fn run_parent_link(client: &AtlassianClient, parent: &str, child: &str) -> Result<()> {
client.set_issue_parent(child, parent).await?;
println!("Set parent of {child} to {parent}.");
Ok(())
}
fn format_link_direction(type_name: &str) -> &str {
match type_name.to_lowercase().as_str() {
"relates to" | "relates" => "↔",
_ => "→",
}
}
fn print_issue_links(key: &str, links: &[JiraIssueLink]) {
if links.is_empty() {
println!("{key}: no links.");
return;
}
let id_width = links.iter().map(|l| l.id.len()).max().unwrap_or(2).max(2);
let type_width = links
.iter()
.map(|l| l.link_type.len())
.max()
.unwrap_or(4)
.max(4);
let dir_width = 7; let key_width = links
.iter()
.map(|l| l.linked_issue_key.len())
.max()
.unwrap_or(3)
.max(3);
println!(
"{:<id_width$} {:<type_width$} {:<dir_width$} {:<key_width$} SUMMARY",
"ID", "TYPE", "DIR", "KEY"
);
let summary_sep = "-".repeat(7);
println!(
"{:<id_width$} {:<type_width$} {:<dir_width$} {:<key_width$} {summary_sep}",
"-".repeat(id_width),
"-".repeat(type_width),
"-".repeat(dir_width),
"-".repeat(key_width),
);
for link in links {
println!(
"{:<id_width$} {:<type_width$} {:<dir_width$} {:<key_width$} {}",
link.id,
link.link_type,
link.direction,
link.linked_issue_key,
link.linked_issue_summary
);
}
}
fn print_link_types(types: &[JiraLinkType]) {
if types.is_empty() {
println!("No link types found.");
return;
}
let id_width = types.iter().map(|t| t.id.len()).max().unwrap_or(2).max(2);
let name_width = types.iter().map(|t| t.name.len()).max().unwrap_or(4).max(4);
let inward_width = types
.iter()
.map(|t| t.inward.len())
.max()
.unwrap_or(6)
.max(6);
println!(
"{:<id_width$} {:<name_width$} {:<inward_width$} OUTWARD",
"ID", "NAME", "INWARD"
);
let out_sep = "-".repeat(7);
println!(
"{:<id_width$} {:<name_width$} {:<inward_width$} {out_sep}",
"-".repeat(id_width),
"-".repeat(name_width),
"-".repeat(inward_width),
);
for t in types {
println!(
"{:<id_width$} {:<name_width$} {:<inward_width$} {}",
t.id, t.name, t.inward, t.outward
);
}
}
async fn run_list_remote_links(
client: &AtlassianClient,
key: &str,
output: &OutputFormat,
) -> Result<()> {
let links = client.get_remote_issue_links(key).await?;
if output_as(&links, output)? {
return Ok(());
}
print_remote_links(key, &links);
Ok(())
}
fn print_remote_links(key: &str, links: &[JiraRemoteIssueLink]) {
if links.is_empty() {
println!("{key}: no remote links.");
return;
}
let id_width = links.iter().map(|l| l.id.len()).max().unwrap_or(2).max(2);
let rel_width = links
.iter()
.map(|l| l.relationship.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(12)
.max(12); let title_width = links
.iter()
.map(|l| l.object.title.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(5)
.max(5);
println!(
"{:<id_width$} {:<rel_width$} {:<title_width$} URL",
"ID", "RELATIONSHIP", "TITLE"
);
let url_sep = "-".repeat(3);
println!(
"{:<id_width$} {:<rel_width$} {:<title_width$} {url_sep}",
"-".repeat(id_width),
"-".repeat(rel_width),
"-".repeat(title_width),
);
for link in links {
println!(
"{:<id_width$} {:<rel_width$} {:<title_width$} {}",
link.id,
link.relationship.as_deref().unwrap_or("-"),
link.object.title.as_deref().unwrap_or("-"),
link.object.url,
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn sample_link_type(id: &str, name: &str, inward: &str, outward: &str) -> JiraLinkType {
JiraLinkType {
id: id.to_string(),
name: name.to_string(),
inward: inward.to_string(),
outward: outward.to_string(),
}
}
#[test]
fn format_direction_blocks() {
assert_eq!(format_link_direction("Blocks"), "→");
}
#[test]
fn format_direction_relates() {
assert_eq!(format_link_direction("Relates to"), "↔");
}
#[test]
fn format_direction_unknown() {
assert_eq!(format_link_direction("Custom Link"), "→");
}
#[test]
fn format_direction_case_insensitive() {
assert_eq!(format_link_direction("BLOCKS"), "→");
assert_eq!(format_link_direction("duplicates"), "→");
}
#[test]
fn print_types_empty() {
print_link_types(&[]);
}
#[test]
fn print_types_with_data() {
let types = vec![
sample_link_type("1", "Blocks", "is blocked by", "blocks"),
sample_link_type("2", "Clones", "is cloned by", "clones"),
];
print_link_types(&types);
}
fn sample_link(
id: &str,
link_type: &str,
direction: &str,
key: &str,
summary: &str,
) -> JiraIssueLink {
JiraIssueLink {
id: id.to_string(),
link_type: link_type.to_string(),
direction: direction.to_string(),
linked_issue_key: key.to_string(),
linked_issue_summary: summary.to_string(),
}
}
#[test]
fn print_issue_links_empty() {
print_issue_links("PROJ-1", &[]);
}
#[test]
fn print_issue_links_with_data() {
let links = vec![
sample_link("100", "Blocks", "outward", "PROJ-2", "Blocked issue"),
sample_link("101", "Relates", "inward", "PROJ-3", "Related issue"),
];
print_issue_links("PROJ-1", &links);
}
#[test]
fn link_command_list_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::List(ListLinksCommand {
key: "PROJ-1".to_string(),
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, LinkSubcommands::List(_)));
}
#[test]
fn link_command_types_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::Types(TypesCommand {
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, LinkSubcommands::Types(_)));
}
#[test]
fn link_command_create_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::Create(CreateLinkCommand {
r#type: "Blocks".to_string(),
inward: "PROJ-1".to_string(),
outward: "PROJ-2".to_string(),
}),
};
assert!(matches!(cmd.command, LinkSubcommands::Create(_)));
}
#[test]
fn link_command_remove_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::Remove(RemoveLinkCommand {
link_id: "12345".to_string(),
force: true,
dry_run: false,
}),
};
assert!(matches!(cmd.command, LinkSubcommands::Remove(_)));
}
#[test]
fn remove_link_command_dry_run_field_default_false() {
let cmd = RemoveLinkCommand {
link_id: "12345".to_string(),
force: false,
dry_run: false,
};
assert!(!cmd.force);
assert!(!cmd.dry_run);
}
#[test]
fn link_command_parent_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::Parent(ParentLinkCommand {
parent: "EPIC-1".to_string(),
child: "PROJ-2".to_string(),
}),
};
assert!(matches!(cmd.command, LinkSubcommands::Parent(_)));
}
#[test]
fn create_link_command_fields() {
let cmd = CreateLinkCommand {
r#type: "Blocks".to_string(),
inward: "PROJ-1".to_string(),
outward: "PROJ-2".to_string(),
};
assert_eq!(cmd.r#type, "Blocks");
assert_eq!(cmd.inward, "PROJ-1");
assert_eq!(cmd.outward, "PROJ-2");
}
#[test]
fn parent_link_command_fields() {
let cmd = ParentLinkCommand {
parent: "EPIC-1".to_string(),
child: "STORY-1".to_string(),
};
assert_eq!(cmd.parent, "EPIC-1");
assert_eq!(cmd.child, "STORY-1");
}
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[tokio::test]
async fn run_list_links_success() {
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!({
"key": "PROJ-1",
"fields": {
"summary": "Issue",
"issuelinks": []
}
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_list_links(&client, "PROJ-1", &OutputFormat::Table)
.await
.is_ok());
}
#[tokio::test]
async fn run_list_links_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_list_links(&client, "NOPE-1", &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_link_types_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issueLinkTypes": [
{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}
]
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_link_types(&client, &OutputFormat::Table).await.is_ok());
}
#[tokio::test]
async fn run_link_types_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_link_types(&client, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn run_create_link_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issueLink"))
.respond_with(wiremock::ResponseTemplate::new(201))
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_create_link(&client, "Blocks", "PROJ-1", "PROJ-2")
.await
.is_ok());
}
#[tokio::test]
async fn run_create_link_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issueLink"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_create_link(&client, "Bad", "PROJ-1", "PROJ-2")
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn remove_link_execute_force_calls_api() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issueLink/12345"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cmd = RemoveLinkCommand {
link_id: "12345".to_string(),
force: true,
dry_run: false,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&client, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Removed link 12345."));
}
#[tokio::test]
async fn remove_link_execute_dry_run_skips_api() {
let server = wiremock::MockServer::start().await;
let client = mock_client(&server.uri());
let cmd = RemoveLinkCommand {
link_id: "12345".to_string(),
force: false,
dry_run: true,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&client, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Would remove link 12345."));
assert!(!out.contains("Removed link"));
}
#[tokio::test]
async fn remove_link_execute_prompt_yes_calls_api() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issueLink/12345"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cmd = RemoveLinkCommand {
link_id: "12345".to_string(),
force: false,
dry_run: false,
};
let mut input = std::io::Cursor::new(b"y\n".to_vec());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&client, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Remove link 12345?"));
assert!(out.contains("Removed link 12345."));
}
#[tokio::test]
async fn remove_link_execute_prompt_no_skips_api() {
let server = wiremock::MockServer::start().await;
let client = mock_client(&server.uri());
let cmd = RemoveLinkCommand {
link_id: "12345".to_string(),
force: false,
dry_run: false,
};
let mut input = std::io::Cursor::new(b"n\n".to_vec());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&client, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Cancelled."));
assert!(!out.contains("Removed link"));
}
#[tokio::test]
async fn remove_link_execute_api_error_propagates() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issueLink/99999"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cmd = RemoveLinkCommand {
link_id: "99999".to_string(),
force: true,
dry_run: false,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
let err = cmd
.execute_with_io(&client, &mut input, &mut output)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_parent_link_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
.respond_with(wiremock::ResponseTemplate::new(204))
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_parent_link(&client, "EPIC-1", "PROJ-2").await.is_ok());
}
#[tokio::test]
async fn run_parent_link_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_parent_link(&client, "EPIC-1", "PROJ-2")
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn link_command_parent_execute_drives_create_client_and_calls_api() {
use crate::test_support::atlassian_env::AtlassianEnvGuard;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
let cmd = LinkCommand {
command: LinkSubcommands::Parent(ParentLinkCommand {
parent: "EPIC-1".to_string(),
child: "PROJ-2".to_string(),
}),
};
cmd.execute().await.unwrap();
}
#[tokio::test]
async fn remove_link_execute_force_propagates_writeln_error() {
use crate::test_support::failing_io::FailingWriter;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issueLink/12345"))
.respond_with(wiremock::ResponseTemplate::new(204))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cmd = RemoveLinkCommand {
link_id: "12345".to_string(),
force: true,
dry_run: false,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut writer = FailingWriter;
let err = cmd
.execute_with_io(&client, &mut input, &mut writer)
.await
.unwrap_err();
assert!(err.to_string().contains("simulated write failure"));
}
#[tokio::test]
async fn remove_link_execute_dry_run_propagates_guard_error() {
use crate::test_support::failing_io::FailingWriter;
let server = wiremock::MockServer::start().await;
let client = mock_client(&server.uri());
let cmd = RemoveLinkCommand {
link_id: "12345".to_string(),
force: false,
dry_run: true,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut writer = FailingWriter;
let err = cmd
.execute_with_io(&client, &mut input, &mut writer)
.await
.unwrap_err();
assert!(err.to_string().contains("simulated write failure"));
}
#[tokio::test]
async fn remove_link_execute_drives_create_client_and_calls_api() {
use crate::test_support::atlassian_env::AtlassianEnvGuard;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issueLink/12345"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
let cmd = RemoveLinkCommand {
link_id: "12345".to_string(),
force: true,
dry_run: false,
};
cmd.execute().await.unwrap();
}
use crate::atlassian::client::{JiraRemoteIssueLinkIcon, JiraRemoteIssueLinkObject};
fn sample_remote_link(
id: &str,
relationship: Option<&str>,
url: &str,
title: Option<&str>,
icon_title: Option<&str>,
) -> JiraRemoteIssueLink {
JiraRemoteIssueLink {
id: id.to_string(),
global_id: None,
relationship: relationship.map(String::from),
object: JiraRemoteIssueLinkObject {
url: url.to_string(),
title: title.map(String::from),
summary: None,
icon: icon_title.map(|t| JiraRemoteIssueLinkIcon {
url: None,
title: Some(t.to_string()),
}),
},
}
}
#[test]
fn print_remote_links_empty() {
print_remote_links("PROJ-1", &[]);
}
#[test]
fn print_remote_links_with_data() {
let links = vec![
sample_remote_link(
"10001",
Some("mentioned in"),
"https://example.atlassian.net/wiki/page/1",
Some("Design doc"),
Some("Confluence Page"),
),
sample_remote_link(
"10002",
None,
"https://bitbucket.org/acme/repo/pull-requests/42",
None,
None,
),
];
print_remote_links("PROJ-1", &links);
}
#[test]
fn link_command_remote_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::Remote(RemoteLinkCommand {
command: RemoteLinkSubcommands::List(ListRemoteLinksCommand {
key: "PROJ-1".to_string(),
output: OutputFormat::Table,
}),
}),
};
assert!(matches!(cmd.command, LinkSubcommands::Remote(_)));
}
#[tokio::test]
async fn run_list_remote_links_table_ok() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/remotelink",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"id": 10001,
"relationship": "mentioned in",
"object": {
"url": "https://example.atlassian.net/wiki/page/1",
"title": "Design doc"
}
}
])),
)
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(
run_list_remote_links(&client, "PROJ-1", &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_list_remote_links_yaml_ok() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/remotelink",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(
run_list_remote_links(&client, "PROJ-1", &OutputFormat::Yaml)
.await
.is_ok()
);
}
#[tokio::test]
async fn link_command_remote_list_execute_drives_create_client_and_calls_api() {
use crate::test_support::atlassian_env::AtlassianEnvGuard;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/remotelink",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.expect(1)
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
let cmd = LinkCommand {
command: LinkSubcommands::Remote(RemoteLinkCommand {
command: RemoteLinkSubcommands::List(ListRemoteLinksCommand {
key: "PROJ-1".to_string(),
output: OutputFormat::Yaml,
}),
}),
};
cmd.execute().await.unwrap();
}
#[tokio::test]
async fn run_list_remote_links_propagates_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/NOPE-1/remotelink",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_list_remote_links(&client, "NOPE-1", &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
}