From 7cf37b449c8d7c0db33bc79842ddc84975eb6007 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 10:41:59 +0900 Subject: [PATCH 1/4] docs: add patterns from issue-54 implementation --- docs/TESTING.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/TESTING.md b/docs/TESTING.md index 22fbff8..fea58fa 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -149,6 +149,38 @@ fn add_with_text_output_succeeds() { /* json: false */ } fn add_with_json_output_succeeds() { /* json: true */ } ``` +## ANSI color codes in format row tests + +`owo_colors` wraps values in ANSI escape sequences. Asserting on bracket-wrapped strings like +`"[1]"` fails because the brackets are not adjacent to the digits in the raw string: + +```rust +// ❌ fails — ANSI codes are inserted between "[" and "1" and "]" +assert!(row.contains("[1]")); + +// ✅ check the text content only +assert!(row.contains("1")); +assert!(row.contains("Fix bug")); +``` + +This applies to any `format_*_row` function that uses `.cyan()`, `.bold()`, or similar. + +## `#[cfg_attr(test, derive(Debug))]` for `try_new` Args structs + +When an Args struct uses `try_new` and tests call `.unwrap_err()`, Rust requires `Debug` to format +the error. Add the conditional derive to avoid a compile error without bloating release builds: + +```rust +#[cfg_attr(test, derive(Debug))] +pub struct MyUpdateArgs { ... } + +#[test] +fn try_new_fails_when_no_fields_provided() { + let err = MyUpdateArgs::try_new(...).unwrap_err(); // needs Debug + assert!(err.to_string().contains("at least one")); +} +``` + ## Rules - **Never** call `BacklogClient::from_config()` in tests — it requires real credentials on disk. From 70a58bac9abb7d224f5a1b87f3ddbc66df4f2d2f Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 11:12:32 +0900 Subject: [PATCH 2/4] feat: add bl team add / update / delete / icon commands --- .cspell/dicts/project.txt | 1 + src/api/mod.rs | 28 ++++ src/api/team.rs | 97 ++++++++++++ src/cmd/team/add.rs | 117 ++++++++++++++ src/cmd/team/delete.rs | 104 +++++++++++++ src/cmd/team/icon.rs | 99 ++++++++++++ src/cmd/team/mod.rs | 8 + src/cmd/team/update.rs | 145 ++++++++++++++++++ src/main.rs | 66 +++++++- website/docs/commands.md | 74 ++++++++- .../current/commands.md | 74 ++++++++- 11 files changed, 804 insertions(+), 9 deletions(-) create mode 100644 src/cmd/team/add.rs create mode 100644 src/cmd/team/delete.rs create mode 100644 src/cmd/team/icon.rs create mode 100644 src/cmd/team/update.rs diff --git a/.cspell/dicts/project.txt b/.cspell/dicts/project.txt index d9aadc3..e80891e 100644 --- a/.cspell/dicts/project.txt +++ b/.cspell/dicts/project.txt @@ -34,3 +34,4 @@ NEWKEY burndown splitn hexdigit +PRRT diff --git a/src/api/mod.rs b/src/api/mod.rs index 366921a..5ce93e0 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -524,6 +524,18 @@ pub trait BacklogApi { fn delete_project_team(&self, _key: &str, _team_id: u64) -> Result { unimplemented!() } + fn create_team(&self, _params: &[(String, String)]) -> Result { + unimplemented!() + } + fn update_team(&self, _team_id: u64, _params: &[(String, String)]) -> Result { + unimplemented!() + } + fn delete_team(&self, _team_id: u64) -> Result { + unimplemented!() + } + fn download_team_icon(&self, _team_id: u64) -> Result<(Vec, String)> { + unimplemented!() + } fn get_user_activities( &self, _user_id: u64, @@ -1286,6 +1298,22 @@ impl BacklogApi for BacklogClient { self.delete_project_team(key, team_id) } + fn create_team(&self, params: &[(String, String)]) -> Result { + self.create_team(params) + } + + fn update_team(&self, team_id: u64, params: &[(String, String)]) -> Result { + self.update_team(team_id, params) + } + + fn delete_team(&self, team_id: u64) -> Result { + self.delete_team(team_id) + } + + fn download_team_icon(&self, team_id: u64) -> Result<(Vec, String)> { + self.download_team_icon(team_id) + } + fn get_user_activities( &self, user_id: u64, diff --git a/src/api/team.rs b/src/api/team.rs index b16e844..24475ce 100644 --- a/src/api/team.rs +++ b/src/api/team.rs @@ -91,6 +91,43 @@ impl BacklogClient { ) }) } + + pub fn create_team(&self, params: &[(String, String)]) -> Result { + let value = self.post_form("/teams", params)?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize create team response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn update_team(&self, team_id: u64, params: &[(String, String)]) -> Result { + let value = self.patch_form(&format!("/teams/{team_id}"), params)?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize update team response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn delete_team(&self, team_id: u64) -> Result { + let value = self.delete_req(&format!("/teams/{team_id}"))?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize delete team response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn download_team_icon(&self, team_id: u64) -> Result<(Vec, String)> { + self.download(&format!("/teams/{team_id}/icon")) + } } #[cfg(test)] @@ -271,4 +308,64 @@ mod tests { let err = client.delete_project_team("TEST", 1).unwrap_err(); assert!(err.to_string().contains("No team")); } + + #[test] + fn create_team_returns_parsed_struct() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/teams"); + then.status(201).json_body(team_json()); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let params = vec![("name".to_string(), "dev-team".to_string())]; + let team = client.create_team(¶ms).unwrap(); + assert_eq!(team.id, 1); + assert_eq!(team.name, "dev-team"); + } + + #[test] + fn update_team_returns_parsed_struct() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::PATCH).path("/teams/1"); + then.status(200).json_body(team_json()); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let params = vec![("name".to_string(), "dev-team".to_string())]; + let team = client.update_team(1, ¶ms).unwrap(); + assert_eq!(team.id, 1); + assert_eq!(team.name, "dev-team"); + } + + #[test] + fn delete_team_returns_parsed_struct() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE).path("/teams/1"); + then.status(200).json_body(team_json()); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let team = client.delete_team(1).unwrap(); + assert_eq!(team.id, 1); + assert_eq!(team.name, "dev-team"); + } + + #[test] + fn download_team_icon_returns_bytes() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/teams/1/icon"); + then.status(200) + .header("Content-Disposition", "attachment; filename=\"icon.png\"") + .body(b"png-data"); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let (bytes, filename) = client.download_team_icon(1).unwrap(); + assert_eq!(bytes, b"png-data"); + assert_eq!(filename, "icon.png"); + } } diff --git a/src/cmd/team/add.rs b/src/cmd/team/add.rs new file mode 100644 index 0000000..0486e18 --- /dev/null +++ b/src/cmd/team/add.rs @@ -0,0 +1,117 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient, team::Team}; + +pub struct TeamAddArgs { + name: String, + members: Vec, + json: bool, +} + +impl TeamAddArgs { + pub fn new(name: String, members: Vec, json: bool) -> Self { + Self { + name, + members, + json, + } + } +} + +pub fn add(args: &TeamAddArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + add_with(args, &client) +} + +pub fn add_with(args: &TeamAddArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params = vec![("name".to_string(), args.name.clone())]; + for id in &args.members { + params.push(("members[]".to_string(), id.to_string())); + } + let team = api.create_team(¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&team).context("Failed to serialize JSON")? + ); + } else { + println!("Created: {}", format_team_row(&team)); + } + Ok(()) +} + +fn format_team_row(t: &Team) -> String { + format!("[{}] {} ({} members)", t.id, t.name, t.members.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use std::collections::BTreeMap; + + use crate::api::team::{Team, TeamMember}; + + struct MockApi { + team: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn create_team(&self, _params: &[(String, String)]) -> anyhow::Result { + self.team.clone().ok_or_else(|| anyhow!("create failed")) + } + } + + fn sample_member() -> TeamMember { + TeamMember { + id: 2, + user_id: Some("dev".to_string()), + name: "Developer".to_string(), + role_type: 2, + lang: None, + mail_address: None, + last_login_time: None, + extra: BTreeMap::new(), + } + } + + fn sample_team() -> Team { + Team { + id: 1, + name: "dev-team".to_string(), + members: vec![sample_member()], + display_order: None, + created: "2024-01-01T00:00:00Z".to_string(), + updated: "2024-01-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> TeamAddArgs { + TeamAddArgs::new("dev-team".to_string(), vec![2], json) + } + + #[test] + fn add_with_text_output_succeeds() { + let api = MockApi { + team: Some(sample_team()), + }; + assert!(add_with(&args(false), &api).is_ok()); + } + + #[test] + fn add_with_json_output_succeeds() { + let api = MockApi { + team: Some(sample_team()), + }; + assert!(add_with(&args(true), &api).is_ok()); + } + + #[test] + fn add_with_propagates_api_error() { + let api = MockApi { team: None }; + let err = add_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("create failed")); + } +} diff --git a/src/cmd/team/delete.rs b/src/cmd/team/delete.rs new file mode 100644 index 0000000..593b912 --- /dev/null +++ b/src/cmd/team/delete.rs @@ -0,0 +1,104 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct TeamDeleteArgs { + id: u64, + json: bool, +} + +impl TeamDeleteArgs { + pub fn new(id: u64, json: bool) -> Self { + Self { id, json } + } +} + +pub fn delete(args: &TeamDeleteArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + delete_with(args, &client) +} + +pub fn delete_with(args: &TeamDeleteArgs, api: &dyn BacklogApi) -> Result<()> { + let team = api.delete_team(args.id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&team).context("Failed to serialize JSON")? + ); + } else { + println!("Deleted: [{}] {}", team.id, team.name); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use std::collections::BTreeMap; + + use crate::api::team::{Team, TeamMember}; + + struct MockApi { + team: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn delete_team(&self, _team_id: u64) -> anyhow::Result { + self.team.clone().ok_or_else(|| anyhow!("delete failed")) + } + } + + fn sample_member() -> TeamMember { + TeamMember { + id: 2, + user_id: Some("dev".to_string()), + name: "Developer".to_string(), + role_type: 2, + lang: None, + mail_address: None, + last_login_time: None, + extra: BTreeMap::new(), + } + } + + fn sample_team() -> Team { + Team { + id: 1, + name: "dev-team".to_string(), + members: vec![sample_member()], + display_order: None, + created: "2024-01-01T00:00:00Z".to_string(), + updated: "2024-01-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> TeamDeleteArgs { + TeamDeleteArgs::new(1, json) + } + + #[test] + fn delete_with_text_output_succeeds() { + let api = MockApi { + team: Some(sample_team()), + }; + assert!(delete_with(&args(false), &api).is_ok()); + } + + #[test] + fn delete_with_json_output_succeeds() { + let api = MockApi { + team: Some(sample_team()), + }; + assert!(delete_with(&args(true), &api).is_ok()); + } + + #[test] + fn delete_with_propagates_api_error() { + let api = MockApi { team: None }; + let err = delete_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("delete failed")); + } +} diff --git a/src/cmd/team/icon.rs b/src/cmd/team/icon.rs new file mode 100644 index 0000000..7b63713 --- /dev/null +++ b/src/cmd/team/icon.rs @@ -0,0 +1,99 @@ +use anstream::println; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct TeamIconArgs { + id: u64, + output: Option, +} + +impl TeamIconArgs { + pub fn new(id: u64, output: Option) -> Self { + Self { id, output } + } +} + +pub fn icon(args: &TeamIconArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + icon_with(args, &client) +} + +pub fn icon_with(args: &TeamIconArgs, api: &dyn BacklogApi) -> Result<()> { + let (bytes, filename) = api.download_team_icon(args.id)?; + let path = args.output.clone().unwrap_or_else(|| { + let base = std::path::Path::new(&filename) + .file_name() + .unwrap_or(std::ffi::OsStr::new("icon")); + PathBuf::from(base) + }); + std::fs::write(&path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?; + println!("Saved: {} ({} bytes)", path.display(), bytes.len()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use tempfile::tempdir; + + struct MockApi { + result: Option<(Vec, String)>, + } + + impl crate::api::BacklogApi for MockApi { + fn download_team_icon(&self, _team_id: u64) -> anyhow::Result<(Vec, String)> { + self.result + .clone() + .ok_or_else(|| anyhow!("download failed")) + } + } + + fn args(output: Option) -> TeamIconArgs { + TeamIconArgs::new(1, output) + } + + #[test] + fn icon_with_saves_file_to_specified_path() { + let dir = tempdir().unwrap(); + let path = dir.path().join("out.png"); + let api = MockApi { + result: Some((b"png-data".to_vec(), "icon.png".to_string())), + }; + assert!(icon_with(&args(Some(path.clone())), &api).is_ok()); + assert_eq!(std::fs::read(&path).unwrap(), b"png-data"); + } + + #[test] + fn icon_with_saves_file_to_default_filename() { + let dir = tempdir().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + + struct DirGuard(std::path::PathBuf); + impl Drop for DirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + let _guard = DirGuard(original_dir); + + let api = MockApi { + result: Some((b"png-data".to_vec(), "icon.png".to_string())), + }; + assert!(icon_with(&args(None), &api).is_ok()); + assert_eq!( + std::fs::read(dir.path().join("icon.png")).unwrap(), + b"png-data" + ); + } + + #[test] + fn icon_with_propagates_api_error() { + let api = MockApi { result: None }; + let err = icon_with(&args(None), &api).unwrap_err(); + assert!(err.to_string().contains("download failed")); + } +} diff --git a/src/cmd/team/mod.rs b/src/cmd/team/mod.rs index 5e4ee27..114a8a2 100644 --- a/src/cmd/team/mod.rs +++ b/src/cmd/team/mod.rs @@ -1,5 +1,13 @@ +mod add; +mod delete; +mod icon; mod list; mod show; +mod update; +pub use add::{TeamAddArgs, add}; +pub use delete::{TeamDeleteArgs, delete}; +pub use icon::{TeamIconArgs, icon}; pub use list::{TeamListArgs, list}; pub use show::{TeamShowArgs, show}; +pub use update::{TeamUpdateArgs, update}; diff --git a/src/cmd/team/update.rs b/src/cmd/team/update.rs new file mode 100644 index 0000000..b41eb1a --- /dev/null +++ b/src/cmd/team/update.rs @@ -0,0 +1,145 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +#[cfg_attr(test, derive(Debug))] +pub struct TeamUpdateArgs { + id: u64, + name: Option, + members: Option>, + json: bool, +} + +impl TeamUpdateArgs { + pub fn try_new( + id: u64, + name: Option, + members: Option>, + json: bool, + ) -> anyhow::Result { + if name.is_none() && members.is_none() { + anyhow::bail!("at least one of --name or --member must be provided"); + } + Ok(Self { + id, + name, + members, + json, + }) + } +} + +pub fn update(args: &TeamUpdateArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + update_with(args, &client) +} + +pub fn update_with(args: &TeamUpdateArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = vec![]; + if let Some(ref name) = args.name { + params.push(("name".to_string(), name.clone())); + } + if let Some(ref members) = args.members { + for id in members { + params.push(("members[]".to_string(), id.to_string())); + } + } + let team = api.update_team(args.id, ¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&team).context("Failed to serialize JSON")? + ); + } else { + println!("Updated: [{}] {}", team.id, team.name); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use std::collections::BTreeMap; + + use crate::api::team::{Team, TeamMember}; + + struct MockApi { + team: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn update_team(&self, _team_id: u64, _params: &[(String, String)]) -> anyhow::Result { + self.team.clone().ok_or_else(|| anyhow!("update failed")) + } + } + + fn sample_member() -> TeamMember { + TeamMember { + id: 2, + user_id: Some("dev".to_string()), + name: "Developer".to_string(), + role_type: 2, + lang: None, + mail_address: None, + last_login_time: None, + extra: BTreeMap::new(), + } + } + + fn sample_team() -> Team { + Team { + id: 1, + name: "dev-team".to_string(), + members: vec![sample_member()], + display_order: None, + created: "2024-01-01T00:00:00Z".to_string(), + updated: "2024-01-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> TeamUpdateArgs { + TeamUpdateArgs::try_new(1, Some("dev-team".to_string()), None, json).unwrap() + } + + #[test] + fn update_with_text_output_succeeds() { + let api = MockApi { + team: Some(sample_team()), + }; + assert!(update_with(&args(false), &api).is_ok()); + } + + #[test] + fn update_with_json_output_succeeds() { + let api = MockApi { + team: Some(sample_team()), + }; + assert!(update_with(&args(true), &api).is_ok()); + } + + #[test] + fn update_with_propagates_api_error() { + let api = MockApi { team: None }; + let err = update_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("update failed")); + } + + #[test] + fn try_new_fails_when_no_fields_provided() { + let err = TeamUpdateArgs::try_new(1, None, None, false).unwrap_err(); + assert!(err.to_string().contains("at least one")); + } + + #[test] + fn try_new_succeeds_with_name_only() { + assert!(TeamUpdateArgs::try_new(1, Some("new-name".to_string()), None, false).is_ok()); + } + + #[test] + fn try_new_succeeds_with_members_only() { + assert!(TeamUpdateArgs::try_new(1, None, Some(vec![2, 3]), false).is_ok()); + } +} diff --git a/src/main.rs b/src/main.rs index 9924fd0..c54a65e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,7 +79,9 @@ use cmd::space::{ SpaceShowArgs, SpaceUpdateNotificationArgs, }; use cmd::star::{StarAddArgs, StarDeleteArgs}; -use cmd::team::{TeamListArgs, TeamShowArgs}; +use cmd::team::{ + TeamAddArgs, TeamDeleteArgs, TeamIconArgs, TeamListArgs, TeamShowArgs, TeamUpdateArgs, +}; use cmd::user::star::{UserStarCountArgs, UserStarListArgs}; use cmd::user::{ UserActivitiesArgs, UserAddArgs, UserDeleteArgs, UserListArgs, UserRecentlyViewedArgs, @@ -1864,6 +1866,48 @@ enum TeamCommands { #[arg(long)] json: bool, }, + /// Create a new team + Add { + /// Team name + #[arg(long)] + name: String, + /// Member user IDs to add (repeatable) + #[arg(long = "member")] + members: Vec, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Update a team + Update { + /// Team numeric ID + id: u64, + /// New team name + #[arg(long)] + name: Option, + /// Replace member list with these user IDs (repeatable) + #[arg(long = "member")] + members: Vec, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Delete a team + Delete { + /// Team numeric ID + id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Download team icon + Icon { + /// Team numeric ID + id: u64, + /// Output file path (default: server-provided filename) + #[arg(long, short = 'o')] + output: Option, + }, } #[derive(Subcommand)] @@ -3306,6 +3350,26 @@ fn run() -> Result<()> { offset, )?), TeamCommands::Show { id, json } => cmd::team::show(&TeamShowArgs::new(id, json)), + TeamCommands::Add { + name, + members, + json, + } => cmd::team::add(&TeamAddArgs::new(name, members, json)), + TeamCommands::Update { + id, + name, + members, + json, + } => { + let members = if members.is_empty() { + None + } else { + Some(members) + }; + cmd::team::update(&TeamUpdateArgs::try_new(id, name, members, json)?) + } + TeamCommands::Delete { id, json } => cmd::team::delete(&TeamDeleteArgs::new(id, json)), + TeamCommands::Icon { id, output } => cmd::team::icon(&TeamIconArgs::new(id, output)), }, Commands::Notification { action } => match action { NotificationCommands::List { diff --git a/website/docs/commands.md b/website/docs/commands.md index 0324251..29a5a47 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -1492,6 +1492,72 @@ Members: [3] Engineer ``` +## `bl team add` + +Create a new team. +Depending on the Backlog space configuration, this command may return `403 Forbidden`. + +```bash +bl team add --name +bl team add --name --member --member +bl team add --name --json +``` + +Example output: + +```text +Created: [1] dev-team (0 members) +``` + +## `bl team update` + +Update a team. At least one of `--name` or `--member` must be provided. +Depending on the Backlog space configuration, this command may return `403 Forbidden`. + +```bash +bl team update --name +bl team update --member --member +bl team update --name --json +``` + +Example output: + +```text +Updated: [1] dev-team (3 members) +``` + +## `bl team delete` + +Delete a team. +Depending on the Backlog space configuration, this command may return `403 Forbidden`. + +```bash +bl team delete +bl team delete --json +``` + +Example output: + +```text +Deleted: [1] dev-team (3 members) +``` + +## `bl team icon` + +Download the team icon image. +Depending on the Backlog space configuration, this command may return `403 Forbidden`. + +```bash +bl team icon +bl team icon --output +``` + +Example output: + +```text +Saved: icon.png (10240 bytes) +``` + ## `bl user activities` Show recent activities of a specific user. @@ -2320,10 +2386,10 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | --- | --- | --- | | `bl team list` | `GET /api/v2/teams` | ✅ Implemented | | `bl team show ` | `GET /api/v2/teams/{teamId}` | ✅ Implemented | -| `bl team add` | `POST /api/v2/teams` | Planned | -| `bl team update ` | `PATCH /api/v2/teams/{teamId}` | Planned | -| `bl team delete ` | `DELETE /api/v2/teams/{teamId}` | Planned | -| — | `GET /api/v2/teams/{teamId}/icon` | Planned | +| `bl team add` | `POST /api/v2/teams` | ✅ Implemented | +| `bl team update ` | `PATCH /api/v2/teams/{teamId}` | ✅ Implemented | +| `bl team delete ` | `DELETE /api/v2/teams/{teamId}` | ✅ Implemented | +| `bl team icon ` | `GET /api/v2/teams/{teamId}/icon` | ✅ Implemented | ### System 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 8a76a49..820a60d 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -1493,6 +1493,72 @@ Members: [3] Engineer ``` +## `bl team add` + +チームを作成します。 +スペースの設定によっては `403 Forbidden` が返ることがあります。 + +```bash +bl team add --name +bl team add --name --member --member +bl team add --name --json +``` + +出力例: + +```text +Created: [1] dev-team (0 members) +``` + +## `bl team update` + +チームを更新します。`--name` または `--member` のいずれか1つ以上が必要です。 +スペースの設定によっては `403 Forbidden` が返ることがあります。 + +```bash +bl team update --name +bl team update --member --member +bl team update --name --json +``` + +出力例: + +```text +Updated: [1] dev-team (3 members) +``` + +## `bl team delete` + +チームを削除します。 +スペースの設定によっては `403 Forbidden` が返ることがあります。 + +```bash +bl team delete +bl team delete --json +``` + +出力例: + +```text +Deleted: [1] dev-team (3 members) +``` + +## `bl team icon` + +チームアイコン画像をダウンロードします。 +スペースの設定によっては `403 Forbidden` が返ることがあります。 + +```bash +bl team icon +bl team icon --output +``` + +出力例: + +```text +Saved: icon.png (10240 bytes) +``` + ## `bl user activities` 特定のユーザーの最近のアクティビティを表示します。 @@ -2322,10 +2388,10 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | --- | --- | --- | | `bl team list` | `GET /api/v2/teams` | ✅ 実装済み | | `bl team show ` | `GET /api/v2/teams/{teamId}` | ✅ 実装済み | -| `bl team add` | `POST /api/v2/teams` | 計画中 | -| `bl team update ` | `PATCH /api/v2/teams/{teamId}` | 計画中 | -| `bl team delete ` | `DELETE /api/v2/teams/{teamId}` | 計画中 | -| — | `GET /api/v2/teams/{teamId}/icon` | 計画中 | +| `bl team add` | `POST /api/v2/teams` | ✅ 実装済み | +| `bl team update ` | `PATCH /api/v2/teams/{teamId}` | ✅ 実装済み | +| `bl team delete ` | `DELETE /api/v2/teams/{teamId}` | ✅ 実装済み | +| `bl team icon ` | `GET /api/v2/teams/{teamId}/icon` | ✅ 実装済み | ### System From 8a416e858e21d7eadf20dada29f741bccbdca8cd Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 11:22:01 +0900 Subject: [PATCH 3/4] refactor: consolidate format_team_row to mod.rs and add member count to delete/update output Addresses review comments: deduplication of format_team_row across add/list/delete/update, consistent member count in text output, and accurate Debug requirement explanation in TESTING.md --- docs/TESTING.md | 8 +++++--- src/cmd/team/add.rs | 7 ++----- src/cmd/team/delete.rs | 3 ++- src/cmd/team/list.rs | 7 ++----- src/cmd/team/mod.rs | 4 ++++ src/cmd/team/update.rs | 3 ++- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index fea58fa..6bef990 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -167,8 +167,10 @@ This applies to any `format_*_row` function that uses `.cyan()`, `.bold()`, or s ## `#[cfg_attr(test, derive(Debug))]` for `try_new` Args structs -When an Args struct uses `try_new` and tests call `.unwrap_err()`, Rust requires `Debug` to format -the error. Add the conditional derive to avoid a compile error without bloating release builds: +`Result::unwrap_err()` requires `T: Debug` so it can format the *Ok value* when the result is +unexpectedly `Ok`. When an Args struct using `try_new` is the `T` in `Result`, tests that +call `.unwrap_err()` will fail to compile without `Debug`. Add the conditional derive to avoid +a compile error without bloating release builds: ```rust #[cfg_attr(test, derive(Debug))] @@ -176,7 +178,7 @@ pub struct MyUpdateArgs { ... } #[test] fn try_new_fails_when_no_fields_provided() { - let err = MyUpdateArgs::try_new(...).unwrap_err(); // needs Debug + let err = MyUpdateArgs::try_new(...).unwrap_err(); // T=MyUpdateArgs needs Debug assert!(err.to_string().contains("at least one")); } ``` diff --git a/src/cmd/team/add.rs b/src/cmd/team/add.rs index 0486e18..a171291 100644 --- a/src/cmd/team/add.rs +++ b/src/cmd/team/add.rs @@ -1,7 +1,8 @@ use anstream::println; use anyhow::{Context, Result}; -use crate::api::{BacklogApi, BacklogClient, team::Team}; +use super::format_team_row; +use crate::api::{BacklogApi, BacklogClient}; pub struct TeamAddArgs { name: String, @@ -41,10 +42,6 @@ pub fn add_with(args: &TeamAddArgs, api: &dyn BacklogApi) -> Result<()> { Ok(()) } -fn format_team_row(t: &Team) -> String { - format!("[{}] {} ({} members)", t.id, t.name, t.members.len()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/cmd/team/delete.rs b/src/cmd/team/delete.rs index 593b912..010c53d 100644 --- a/src/cmd/team/delete.rs +++ b/src/cmd/team/delete.rs @@ -1,6 +1,7 @@ use anstream::println; use anyhow::{Context, Result}; +use super::format_team_row; use crate::api::{BacklogApi, BacklogClient}; pub struct TeamDeleteArgs { @@ -27,7 +28,7 @@ pub fn delete_with(args: &TeamDeleteArgs, api: &dyn BacklogApi) -> Result<()> { serde_json::to_string_pretty(&team).context("Failed to serialize JSON")? ); } else { - println!("Deleted: [{}] {}", team.id, team.name); + println!("Deleted: {}", format_team_row(&team)); } Ok(()) } diff --git a/src/cmd/team/list.rs b/src/cmd/team/list.rs index 2d9d878..938049f 100644 --- a/src/cmd/team/list.rs +++ b/src/cmd/team/list.rs @@ -1,7 +1,8 @@ use anstream::println; use anyhow::{Context, Result}; -use crate::api::{BacklogApi, BacklogClient, team::Team}; +use super::format_team_row; +use crate::api::{BacklogApi, BacklogClient}; pub struct TeamListArgs { json: bool, @@ -56,10 +57,6 @@ pub fn list_with(args: &TeamListArgs, api: &dyn BacklogApi) -> Result<()> { Ok(()) } -fn format_team_row(t: &Team) -> String { - format!("[{}] {} ({} members)", t.id, t.name, t.members.len()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/cmd/team/mod.rs b/src/cmd/team/mod.rs index 114a8a2..14865bb 100644 --- a/src/cmd/team/mod.rs +++ b/src/cmd/team/mod.rs @@ -11,3 +11,7 @@ pub use icon::{TeamIconArgs, icon}; pub use list::{TeamListArgs, list}; pub use show::{TeamShowArgs, show}; pub use update::{TeamUpdateArgs, update}; + +pub(crate) fn format_team_row(t: &crate::api::team::Team) -> String { + format!("[{}] {} ({} members)", t.id, t.name, t.members.len()) +} diff --git a/src/cmd/team/update.rs b/src/cmd/team/update.rs index b41eb1a..b60a1e6 100644 --- a/src/cmd/team/update.rs +++ b/src/cmd/team/update.rs @@ -1,6 +1,7 @@ use anstream::println; use anyhow::{Context, Result}; +use super::format_team_row; use crate::api::{BacklogApi, BacklogClient}; #[cfg_attr(test, derive(Debug))] @@ -52,7 +53,7 @@ pub fn update_with(args: &TeamUpdateArgs, api: &dyn BacklogApi) -> Result<()> { serde_json::to_string_pretty(&team).context("Failed to serialize JSON")? ); } else { - println!("Updated: [{}] {}", team.id, team.name); + println!("Updated: {}", format_team_row(&team)); } Ok(()) } From 190696547911a0e5ddd5cf67fa8468dcfa18c2b4 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 12:58:12 +0900 Subject: [PATCH 4/4] fix: reject empty members list in TeamUpdateArgs::try_new when name is absent Addresses review comment: treat Some(vec![]) as equivalent to None for members validation --- src/cmd/team/update.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/cmd/team/update.rs b/src/cmd/team/update.rs index b60a1e6..9516350 100644 --- a/src/cmd/team/update.rs +++ b/src/cmd/team/update.rs @@ -19,7 +19,8 @@ impl TeamUpdateArgs { members: Option>, json: bool, ) -> anyhow::Result { - if name.is_none() && members.is_none() { + let no_members = members.as_ref().is_none_or(|m| m.is_empty()); + if name.is_none() && no_members { anyhow::bail!("at least one of --name or --member must be provided"); } Ok(Self { @@ -143,4 +144,10 @@ mod tests { fn try_new_succeeds_with_members_only() { assert!(TeamUpdateArgs::try_new(1, None, Some(vec![2, 3]), false).is_ok()); } + + #[test] + fn try_new_fails_when_members_is_empty_and_name_absent() { + let err = TeamUpdateArgs::try_new(1, None, Some(vec![]), false).unwrap_err(); + assert!(err.to_string().contains("at least one")); + } }