diff --git a/src/api/issue.rs b/src/api/issue.rs index 53e2e5b..9e5ce8d 100644 --- a/src/api/issue.rs +++ b/src/api/issue.rs @@ -96,6 +96,30 @@ pub struct IssueComment { pub extra: BTreeMap, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueParticipant { + pub id: u64, + pub user_id: Option, + pub name: String, + pub role_type: Option, + pub lang: Option, + pub mail_address: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueSharedFile { + pub id: u64, + pub dir: String, + pub name: String, + pub size: u64, + #[serde(flatten)] + pub extra: BTreeMap, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IssueCommentCount { pub count: u64, @@ -197,6 +221,47 @@ impl BacklogClient { deserialize(value) } + pub fn delete_issue_attachment( + &self, + key: &str, + attachment_id: u64, + ) -> Result { + let value = self.delete_req(&format!("/issues/{}/attachments/{}", key, attachment_id))?; + deserialize(value) + } + + pub fn get_issue_participants(&self, key: &str) -> Result> { + let value = self.get(&format!("/issues/{}/participants", key))?; + deserialize(value) + } + + pub fn get_issue_shared_files(&self, key: &str) -> Result> { + let value = self.get(&format!("/issues/{}/sharedFiles", key))?; + deserialize(value) + } + + pub fn link_issue_shared_files( + &self, + key: &str, + shared_file_ids: &[u64], + ) -> Result> { + let params: Vec<(String, String)> = shared_file_ids + .iter() + .map(|id| ("fileId[]".to_string(), id.to_string())) + .collect(); + let value = self.post_form(&format!("/issues/{}/sharedFiles", key), ¶ms)?; + deserialize(value) + } + + pub fn unlink_issue_shared_file( + &self, + key: &str, + shared_file_id: u64, + ) -> Result { + let value = self.delete_req(&format!("/issues/{}/sharedFiles/{}", key, shared_file_id))?; + deserialize(value) + } + pub fn count_issue_comments(&self, key: &str) -> Result { let value = self.get(&format!("/issues/{}/comments/count", key))?; deserialize(value) @@ -418,6 +483,98 @@ mod tests { assert_eq!(attachments[0].name, "file.txt"); } + fn participant_json() -> serde_json::Value { + json!({ + "id": 1, + "userId": "alice", + "name": "Alice", + "roleType": 1, + "lang": null, + "mailAddress": null + }) + } + + fn shared_file_json() -> serde_json::Value { + json!({ + "id": 1, + "dir": "/docs", + "name": "spec.pdf", + "size": 2048 + }) + } + + #[test] + fn delete_issue_attachment_returns_attachment() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE) + .path("/issues/TEST-1/attachments/1") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(attachment_json()); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let a = client.delete_issue_attachment("TEST-1", 1).unwrap(); + assert_eq!(a.name, "file.txt"); + } + + #[test] + fn get_issue_participants_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/issues/TEST-1/participants") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([participant_json()])); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let ps = client.get_issue_participants("TEST-1").unwrap(); + assert_eq!(ps.len(), 1); + assert_eq!(ps[0].name, "Alice"); + } + + #[test] + fn get_issue_shared_files_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/issues/TEST-1/sharedFiles") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([shared_file_json()])); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let fs = client.get_issue_shared_files("TEST-1").unwrap(); + assert_eq!(fs.len(), 1); + assert_eq!(fs[0].name, "spec.pdf"); + } + + #[test] + fn link_issue_shared_files_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST) + .path("/issues/TEST-1/sharedFiles") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([shared_file_json()])); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let fs = client.link_issue_shared_files("TEST-1", &[1]).unwrap(); + assert_eq!(fs.len(), 1); + } + + #[test] + fn unlink_issue_shared_file_returns_file() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE) + .path("/issues/TEST-1/sharedFiles/1") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(shared_file_json()); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let f = client.unlink_issue_shared_file("TEST-1", 1).unwrap(); + assert_eq!(f.name, "spec.pdf"); + } + #[test] fn count_issue_comments_returns_count() { let server = MockServer::start(); diff --git a/src/api/mod.rs b/src/api/mod.rs index 7170959..e998aae 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -22,6 +22,7 @@ use activity::Activity; use disk_usage::DiskUsage; use issue::{ Issue, IssueAttachment, IssueComment, IssueCommentCount, IssueCommentNotification, IssueCount, + IssueParticipant, IssueSharedFile, }; use licence::Licence; use notification::{Notification, NotificationCount}; @@ -141,6 +142,29 @@ pub trait BacklogApi { fn get_issue_attachments(&self, _key: &str) -> Result> { unimplemented!() } + fn delete_issue_attachment(&self, _key: &str, _attachment_id: u64) -> Result { + unimplemented!() + } + fn get_issue_participants(&self, _key: &str) -> Result> { + unimplemented!() + } + fn get_issue_shared_files(&self, _key: &str) -> Result> { + unimplemented!() + } + fn link_issue_shared_files( + &self, + _key: &str, + _shared_file_ids: &[u64], + ) -> Result> { + unimplemented!() + } + fn unlink_issue_shared_file( + &self, + _key: &str, + _shared_file_id: u64, + ) -> Result { + unimplemented!() + } fn count_issue_comments(&self, _key: &str) -> Result { unimplemented!() } @@ -340,6 +364,30 @@ impl BacklogApi for BacklogClient { self.get_issue_attachments(key) } + fn delete_issue_attachment(&self, key: &str, attachment_id: u64) -> Result { + self.delete_issue_attachment(key, attachment_id) + } + + fn get_issue_participants(&self, key: &str) -> Result> { + self.get_issue_participants(key) + } + + fn get_issue_shared_files(&self, key: &str) -> Result> { + self.get_issue_shared_files(key) + } + + fn link_issue_shared_files( + &self, + key: &str, + shared_file_ids: &[u64], + ) -> Result> { + self.link_issue_shared_files(key, shared_file_ids) + } + + fn unlink_issue_shared_file(&self, key: &str, shared_file_id: u64) -> Result { + self.unlink_issue_shared_file(key, shared_file_id) + } + fn count_issue_comments(&self, key: &str) -> Result { self.count_issue_comments(key) } diff --git a/src/cmd/issue/attachment/delete.rs b/src/cmd/issue/attachment/delete.rs new file mode 100644 index 0000000..4d6297f --- /dev/null +++ b/src/cmd/issue/attachment/delete.rs @@ -0,0 +1,107 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct IssueAttachmentDeleteArgs { + key: String, + attachment_id: u64, + json: bool, +} + +impl IssueAttachmentDeleteArgs { + pub fn new(key: String, attachment_id: u64, json: bool) -> Self { + Self { + key, + attachment_id, + json, + } + } +} + +pub fn delete(args: &IssueAttachmentDeleteArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + delete_with(args, &client) +} + +pub fn delete_with(args: &IssueAttachmentDeleteArgs, api: &dyn BacklogApi) -> Result<()> { + let attachment = api.delete_issue_attachment(&args.key, args.attachment_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&attachment).context("Failed to serialize JSON")? + ); + } else { + println!("Deleted: {} ({} bytes)", attachment.name, attachment.size); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::issue::{IssueAttachment, IssueUser}; + use anyhow::anyhow; + use std::collections::BTreeMap; + + fn sample_attachment() -> IssueAttachment { + IssueAttachment { + id: 1, + name: "file.txt".to_string(), + size: 1024, + created_user: IssueUser { + id: 1, + user_id: Some("john".to_string()), + name: "John Doe".to_string(), + role_type: 1, + lang: None, + mail_address: None, + extra: BTreeMap::new(), + }, + created: "2024-01-01T00:00:00Z".to_string(), + } + } + + struct MockApi { + attachment: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn delete_issue_attachment( + &self, + _key: &str, + _attachment_id: u64, + ) -> anyhow::Result { + self.attachment + .clone() + .ok_or_else(|| anyhow!("no attachment")) + } + } + + fn args(json: bool) -> IssueAttachmentDeleteArgs { + IssueAttachmentDeleteArgs::new("TEST-1".to_string(), 1, json) + } + + #[test] + fn delete_with_text_output_succeeds() { + let api = MockApi { + attachment: Some(sample_attachment()), + }; + assert!(delete_with(&args(false), &api).is_ok()); + } + + #[test] + fn delete_with_json_output_succeeds() { + let api = MockApi { + attachment: Some(sample_attachment()), + }; + assert!(delete_with(&args(true), &api).is_ok()); + } + + #[test] + fn delete_with_propagates_api_error() { + let api = MockApi { attachment: None }; + let err = delete_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no attachment")); + } +} diff --git a/src/cmd/issue/attachment/mod.rs b/src/cmd/issue/attachment/mod.rs index 47e354a..8aa5ba2 100644 --- a/src/cmd/issue/attachment/mod.rs +++ b/src/cmd/issue/attachment/mod.rs @@ -1,3 +1,5 @@ +pub mod delete; mod list; +pub use delete::{IssueAttachmentDeleteArgs, delete}; pub use list::{IssueAttachmentListArgs, list}; diff --git a/src/cmd/issue/mod.rs b/src/cmd/issue/mod.rs index e864cbf..c39fcb5 100644 --- a/src/cmd/issue/mod.rs +++ b/src/cmd/issue/mod.rs @@ -4,6 +4,8 @@ mod count; mod create; mod delete; pub mod list; +pub mod participant; +pub mod shared_file; mod show; mod update; diff --git a/src/cmd/issue/participant/list.rs b/src/cmd/issue/participant/list.rs new file mode 100644 index 0000000..e455163 --- /dev/null +++ b/src/cmd/issue/participant/list.rs @@ -0,0 +1,120 @@ +use anstream::println; +use anyhow::{Context, Result}; +use owo_colors::OwoColorize; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct IssueParticipantListArgs { + key: String, + json: bool, +} + +impl IssueParticipantListArgs { + pub fn new(key: String, json: bool) -> Self { + Self { key, json } + } +} + +pub fn list(args: &IssueParticipantListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &IssueParticipantListArgs, api: &dyn BacklogApi) -> Result<()> { + let participants = api.get_issue_participants(&args.key)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&participants).context("Failed to serialize JSON")? + ); + } else { + for p in &participants { + let uid = p + .user_id + .as_deref() + .map(|s| s.to_string()) + .unwrap_or_else(|| p.id.to_string()); + println!("[{}] {}", uid.cyan().bold(), p.name); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::issue::IssueParticipant; + use anyhow::anyhow; + use std::collections::BTreeMap; + + fn sample_participant() -> IssueParticipant { + IssueParticipant { + id: 1, + user_id: Some("alice".to_string()), + name: "Alice".to_string(), + role_type: Some(1), + lang: None, + mail_address: None, + extra: BTreeMap::new(), + } + } + + fn bot_participant() -> IssueParticipant { + IssueParticipant { + id: 99, + user_id: None, + name: "Bot".to_string(), + role_type: None, + lang: None, + mail_address: None, + extra: BTreeMap::new(), + } + } + + struct MockApi { + participants: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_issue_participants(&self, _key: &str) -> anyhow::Result> { + self.participants + .clone() + .ok_or_else(|| anyhow!("no participants")) + } + } + + fn args(json: bool) -> IssueParticipantListArgs { + IssueParticipantListArgs::new("TEST-1".to_string(), json) + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + participants: Some(vec![sample_participant()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + participants: Some(vec![sample_participant()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { participants: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no participants")); + } + + #[test] + fn list_with_bot_falls_back_to_numeric_id() { + let api = MockApi { + participants: Some(vec![bot_participant()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } +} diff --git a/src/cmd/issue/participant/mod.rs b/src/cmd/issue/participant/mod.rs new file mode 100644 index 0000000..ed18251 --- /dev/null +++ b/src/cmd/issue/participant/mod.rs @@ -0,0 +1,3 @@ +pub mod list; + +pub use list::{IssueParticipantListArgs, list}; diff --git a/src/cmd/issue/shared_file/link.rs b/src/cmd/issue/shared_file/link.rs new file mode 100644 index 0000000..e7cbfae --- /dev/null +++ b/src/cmd/issue/shared_file/link.rs @@ -0,0 +1,98 @@ +use anstream::println; +use anyhow::{Context, Result, bail}; + +use crate::api::{BacklogApi, BacklogClient}; + +#[derive(Debug)] +pub struct IssueSharedFileLinkArgs { + key: String, + shared_file_ids: Vec, + json: bool, +} + +impl IssueSharedFileLinkArgs { + pub fn try_new(key: String, shared_file_ids: Vec, json: bool) -> Result { + if shared_file_ids.is_empty() { + bail!("at least one --shared-file-id must be specified"); + } + Ok(Self { + key, + shared_file_ids, + json, + }) + } +} + +pub fn link(args: &IssueSharedFileLinkArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + link_with(args, &client) +} + +pub fn link_with(args: &IssueSharedFileLinkArgs, api: &dyn BacklogApi) -> Result<()> { + let files = api.link_issue_shared_files(&args.key, &args.shared_file_ids)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&files).context("Failed to serialize JSON")? + ); + } else { + println!("Linked {} file(s).", files.len()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::issue::IssueSharedFile; + use crate::cmd::issue::shared_file::list::sample_shared_file; + use anyhow::anyhow; + + struct MockApi { + files: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn link_issue_shared_files( + &self, + _key: &str, + _shared_file_ids: &[u64], + ) -> anyhow::Result> { + self.files.clone().ok_or_else(|| anyhow!("no files")) + } + } + + fn args(json: bool) -> IssueSharedFileLinkArgs { + IssueSharedFileLinkArgs::try_new("TEST-1".to_string(), vec![1], json).unwrap() + } + + #[test] + fn link_with_text_output_succeeds() { + let api = MockApi { + files: Some(vec![sample_shared_file()]), + }; + assert!(link_with(&args(false), &api).is_ok()); + } + + #[test] + fn link_with_json_output_succeeds() { + let api = MockApi { + files: Some(vec![sample_shared_file()]), + }; + assert!(link_with(&args(true), &api).is_ok()); + } + + #[test] + fn link_with_propagates_api_error() { + let api = MockApi { files: None }; + let err = link_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no files")); + } + + #[test] + fn try_new_rejects_empty_ids() { + let err = + IssueSharedFileLinkArgs::try_new("TEST-1".to_string(), vec![], false).unwrap_err(); + assert!(err.to_string().contains("at least one")); + } +} diff --git a/src/cmd/issue/shared_file/list.rs b/src/cmd/issue/shared_file/list.rs new file mode 100644 index 0000000..0f4032b --- /dev/null +++ b/src/cmd/issue/shared_file/list.rs @@ -0,0 +1,101 @@ +use anstream::println; +use anyhow::{Context, Result}; +use owo_colors::OwoColorize; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct IssueSharedFileListArgs { + key: String, + json: bool, +} + +impl IssueSharedFileListArgs { + pub fn new(key: String, json: bool) -> Self { + Self { key, json } + } +} + +pub fn list(args: &IssueSharedFileListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &IssueSharedFileListArgs, api: &dyn BacklogApi) -> Result<()> { + let files = api.get_issue_shared_files(&args.key)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&files).context("Failed to serialize JSON")? + ); + } else { + for f in &files { + println!( + "[{}] {}/{} ({} bytes)", + f.id.to_string().cyan().bold(), + f.dir, + f.name, + f.size + ); + } + } + Ok(()) +} + +#[cfg(test)] +use crate::api::issue::IssueSharedFile; +#[cfg(test)] +use std::collections::BTreeMap; + +#[cfg(test)] +pub(crate) fn sample_shared_file() -> IssueSharedFile { + IssueSharedFile { + id: 1, + dir: "/docs".to_string(), + name: "spec.pdf".to_string(), + size: 2048, + extra: BTreeMap::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + + struct MockApi { + files: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_issue_shared_files(&self, _key: &str) -> anyhow::Result> { + self.files.clone().ok_or_else(|| anyhow!("no files")) + } + } + + fn args(json: bool) -> IssueSharedFileListArgs { + IssueSharedFileListArgs::new("TEST-1".to_string(), json) + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + files: Some(vec![sample_shared_file()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + files: Some(vec![sample_shared_file()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { files: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no files")); + } +} diff --git a/src/cmd/issue/shared_file/mod.rs b/src/cmd/issue/shared_file/mod.rs new file mode 100644 index 0000000..df7d18a --- /dev/null +++ b/src/cmd/issue/shared_file/mod.rs @@ -0,0 +1,7 @@ +pub mod link; +pub mod list; +pub mod unlink; + +pub use link::{IssueSharedFileLinkArgs, link}; +pub use list::{IssueSharedFileListArgs, list}; +pub use unlink::{IssueSharedFileUnlinkArgs, unlink}; diff --git a/src/cmd/issue/shared_file/unlink.rs b/src/cmd/issue/shared_file/unlink.rs new file mode 100644 index 0000000..ee54a50 --- /dev/null +++ b/src/cmd/issue/shared_file/unlink.rs @@ -0,0 +1,87 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct IssueSharedFileUnlinkArgs { + key: String, + shared_file_id: u64, + json: bool, +} + +impl IssueSharedFileUnlinkArgs { + pub fn new(key: String, shared_file_id: u64, json: bool) -> Self { + Self { + key, + shared_file_id, + json, + } + } +} + +pub fn unlink(args: &IssueSharedFileUnlinkArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + unlink_with(args, &client) +} + +pub fn unlink_with(args: &IssueSharedFileUnlinkArgs, api: &dyn BacklogApi) -> Result<()> { + let file = api.unlink_issue_shared_file(&args.key, args.shared_file_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&file).context("Failed to serialize JSON")? + ); + } else { + println!("Unlinked: {}", file.name); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::issue::IssueSharedFile; + use crate::cmd::issue::shared_file::list::sample_shared_file; + use anyhow::anyhow; + + struct MockApi { + file: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn unlink_issue_shared_file( + &self, + _key: &str, + _shared_file_id: u64, + ) -> anyhow::Result { + self.file.clone().ok_or_else(|| anyhow!("no file")) + } + } + + fn args(json: bool) -> IssueSharedFileUnlinkArgs { + IssueSharedFileUnlinkArgs::new("TEST-1".to_string(), 1, json) + } + + #[test] + fn unlink_with_text_output_succeeds() { + let api = MockApi { + file: Some(sample_shared_file()), + }; + assert!(unlink_with(&args(false), &api).is_ok()); + } + + #[test] + fn unlink_with_json_output_succeeds() { + let api = MockApi { + file: Some(sample_shared_file()), + }; + assert!(unlink_with(&args(true), &api).is_ok()); + } + + #[test] + fn unlink_with_propagates_api_error() { + let api = MockApi { file: None }; + let err = unlink_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no file")); + } +} diff --git a/src/main.rs b/src/main.rs index bbff8c6..daeaec5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use cmd::auth::AuthStatusArgs; -use cmd::issue::attachment::IssueAttachmentListArgs; +use cmd::issue::attachment::{IssueAttachmentDeleteArgs, IssueAttachmentListArgs}; use cmd::issue::comment::notification::{ IssueCommentNotificationAddArgs, IssueCommentNotificationListArgs, }; @@ -17,6 +17,10 @@ use cmd::issue::comment::{ IssueCommentAddArgs, IssueCommentCountArgs, IssueCommentDeleteArgs, IssueCommentListArgs, IssueCommentShowArgs, IssueCommentUpdateArgs, }; +use cmd::issue::participant::IssueParticipantListArgs; +use cmd::issue::shared_file::{ + IssueSharedFileLinkArgs, IssueSharedFileListArgs, IssueSharedFileUnlinkArgs, +}; use cmd::issue::{ IssueCountArgs, IssueCreateArgs, IssueDeleteArgs, IssueListArgs, IssueShowArgs, IssueUpdateArgs, ParentChild, @@ -456,6 +460,16 @@ enum IssueCommands { #[command(subcommand)] action: IssueAttachmentCommands, }, + /// List participants of an issue + Participant { + #[command(subcommand)] + action: IssueParticipantCommands, + }, + /// Manage shared files linked to an issue + SharedFile { + #[command(subcommand)] + action: IssueSharedFileCommands, + }, } #[derive(Subcommand)] @@ -564,6 +578,61 @@ enum IssueAttachmentCommands { #[arg(long)] json: bool, }, + /// Delete an issue attachment + Delete { + /// Issue ID or key + id_or_key: String, + /// Attachment ID + attachment_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand)] +enum IssueParticipantCommands { + /// List participants of an issue + List { + /// Issue ID or key + id_or_key: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand)] +enum IssueSharedFileCommands { + /// List shared files linked to an issue + List { + /// Issue ID or key + id_or_key: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Link shared files to an issue + Link { + /// Issue ID or key + id_or_key: String, + /// Shared file ID to link (repeatable) + #[arg(long = "shared-file-id", value_name = "ID")] + shared_file_ids: Vec, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Unlink a shared file from an issue + Unlink { + /// Issue ID or key + id_or_key: String, + /// Shared file ID to unlink + shared_file_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -1081,6 +1150,43 @@ fn run() -> Result<()> { IssueAttachmentCommands::List { id_or_key, json } => { cmd::issue::attachment::list(&IssueAttachmentListArgs::new(id_or_key, json)) } + IssueAttachmentCommands::Delete { + id_or_key, + attachment_id, + json, + } => cmd::issue::attachment::delete(&IssueAttachmentDeleteArgs::new( + id_or_key, + attachment_id, + json, + )), + }, + IssueCommands::Participant { action } => match action { + IssueParticipantCommands::List { id_or_key, json } => { + cmd::issue::participant::list(&IssueParticipantListArgs::new(id_or_key, json)) + } + }, + IssueCommands::SharedFile { action } => match action { + IssueSharedFileCommands::List { id_or_key, json } => { + cmd::issue::shared_file::list(&IssueSharedFileListArgs::new(id_or_key, json)) + } + IssueSharedFileCommands::Link { + id_or_key, + shared_file_ids, + json, + } => cmd::issue::shared_file::link(&IssueSharedFileLinkArgs::try_new( + id_or_key, + shared_file_ids, + json, + )?), + IssueSharedFileCommands::Unlink { + id_or_key, + shared_file_id, + json, + } => cmd::issue::shared_file::unlink(&IssueSharedFileUnlinkArgs::new( + id_or_key, + shared_file_id, + json, + )), }, }, Commands::Wiki { action } => match action { diff --git a/website/docs/commands.md b/website/docs/commands.md index 74d57a5..fb52f14 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -496,6 +496,51 @@ Example output: [2] log.txt (1024 bytes) ``` +## `bl issue attachment delete` + +Delete an attachment from an issue. + +```bash +bl issue attachment delete TEST-1 1 +bl issue attachment delete TEST-1 1 --json +``` + +## `bl issue participant list` + +List participants of an issue. + +```bash +bl issue participant list TEST-1 +bl issue participant list TEST-1 --json +``` + +## `bl issue shared-file list` + +List shared files linked to an issue. + +```bash +bl issue shared-file list TEST-1 +bl issue shared-file list TEST-1 --json +``` + +## `bl issue shared-file link` + +Link shared files to an issue. + +```bash +bl issue shared-file link TEST-1 --shared-file-id 1 +bl issue shared-file link TEST-1 --shared-file-id 1 --shared-file-id 2 --json +``` + +## `bl issue shared-file unlink` + +Unlink a shared file from an issue. + +```bash +bl issue shared-file unlink TEST-1 1 +bl issue shared-file unlink TEST-1 1 --json +``` + ## `bl wiki list` List wiki pages in a project. @@ -859,11 +904,11 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | `bl issue comment notification add ` | `POST /api/v2/issues/{issueIdOrKey}/comments/{commentId}/notifications` | ✅ Implemented | | `bl issue attachment list ` | `GET /api/v2/issues/{issueIdOrKey}/attachments` | ✅ Implemented | | `bl issue attachment get ` | `GET /api/v2/issues/{issueIdOrKey}/attachments/{attachmentId}` | Planned | -| `bl issue attachment delete ` | `DELETE /api/v2/issues/{issueIdOrKey}/attachments/{attachmentId}` | Planned | -| `bl issue participant list ` | `GET /api/v2/issues/{issueIdOrKey}/participants` | Planned | -| `bl issue shared-file list ` | `GET /api/v2/issues/{issueIdOrKey}/sharedFiles` | Planned | -| `bl issue shared-file link ` | `POST /api/v2/issues/{issueIdOrKey}/sharedFiles` | Planned | -| `bl issue shared-file unlink ` | `DELETE /api/v2/issues/{issueIdOrKey}/sharedFiles/{id}` | Planned | +| `bl issue attachment delete ` | `DELETE /api/v2/issues/{issueIdOrKey}/attachments/{attachmentId}` | ✅ Implemented | +| `bl issue participant list ` | `GET /api/v2/issues/{issueIdOrKey}/participants` | ✅ Implemented | +| `bl issue shared-file list ` | `GET /api/v2/issues/{issueIdOrKey}/sharedFiles` | ✅ Implemented | +| `bl issue shared-file link ` | `POST /api/v2/issues/{issueIdOrKey}/sharedFiles` | ✅ Implemented | +| `bl issue shared-file unlink ` | `DELETE /api/v2/issues/{issueIdOrKey}/sharedFiles/{id}` | ✅ Implemented | ### Documents diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md index df860cc..ebbdd41 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -496,6 +496,51 @@ bl issue attachment list TEST-1 --json [2] log.txt (1024 bytes) ``` +## `bl issue attachment delete` + +課題の添付ファイルを削除します。 + +```bash +bl issue attachment delete TEST-1 1 +bl issue attachment delete TEST-1 1 --json +``` + +## `bl issue participant list` + +課題の参加者一覧を取得します。 + +```bash +bl issue participant list TEST-1 +bl issue participant list TEST-1 --json +``` + +## `bl issue shared-file list` + +課題にリンクされた共有ファイルの一覧を取得します。 + +```bash +bl issue shared-file list TEST-1 +bl issue shared-file list TEST-1 --json +``` + +## `bl issue shared-file link` + +課題に共有ファイルをリンクします。 + +```bash +bl issue shared-file link TEST-1 --shared-file-id 1 +bl issue shared-file link TEST-1 --shared-file-id 1 --shared-file-id 2 --json +``` + +## `bl issue shared-file unlink` + +課題から共有ファイルのリンクを解除します。 + +```bash +bl issue shared-file unlink TEST-1 1 +bl issue shared-file unlink TEST-1 1 --json +``` + ## `bl wiki list` プロジェクトの Wiki ページを一覧表示します。 @@ -863,11 +908,11 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `bl issue comment notification add ` | `POST /api/v2/issues/{issueIdOrKey}/comments/{commentId}/notifications` | ✅ 実装済み | | `bl issue attachment list ` | `GET /api/v2/issues/{issueIdOrKey}/attachments` | ✅ 実装済み | | `bl issue attachment get ` | `GET /api/v2/issues/{issueIdOrKey}/attachments/{attachmentId}` | 計画中 | -| `bl issue attachment delete ` | `DELETE /api/v2/issues/{issueIdOrKey}/attachments/{attachmentId}` | 計画中 | -| `bl issue participant list ` | `GET /api/v2/issues/{issueIdOrKey}/participants` | 計画中 | -| `bl issue shared-file list ` | `GET /api/v2/issues/{issueIdOrKey}/sharedFiles` | 計画中 | -| `bl issue shared-file link ` | `POST /api/v2/issues/{issueIdOrKey}/sharedFiles` | 計画中 | -| `bl issue shared-file unlink ` | `DELETE /api/v2/issues/{issueIdOrKey}/sharedFiles/{id}` | 計画中 | +| `bl issue attachment delete ` | `DELETE /api/v2/issues/{issueIdOrKey}/attachments/{attachmentId}` | ✅ 実装済み | +| `bl issue participant list ` | `GET /api/v2/issues/{issueIdOrKey}/participants` | ✅ 実装済み | +| `bl issue shared-file list ` | `GET /api/v2/issues/{issueIdOrKey}/sharedFiles` | ✅ 実装済み | +| `bl issue shared-file link ` | `POST /api/v2/issues/{issueIdOrKey}/sharedFiles` | ✅ 実装済み | +| `bl issue shared-file unlink ` | `DELETE /api/v2/issues/{issueIdOrKey}/sharedFiles/{id}` | ✅ 実装済み | ### Documents