From 7301843d6c93ea38dd17d5989613f25775911def Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Fri, 20 Mar 2026 13:59:17 +0900 Subject: [PATCH 1/2] feat: bl project category add / update / delete --- src/api/mod.rs | 31 +++ src/api/project.rs | 43 ++++ src/cmd/project/category.rs | 212 +++++++++++++++++- src/main.rs | 68 +++++- website/docs/commands.md | 51 ++++- .../current/commands.md | 51 ++++- 6 files changed, 437 insertions(+), 19 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 1a04c2b..b698a85 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -157,6 +157,20 @@ pub trait BacklogApi { fn get_project_categories(&self, _key: &str) -> Result> { unimplemented!() } + fn add_project_category(&self, _key: &str, _name: &str) -> Result { + unimplemented!() + } + fn update_project_category( + &self, + _key: &str, + _category_id: u64, + _name: &str, + ) -> Result { + unimplemented!() + } + fn delete_project_category(&self, _key: &str, _category_id: u64) -> Result { + unimplemented!() + } fn get_project_versions(&self, _key: &str) -> Result> { unimplemented!() } @@ -513,6 +527,23 @@ impl BacklogApi for BacklogClient { self.get_project_categories(key) } + fn add_project_category(&self, key: &str, name: &str) -> Result { + self.add_project_category(key, name) + } + + fn update_project_category( + &self, + key: &str, + category_id: u64, + name: &str, + ) -> Result { + self.update_project_category(key, category_id, name) + } + + fn delete_project_category(&self, key: &str, category_id: u64) -> Result { + self.delete_project_category(key, category_id) + } + fn get_project_versions(&self, key: &str) -> Result> { self.get_project_versions(key) } diff --git a/src/api/project.rs b/src/api/project.rs index 955c551..010276f 100644 --- a/src/api/project.rs +++ b/src/api/project.rs @@ -246,6 +246,49 @@ impl BacklogClient { }) } + pub fn add_project_category(&self, key: &str, name: &str) -> Result { + let params = vec![("name".to_string(), name.to_string())]; + let value = self.post_form(&format!("/projects/{}/categories", key), ¶ms)?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize add project category response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn update_project_category( + &self, + key: &str, + category_id: u64, + name: &str, + ) -> Result { + let params = vec![("name".to_string(), name.to_string())]; + let value = self.patch_form( + &format!("/projects/{}/categories/{}", key, category_id), + ¶ms, + )?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize update project category response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn delete_project_category(&self, key: &str, category_id: u64) -> Result { + let value = self.delete_req(&format!("/projects/{}/categories/{}", key, category_id))?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize delete project category response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + pub fn get_project_versions(&self, key: &str) -> Result> { let value = self.get(&format!("/projects/{}/versions", key))?; serde_json::from_value(value.clone()).map_err(|e| { diff --git a/src/cmd/project/category.rs b/src/cmd/project/category.rs index 89731f1..c758856 100644 --- a/src/cmd/project/category.rs +++ b/src/cmd/project/category.rs @@ -14,6 +14,52 @@ impl ProjectCategoryListArgs { } } +pub struct ProjectCategoryAddArgs { + key: String, + name: String, + json: bool, +} + +impl ProjectCategoryAddArgs { + pub fn new(key: String, name: String, json: bool) -> Self { + Self { key, name, json } + } +} + +pub struct ProjectCategoryUpdateArgs { + key: String, + category_id: u64, + name: String, + json: bool, +} + +impl ProjectCategoryUpdateArgs { + pub fn new(key: String, category_id: u64, name: String, json: bool) -> Self { + Self { + key, + category_id, + name, + json, + } + } +} + +pub struct ProjectCategoryDeleteArgs { + key: String, + category_id: u64, + json: bool, +} + +impl ProjectCategoryDeleteArgs { + pub fn new(key: String, category_id: u64, json: bool) -> Self { + Self { + key, + category_id, + json, + } + } +} + pub fn list(args: &ProjectCategoryListArgs) -> Result<()> { let client = BacklogClient::from_config()?; list_with(args, &client) @@ -34,6 +80,60 @@ pub fn list_with(args: &ProjectCategoryListArgs, api: &dyn BacklogApi) -> Result Ok(()) } +pub fn add(args: &ProjectCategoryAddArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + add_with(args, &client) +} + +pub fn add_with(args: &ProjectCategoryAddArgs, api: &dyn BacklogApi) -> Result<()> { + let category = api.add_project_category(&args.key, &args.name)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&category).context("Failed to serialize JSON")? + ); + } else { + println!("Added: {}", format_category_row(&category)); + } + Ok(()) +} + +pub fn update(args: &ProjectCategoryUpdateArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + update_with(args, &client) +} + +pub fn update_with(args: &ProjectCategoryUpdateArgs, api: &dyn BacklogApi) -> Result<()> { + let category = api.update_project_category(&args.key, args.category_id, &args.name)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&category).context("Failed to serialize JSON")? + ); + } else { + println!("Updated: {}", format_category_row(&category)); + } + Ok(()) +} + +pub fn delete(args: &ProjectCategoryDeleteArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + delete_with(args, &client) +} + +pub fn delete_with(args: &ProjectCategoryDeleteArgs, api: &dyn BacklogApi) -> Result<()> { + let category = api.delete_project_category(&args.key, args.category_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&category).context("Failed to serialize JSON")? + ); + } else { + println!("Deleted: {}", format_category_row(&category)); + } + Ok(()) +} + fn format_category_row(c: &ProjectCategory) -> String { format!("[{}] {}", c.id, c.name) } @@ -44,14 +144,38 @@ mod tests { use anyhow::anyhow; struct MockApi { - categories: Option>, + list: Option>, + single: Option, + } + + fn mock(list: Option>, single: Option) -> MockApi { + MockApi { list, single } } impl crate::api::BacklogApi for MockApi { fn get_project_categories(&self, _key: &str) -> anyhow::Result> { - self.categories - .clone() - .ok_or_else(|| anyhow!("no categories")) + self.list.clone().ok_or_else(|| anyhow!("list failed")) + } + + fn add_project_category(&self, _key: &str, _name: &str) -> anyhow::Result { + self.single.clone().ok_or_else(|| anyhow!("add failed")) + } + + fn update_project_category( + &self, + _key: &str, + _category_id: u64, + _name: &str, + ) -> anyhow::Result { + self.single.clone().ok_or_else(|| anyhow!("update failed")) + } + + fn delete_project_category( + &self, + _key: &str, + _category_id: u64, + ) -> anyhow::Result { + self.single.clone().ok_or_else(|| anyhow!("delete failed")) } } @@ -72,9 +196,7 @@ mod tests { #[test] fn list_with_text_output_succeeds() { - let api = MockApi { - categories: Some(vec![sample_category()]), - }; + let api = mock(Some(vec![sample_category()]), None); assert!( list_with( &ProjectCategoryListArgs::new("TEST".to_string(), false), @@ -86,9 +208,7 @@ mod tests { #[test] fn list_with_json_output_succeeds() { - let api = MockApi { - categories: Some(vec![sample_category()]), - }; + let api = mock(Some(vec![sample_category()]), None); assert!( list_with( &ProjectCategoryListArgs::new("TEST".to_string(), true), @@ -100,12 +220,80 @@ mod tests { #[test] fn list_with_propagates_api_error() { - let api = MockApi { categories: None }; + let api = mock(None, None); let err = list_with( &ProjectCategoryListArgs::new("TEST".to_string(), false), &api, ) .unwrap_err(); - assert!(err.to_string().contains("no categories")); + assert!(err.to_string().contains("list failed")); + } + + #[test] + fn add_with_text_output_succeeds() { + let api = mock(None, Some(sample_category())); + let args = ProjectCategoryAddArgs::new("TEST".to_string(), "Dev".to_string(), false); + assert!(add_with(&args, &api).is_ok()); + } + + #[test] + fn add_with_json_output_succeeds() { + let api = mock(None, Some(sample_category())); + let args = ProjectCategoryAddArgs::new("TEST".to_string(), "Dev".to_string(), true); + assert!(add_with(&args, &api).is_ok()); + } + + #[test] + fn add_with_propagates_api_error() { + let api = mock(None, None); + let args = ProjectCategoryAddArgs::new("TEST".to_string(), "Dev".to_string(), false); + let err = add_with(&args, &api).unwrap_err(); + assert!(err.to_string().contains("add failed")); + } + + #[test] + fn update_with_text_output_succeeds() { + let api = mock(None, Some(sample_category())); + let args = + ProjectCategoryUpdateArgs::new("TEST".to_string(), 11, "Dev2".to_string(), false); + assert!(update_with(&args, &api).is_ok()); + } + + #[test] + fn update_with_json_output_succeeds() { + let api = mock(None, Some(sample_category())); + let args = ProjectCategoryUpdateArgs::new("TEST".to_string(), 11, "Dev2".to_string(), true); + assert!(update_with(&args, &api).is_ok()); + } + + #[test] + fn update_with_propagates_api_error() { + let api = mock(None, None); + let args = + ProjectCategoryUpdateArgs::new("TEST".to_string(), 11, "Dev2".to_string(), false); + let err = update_with(&args, &api).unwrap_err(); + assert!(err.to_string().contains("update failed")); + } + + #[test] + fn delete_with_text_output_succeeds() { + let api = mock(None, Some(sample_category())); + let args = ProjectCategoryDeleteArgs::new("TEST".to_string(), 11, false); + assert!(delete_with(&args, &api).is_ok()); + } + + #[test] + fn delete_with_json_output_succeeds() { + let api = mock(None, Some(sample_category())); + let args = ProjectCategoryDeleteArgs::new("TEST".to_string(), 11, true); + assert!(delete_with(&args, &api).is_ok()); + } + + #[test] + fn delete_with_propagates_api_error() { + let api = mock(None, None); + let args = ProjectCategoryDeleteArgs::new("TEST".to_string(), 11, false); + let err = delete_with(&args, &api).unwrap_err(); + assert!(err.to_string().contains("delete failed")); } } diff --git a/src/main.rs b/src/main.rs index 60a3106..ddd613d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,10 @@ use cmd::issue::{ use cmd::notification::{NotificationCountArgs, NotificationListArgs, NotificationReadArgs}; use cmd::priority::PriorityListArgs; use cmd::project::admin::{ProjectAdminAddArgs, ProjectAdminDeleteArgs, ProjectAdminListArgs}; -use cmd::project::category::ProjectCategoryListArgs; +use cmd::project::category::{ + ProjectCategoryAddArgs, ProjectCategoryDeleteArgs, ProjectCategoryListArgs, + ProjectCategoryUpdateArgs, +}; use cmd::project::issue_type::{ ProjectIssueTypeAddArgs, ProjectIssueTypeDeleteArgs, ProjectIssueTypeListArgs, ProjectIssueTypeUpdateArgs, @@ -605,6 +608,42 @@ enum ProjectCategoryCommands { #[arg(long)] json: bool, }, + /// Add a category to a project + Add { + /// Project ID or key + id_or_key: String, + /// Category name + #[arg(long)] + name: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Update a project category + Update { + /// Project ID or key + id_or_key: String, + /// Category ID to update + #[arg(long)] + category_id: u64, + /// New category name + #[arg(long)] + name: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Delete a project category + Delete { + /// Project ID or key + id_or_key: String, + /// Category ID to delete + #[arg(long)] + category_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -1671,6 +1710,33 @@ fn run() -> Result<()> { ProjectCategoryCommands::List { id_or_key, json } => { cmd::project::category::list(&ProjectCategoryListArgs::new(id_or_key, json)) } + ProjectCategoryCommands::Add { + id_or_key, + name, + json, + } => { + cmd::project::category::add(&ProjectCategoryAddArgs::new(id_or_key, name, json)) + } + ProjectCategoryCommands::Update { + id_or_key, + category_id, + name, + json, + } => cmd::project::category::update(&ProjectCategoryUpdateArgs::new( + id_or_key, + category_id, + name, + json, + )), + ProjectCategoryCommands::Delete { + id_or_key, + category_id, + json, + } => cmd::project::category::delete(&ProjectCategoryDeleteArgs::new( + id_or_key, + category_id, + json, + )), }, ProjectCommands::Version { action } => match action { ProjectVersionCommands::List { id_or_key, json } => { diff --git a/website/docs/commands.md b/website/docs/commands.md index be43652..8a79623 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -487,6 +487,51 @@ Example output: [12] Design ``` +## `bl project category add` + +Add a category to a project. + +```bash +bl project category add --name +bl project category add --name --json +``` + +Example output: + +```text +Added: [11] Development +``` + +## `bl project category update` + +Update a project category. + +```bash +bl project category update --category-id --name +bl project category update --category-id --name --json +``` + +Example output: + +```text +Updated: [11] Development +``` + +## `bl project category delete` + +Delete a project category. + +```bash +bl project category delete --category-id +bl project category delete --category-id --json +``` + +Example output: + +```text +Deleted: [11] Development +``` + ## `bl project version list` List versions (milestones) defined for a specific project. @@ -1383,9 +1428,9 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | `bl project issue-type update --issue-type-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/issueTypes/{id}` | ✅ Implemented | | `bl project issue-type delete --issue-type-id ` | `DELETE /api/v2/projects/{projectIdOrKey}/issueTypes/{id}` | ✅ Implemented | | `bl project category list ` | `GET /api/v2/projects/{projectIdOrKey}/categories` | ✅ Implemented | -| `bl project category add ` | `POST /api/v2/projects/{projectIdOrKey}/categories` | Planned | -| `bl project category update ` | `PATCH /api/v2/projects/{projectIdOrKey}/categories/{id}` | Planned | -| `bl project category delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/categories/{id}` | Planned | +| `bl project category add ` | `POST /api/v2/projects/{projectIdOrKey}/categories` | ✅ Implemented | +| `bl project category update --category-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/categories/{id}` | ✅ Implemented | +| `bl project category delete --category-id ` | `DELETE /api/v2/projects/{projectIdOrKey}/categories/{id}` | ✅ Implemented | | `bl project version list ` | `GET /api/v2/projects/{projectIdOrKey}/versions` | ✅ Implemented | | `bl project version add ` | `POST /api/v2/projects/{projectIdOrKey}/versions` | Planned | | `bl project version update ` | `PATCH /api/v2/projects/{projectIdOrKey}/versions/{id}` | Planned | 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 487b05c..23fd5cc 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -487,6 +487,51 @@ bl project category list --json [12] Design ``` +## `bl project category add` + +プロジェクトにカテゴリーを追加します。 + +```bash +bl project category add --name +bl project category add --name --json +``` + +出力例: + +```text +Added: [11] Development +``` + +## `bl project category update` + +プロジェクトのカテゴリーを更新します。 + +```bash +bl project category update --category-id --name +bl project category update --category-id --name --json +``` + +出力例: + +```text +Updated: [11] Development +``` + +## `bl project category delete` + +プロジェクトのカテゴリーを削除します。 + +```bash +bl project category delete --category-id +bl project category delete --category-id --json +``` + +出力例: + +```text +Deleted: [11] Development +``` + ## `bl project version list` 特定のプロジェクトのバージョン(マイルストーン)を一覧表示します。 @@ -1387,9 +1432,9 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `bl project issue-type update --issue-type-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/issueTypes/{id}` | ✅ 実装済み | | `bl project issue-type delete --issue-type-id ` | `DELETE /api/v2/projects/{projectIdOrKey}/issueTypes/{id}` | ✅ 実装済み | | `bl project category list ` | `GET /api/v2/projects/{projectIdOrKey}/categories` | ✅ 実装済み | -| `bl project category add ` | `POST /api/v2/projects/{projectIdOrKey}/categories` | 計画中 | -| `bl project category update ` | `PATCH /api/v2/projects/{projectIdOrKey}/categories/{id}` | 計画中 | -| `bl project category delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/categories/{id}` | 計画中 | +| `bl project category add ` | `POST /api/v2/projects/{projectIdOrKey}/categories` | ✅ 実装済み | +| `bl project category update --category-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/categories/{id}` | ✅ 実装済み | +| `bl project category delete --category-id ` | `DELETE /api/v2/projects/{projectIdOrKey}/categories/{id}` | ✅ 実装済み | | `bl project version list ` | `GET /api/v2/projects/{projectIdOrKey}/versions` | ✅ 実装済み | | `bl project version add ` | `POST /api/v2/projects/{projectIdOrKey}/versions` | 計画中 | | `bl project version update ` | `PATCH /api/v2/projects/{projectIdOrKey}/versions/{id}` | 計画中 | From 259d658f3eec0615fa1e6bf22be302a85e626d3a Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Fri, 20 Mar 2026 15:33:01 +0900 Subject: [PATCH 2/2] fix: add project_id field to ProjectCategory struct Addresses review comment: ProjectCategory is losing projectId --- src/api/project.rs | 2 ++ src/cmd/project/category.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/src/api/project.rs b/src/api/project.rs index 010276f..b0666e1 100644 --- a/src/api/project.rs +++ b/src/api/project.rs @@ -74,6 +74,7 @@ pub struct ProjectIssueType { #[serde(rename_all = "camelCase")] pub struct ProjectCategory { pub id: u64, + pub project_id: u64, pub name: String, pub display_order: u32, } @@ -715,6 +716,7 @@ mod tests { when.method(GET).path("/projects/TEST/categories"); then.status(200).json_body(json!([{ "id": 11, + "projectId": 1, "name": "Development", "displayOrder": 0 }])); diff --git a/src/cmd/project/category.rs b/src/cmd/project/category.rs index c758856..3ad8e01 100644 --- a/src/cmd/project/category.rs +++ b/src/cmd/project/category.rs @@ -182,6 +182,7 @@ mod tests { fn sample_category() -> ProjectCategory { ProjectCategory { id: 11, + project_id: 1, name: "Development".to_string(), display_order: 0, }