diff --git a/.cspell/dicts/project.txt b/.cspell/dicts/project.txt index a93f7e3..d9aadc3 100644 --- a/.cspell/dicts/project.txt +++ b/.cspell/dicts/project.txt @@ -33,3 +33,4 @@ MYPRJ NEWKEY burndown splitn +hexdigit diff --git a/src/api/mod.rs b/src/api/mod.rs index cc19299..4c3a27b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -101,6 +101,32 @@ pub trait BacklogApi { fn get_project_statuses(&self, _key: &str) -> Result> { unimplemented!() } + fn add_project_status(&self, _key: &str, _name: &str, _color: &str) -> Result { + unimplemented!() + } + fn update_project_status( + &self, + _key: &str, + _status_id: u64, + _params: &[(String, String)], + ) -> Result { + unimplemented!() + } + fn delete_project_status( + &self, + _key: &str, + _status_id: u64, + _substitute_status_id: u64, + ) -> Result { + unimplemented!() + } + fn reorder_project_statuses( + &self, + _key: &str, + _status_ids: &[u64], + ) -> Result> { + unimplemented!() + } fn get_project_issue_types(&self, _key: &str) -> Result> { unimplemented!() } @@ -398,6 +424,36 @@ impl BacklogApi for BacklogClient { self.get_project_statuses(key) } + fn add_project_status(&self, key: &str, name: &str, color: &str) -> Result { + self.add_project_status(key, name, color) + } + + fn update_project_status( + &self, + key: &str, + status_id: u64, + params: &[(String, String)], + ) -> Result { + self.update_project_status(key, status_id, params) + } + + fn delete_project_status( + &self, + key: &str, + status_id: u64, + substitute_status_id: u64, + ) -> Result { + self.delete_project_status(key, status_id, substitute_status_id) + } + + fn reorder_project_statuses( + &self, + key: &str, + status_ids: &[u64], + ) -> Result> { + self.reorder_project_statuses(key, status_ids) + } + fn get_project_issue_types(&self, key: &str) -> Result> { self.get_project_issue_types(key) } diff --git a/src/api/project.rs b/src/api/project.rs index 7437189..da7fb83 100644 --- a/src/api/project.rs +++ b/src/api/project.rs @@ -228,6 +228,83 @@ impl BacklogClient { }) } + pub fn add_project_status(&self, key: &str, name: &str, color: &str) -> Result { + let params = vec![ + ("name".to_string(), name.to_string()), + ("color".to_string(), color.to_string()), + ]; + let value = self.post_form(&format!("/projects/{}/statuses", key), ¶ms)?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize add project status response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn update_project_status( + &self, + key: &str, + status_id: u64, + params: &[(String, String)], + ) -> Result { + let value = + self.patch_form(&format!("/projects/{}/statuses/{}", key, status_id), params)?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize update project status response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn delete_project_status( + &self, + key: &str, + status_id: u64, + substitute_status_id: u64, + ) -> Result { + let params = vec![( + "substituteStatusId".to_string(), + substitute_status_id.to_string(), + )]; + let value = self.delete_form( + &format!("/projects/{}/statuses/{}", key, status_id), + ¶ms, + )?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize delete project status response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn reorder_project_statuses( + &self, + key: &str, + status_ids: &[u64], + ) -> Result> { + let params: Vec<(String, String)> = status_ids + .iter() + .map(|id| ("statusId[]".to_string(), id.to_string())) + .collect(); + let value = self.patch_form( + &format!("/projects/{}/statuses/updateDisplayOrder", key), + ¶ms, + )?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize reorder project statuses response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + pub fn add_project_user(&self, key: &str, user_id: u64) -> Result { let params = vec![("userId".to_string(), user_id.to_string())]; let value = self.post_form(&format!("/projects/{}/users", key), ¶ms)?; diff --git a/src/cmd/project/status.rs b/src/cmd/project/status.rs index a47a099..8ebdf61 100644 --- a/src/cmd/project/status.rs +++ b/src/cmd/project/status.rs @@ -34,6 +34,216 @@ pub fn list_with(args: &ProjectStatusListArgs, api: &dyn BacklogApi) -> Result<( Ok(()) } +#[cfg_attr(test, derive(Debug))] +pub struct ProjectStatusAddArgs { + key: String, + name: String, + color: String, + json: bool, +} + +impl ProjectStatusAddArgs { + pub fn try_new(key: String, name: String, color: String, json: bool) -> anyhow::Result { + validate_color(&color)?; + Ok(Self { + key, + name, + color, + json, + }) + } +} + +pub fn add(args: &ProjectStatusAddArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + add_with(args, &client) +} + +pub fn add_with(args: &ProjectStatusAddArgs, api: &dyn BacklogApi) -> Result<()> { + let status = api.add_project_status(&args.key, &args.name, &args.color)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&status).context("Failed to serialize JSON")? + ); + } else { + println!("Added: {}", format_status_row(&status)); + } + Ok(()) +} + +#[cfg_attr(test, derive(Debug))] +pub struct ProjectStatusUpdateArgs { + key: String, + status_id: u64, + name: Option, + color: Option, + json: bool, +} + +impl ProjectStatusUpdateArgs { + pub fn try_new( + key: String, + status_id: u64, + name: Option, + color: Option, + json: bool, + ) -> anyhow::Result { + if name.is_none() && color.is_none() { + return Err(anyhow::anyhow!( + "At least one of --name or --color must be specified for update" + )); + } + if let Some(c) = &color { + validate_color(c)?; + } + Ok(Self { + key, + status_id, + name, + color, + json, + }) + } +} + +pub fn update(args: &ProjectStatusUpdateArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + update_with(args, &client) +} + +pub fn update_with(args: &ProjectStatusUpdateArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + if let Some(name) = &args.name { + params.push(("name".to_string(), name.clone())); + } + if let Some(color) = &args.color { + params.push(("color".to_string(), color.clone())); + } + let status = api.update_project_status(&args.key, args.status_id, ¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&status).context("Failed to serialize JSON")? + ); + } else { + println!("Updated: {}", format_status_row(&status)); + } + Ok(()) +} + +#[cfg_attr(test, derive(Debug))] +pub struct ProjectStatusDeleteArgs { + key: String, + status_id: u64, + substitute_status_id: u64, + json: bool, +} + +impl ProjectStatusDeleteArgs { + pub fn try_new( + key: String, + status_id: u64, + substitute_status_id: u64, + json: bool, + ) -> anyhow::Result { + if status_id == substitute_status_id { + return Err(anyhow::anyhow!( + "--substitute-status-id must differ from --status-id" + )); + } + Ok(Self { + key, + status_id, + substitute_status_id, + json, + }) + } +} + +pub fn delete(args: &ProjectStatusDeleteArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + delete_with(args, &client) +} + +pub fn delete_with(args: &ProjectStatusDeleteArgs, api: &dyn BacklogApi) -> Result<()> { + let status = api.delete_project_status(&args.key, args.status_id, args.substitute_status_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&status).context("Failed to serialize JSON")? + ); + } else { + println!("Deleted: {}", format_status_row(&status)); + } + Ok(()) +} + +#[cfg_attr(test, derive(Debug))] +pub struct ProjectStatusReorderArgs { + key: String, + status_ids: Vec, + json: bool, +} + +impl ProjectStatusReorderArgs { + pub fn try_new(key: String, status_ids: Vec, json: bool) -> anyhow::Result { + if status_ids.is_empty() { + return Err(anyhow::anyhow!( + "At least one --status-id must be specified for reorder" + )); + } + let unique_count = status_ids + .iter() + .copied() + .collect::>() + .len(); + if unique_count != status_ids.len() { + return Err(anyhow::anyhow!( + "--status-id values for reorder must be unique" + )); + } + Ok(Self { + key, + status_ids, + json, + }) + } +} + +pub fn reorder(args: &ProjectStatusReorderArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + reorder_with(args, &client) +} + +pub fn reorder_with(args: &ProjectStatusReorderArgs, api: &dyn BacklogApi) -> Result<()> { + let statuses = api.reorder_project_statuses(&args.key, &args.status_ids)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&statuses).context("Failed to serialize JSON")? + ); + } else { + for s in &statuses { + println!("{}", format_status_row(s)); + } + } + Ok(()) +} + +fn validate_color(color: &str) -> anyhow::Result<()> { + if color.len() == 7 + && color.starts_with('#') + && color[1..].chars().all(|c| c.is_ascii_hexdigit()) + { + Ok(()) + } else { + Err(anyhow::anyhow!( + "Color must be a 6-digit hex code with # prefix (e.g. #ed8077)" + )) + } +} + fn format_status_row(s: &ProjectStatus) -> String { format!("[{}] {}", s.id, s.name) } @@ -45,12 +255,50 @@ mod tests { struct MockApi { statuses: Option>, + status: Option, } impl crate::api::BacklogApi for MockApi { fn get_project_statuses(&self, _key: &str) -> anyhow::Result> { self.statuses.clone().ok_or_else(|| anyhow!("no statuses")) } + + fn add_project_status( + &self, + _key: &str, + _name: &str, + _color: &str, + ) -> anyhow::Result { + self.status.clone().ok_or_else(|| anyhow!("add failed")) + } + + fn update_project_status( + &self, + _key: &str, + _status_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + self.status.clone().ok_or_else(|| anyhow!("update failed")) + } + + fn delete_project_status( + &self, + _key: &str, + _status_id: u64, + _substitute_status_id: u64, + ) -> anyhow::Result { + self.status.clone().ok_or_else(|| anyhow!("delete failed")) + } + + fn reorder_project_statuses( + &self, + _key: &str, + _status_ids: &[u64], + ) -> anyhow::Result> { + self.statuses + .clone() + .ok_or_else(|| anyhow!("reorder failed")) + } } fn sample_status() -> ProjectStatus { @@ -63,6 +311,10 @@ mod tests { } } + fn mock(statuses: Option>, status: Option) -> MockApi { + MockApi { statuses, status } + } + #[test] fn format_status_row_contains_fields() { let text = format_status_row(&sample_status()); @@ -72,25 +324,188 @@ mod tests { #[test] fn list_with_text_output_succeeds() { - let api = MockApi { - statuses: Some(vec![sample_status()]), - }; + let api = mock(Some(vec![sample_status()]), None); assert!(list_with(&ProjectStatusListArgs::new("TEST".to_string(), false), &api).is_ok()); } #[test] fn list_with_json_output_succeeds() { - let api = MockApi { - statuses: Some(vec![sample_status()]), - }; + let api = mock(Some(vec![sample_status()]), None); assert!(list_with(&ProjectStatusListArgs::new("TEST".to_string(), true), &api).is_ok()); } #[test] fn list_with_propagates_api_error() { - let api = MockApi { statuses: None }; + let api = mock(None, None); let err = list_with(&ProjectStatusListArgs::new("TEST".to_string(), false), &api).unwrap_err(); assert!(err.to_string().contains("no statuses")); } + + #[test] + fn add_try_new_rejects_invalid_color() { + let err = ProjectStatusAddArgs::try_new( + "TEST".to_string(), + "Open".to_string(), + "ed8077".to_string(), + false, + ) + .unwrap_err(); + assert!(err.to_string().contains("hex code")); + } + + #[test] + fn add_with_text_output_succeeds() { + let api = mock(None, Some(sample_status())); + let args = ProjectStatusAddArgs::try_new( + "TEST".to_string(), + "Open".to_string(), + "#ed8077".to_string(), + false, + ) + .unwrap(); + assert!(add_with(&args, &api).is_ok()); + } + + #[test] + fn add_with_json_output_succeeds() { + let api = mock(None, Some(sample_status())); + let args = ProjectStatusAddArgs::try_new( + "TEST".to_string(), + "Open".to_string(), + "#ed8077".to_string(), + true, + ) + .unwrap(); + assert!(add_with(&args, &api).is_ok()); + } + + #[test] + fn add_with_propagates_api_error() { + let api = mock(None, None); + let args = ProjectStatusAddArgs::try_new( + "TEST".to_string(), + "Open".to_string(), + "#ed8077".to_string(), + false, + ) + .unwrap(); + let err = add_with(&args, &api).unwrap_err(); + assert!(err.to_string().contains("add failed")); + } + + #[test] + fn update_try_new_rejects_empty() { + let err = + ProjectStatusUpdateArgs::try_new("TEST".to_string(), 1, None, None, false).unwrap_err(); + assert!(err.to_string().contains("At least one")); + } + + #[test] + fn update_with_text_output_succeeds() { + let api = mock(None, Some(sample_status())); + let args = ProjectStatusUpdateArgs::try_new( + "TEST".to_string(), + 1, + Some("Closed".to_string()), + None, + false, + ) + .unwrap(); + assert!(update_with(&args, &api).is_ok()); + } + + #[test] + fn update_with_json_output_succeeds() { + let api = mock(None, Some(sample_status())); + let args = ProjectStatusUpdateArgs::try_new( + "TEST".to_string(), + 1, + None, + Some("#f42858".to_string()), + true, + ) + .unwrap(); + assert!(update_with(&args, &api).is_ok()); + } + + #[test] + fn update_with_propagates_api_error() { + let api = mock(None, None); + let args = ProjectStatusUpdateArgs::try_new( + "TEST".to_string(), + 1, + Some("Closed".to_string()), + None, + false, + ) + .unwrap(); + let err = update_with(&args, &api).unwrap_err(); + assert!(err.to_string().contains("update failed")); + } + + #[test] + fn delete_try_new_rejects_same_ids() { + let err = ProjectStatusDeleteArgs::try_new("TEST".to_string(), 1, 1, false).unwrap_err(); + assert!(err.to_string().contains("must differ")); + } + + #[test] + fn delete_with_text_output_succeeds() { + let api = mock(None, Some(sample_status())); + let args = ProjectStatusDeleteArgs::try_new("TEST".to_string(), 1, 2, false).unwrap(); + assert!(delete_with(&args, &api).is_ok()); + } + + #[test] + fn delete_with_json_output_succeeds() { + let api = mock(None, Some(sample_status())); + let args = ProjectStatusDeleteArgs::try_new("TEST".to_string(), 1, 2, true).unwrap(); + assert!(delete_with(&args, &api).is_ok()); + } + + #[test] + fn delete_with_propagates_api_error() { + let api = mock(None, None); + let args = ProjectStatusDeleteArgs::try_new("TEST".to_string(), 1, 2, false).unwrap(); + let err = delete_with(&args, &api).unwrap_err(); + assert!(err.to_string().contains("delete failed")); + } + + #[test] + fn reorder_try_new_rejects_empty() { + let err = ProjectStatusReorderArgs::try_new("TEST".to_string(), vec![], false).unwrap_err(); + assert!(err.to_string().contains("At least one")); + } + + #[test] + fn reorder_try_new_rejects_duplicate_ids() { + let err = + ProjectStatusReorderArgs::try_new("TEST".to_string(), vec![1, 1], false).unwrap_err(); + assert!(err.to_string().contains("unique")); + } + + #[test] + fn reorder_with_text_output_succeeds() { + let api = mock(Some(vec![sample_status()]), None); + let args = + ProjectStatusReorderArgs::try_new("TEST".to_string(), vec![1, 2], false).unwrap(); + assert!(reorder_with(&args, &api).is_ok()); + } + + #[test] + fn reorder_with_json_output_succeeds() { + let api = mock(Some(vec![sample_status()]), None); + let args = ProjectStatusReorderArgs::try_new("TEST".to_string(), vec![1, 2], true).unwrap(); + assert!(reorder_with(&args, &api).is_ok()); + } + + #[test] + fn reorder_with_propagates_api_error() { + let api = mock(None, None); + let args = + ProjectStatusReorderArgs::try_new("TEST".to_string(), vec![1, 2], false).unwrap(); + let err = reorder_with(&args, &api).unwrap_err(); + assert!(err.to_string().contains("reorder failed")); + } } diff --git a/src/main.rs b/src/main.rs index dcd2a03..3ec5166 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,10 @@ use cmd::priority::PriorityListArgs; use cmd::project::admin::{ProjectAdminAddArgs, ProjectAdminDeleteArgs, ProjectAdminListArgs}; use cmd::project::category::ProjectCategoryListArgs; use cmd::project::issue_type::ProjectIssueTypeListArgs; -use cmd::project::status::ProjectStatusListArgs; +use cmd::project::status::{ + ProjectStatusAddArgs, ProjectStatusDeleteArgs, ProjectStatusListArgs, ProjectStatusReorderArgs, + ProjectStatusUpdateArgs, +}; use cmd::project::user::{ProjectUserAddArgs, ProjectUserDeleteArgs, ProjectUserListArgs}; use cmd::project::version::ProjectVersionListArgs; use cmd::project::{ @@ -474,6 +477,62 @@ enum ProjectStatusCommands { #[arg(long)] json: bool, }, + /// Add a status to a project + Add { + /// Project ID or key + id_or_key: String, + /// Status name + #[arg(long)] + name: String, + /// Status color (hex code, e.g. #ed8077) + #[arg(long)] + color: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Update a project status + Update { + /// Project ID or key + id_or_key: String, + /// Status ID to update + #[arg(long)] + status_id: u64, + /// New status name + #[arg(long)] + name: Option, + /// New status color (hex code) + #[arg(long)] + color: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Delete a project status + Delete { + /// Project ID or key + id_or_key: String, + /// Status ID to delete + #[arg(long)] + status_id: u64, + /// Status ID to substitute for issues with the deleted status + #[arg(long)] + substitute_status_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Reorder project statuses + Reorder { + /// Project ID or key + id_or_key: String, + /// Status IDs in desired display order (repeatable) + #[arg(long = "status-id", value_name = "ID")] + status_ids: Vec, + /// Output as JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -1487,6 +1546,41 @@ fn run() -> Result<()> { ProjectStatusCommands::List { id_or_key, json } => { cmd::project::status::list(&ProjectStatusListArgs::new(id_or_key, json)) } + ProjectStatusCommands::Add { + id_or_key, + name, + color, + json, + } => cmd::project::status::add(&ProjectStatusAddArgs::try_new( + id_or_key, name, color, json, + )?), + ProjectStatusCommands::Update { + id_or_key, + status_id, + name, + color, + json, + } => cmd::project::status::update(&ProjectStatusUpdateArgs::try_new( + id_or_key, status_id, name, color, json, + )?), + ProjectStatusCommands::Delete { + id_or_key, + status_id, + substitute_status_id, + json, + } => cmd::project::status::delete(&ProjectStatusDeleteArgs::try_new( + id_or_key, + status_id, + substitute_status_id, + json, + )?), + ProjectStatusCommands::Reorder { + id_or_key, + status_ids, + json, + } => cmd::project::status::reorder(&ProjectStatusReorderArgs::try_new( + id_or_key, status_ids, json, + )?), }, ProjectCommands::IssueType { action } => match action { ProjectIssueTypeCommands::List { id_or_key, json } => { diff --git a/website/docs/commands.md b/website/docs/commands.md index 4273526..b8963d3 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -334,6 +334,74 @@ Example output: [4] Closed ``` +## `bl project status add` + +Add a status to a project. + +```bash +bl project status add --name --color +bl project status add --name --color --json +``` + +The `--color` value must be a 6-digit hex color code with a `#` prefix (e.g., `#ed8077`). Only hex colors are accepted; CSS color names and shorthand hex are not supported. + +Example output: + +```text +Added: [5] In Review +``` + +## `bl project status update` + +Update a project status. + +```bash +bl project status update --status-id --name +bl project status update --status-id --color +bl project status update --status-id --name --color --json +``` + +At least one of `--name` or `--color` must be specified. + +Example output: + +```text +Updated: [5] In Review +``` + +## `bl project status delete` + +Delete a project status. Issues with the deleted status are migrated to the substitute status. + +```bash +bl project status delete --status-id --substitute-status-id +bl project status delete --status-id --substitute-status-id --json +``` + +Example output: + +```text +Deleted: [5] In Review +``` + +## `bl project status reorder` + +Reorder project statuses by specifying status IDs in the desired display order. + +```bash +bl project status reorder --status-id --status-id ... +bl project status reorder --status-id --status-id --json +``` + +Example output: + +```text +[2] In Progress +[1] Open +[3] Resolved +[4] Closed +``` + ## `bl project issue-type list` List issue types defined for a specific project. @@ -1254,10 +1322,10 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | `bl project admin delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented | | — | `GET /api/v2/projects/{projectIdOrKey}/image` | Planned | | `bl project status list ` | `GET /api/v2/projects/{projectIdOrKey}/statuses` | ✅ Implemented | -| `bl project status add ` | `POST /api/v2/projects/{projectIdOrKey}/statuses` | Planned | -| `bl project status update ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/{id}` | Planned | -| `bl project status delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/statuses/{id}` | Planned | -| `bl project status reorder ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/updateDisplayOrder` | Planned | +| `bl project status add ` | `POST /api/v2/projects/{projectIdOrKey}/statuses` | ✅ Implemented | +| `bl project status update --status-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/{id}` | ✅ Implemented | +| `bl project status delete --status-id ` | `DELETE /api/v2/projects/{projectIdOrKey}/statuses/{id}` | ✅ Implemented | +| `bl project status reorder ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/updateDisplayOrder` | ✅ Implemented | | `bl project issue-type list ` | `GET /api/v2/projects/{projectIdOrKey}/issueTypes` | ✅ Implemented | | `bl project issue-type add ` | `POST /api/v2/projects/{projectIdOrKey}/issueTypes` | Planned | | `bl project issue-type update ` | `PATCH /api/v2/projects/{projectIdOrKey}/issueTypes/{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 8a8a0bb..279a94b 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -334,6 +334,74 @@ bl project status list --json [4] Closed ``` +## `bl project status add` + +プロジェクトにステータスを追加します。 + +```bash +bl project status add --name --color +bl project status add --name --color --json +``` + +`--color` の値は `#` プレフィックス付きの 6 桁の hex カラーコード(例: `#ed8077`)を指定します。CSS カラー名や省略 hex 形式は受け付けられません。 + +出力例: + +```text +Added: [5] In Review +``` + +## `bl project status update` + +プロジェクトのステータスを更新します。 + +```bash +bl project status update --status-id --name +bl project status update --status-id --color +bl project status update --status-id --name --color --json +``` + +`--name` または `--color` の少なくともどちらかを指定する必要があります。 + +出力例: + +```text +Updated: [5] In Review +``` + +## `bl project status delete` + +プロジェクトのステータスを削除します。削除されたステータスの課題は代替ステータスに移行されます。 + +```bash +bl project status delete --status-id --substitute-status-id +bl project status delete --status-id --substitute-status-id --json +``` + +出力例: + +```text +Deleted: [5] In Review +``` + +## `bl project status reorder` + +表示順序を指定してプロジェクトのステータスを並べ替えます。 + +```bash +bl project status reorder --status-id --status-id ... +bl project status reorder --status-id --status-id --json +``` + +出力例: + +```text +[2] In Progress +[1] Open +[3] Resolved +[4] Closed +``` + ## `bl project issue-type list` 特定のプロジェクトの課題種別を一覧表示します。 @@ -1258,10 +1326,10 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `bl project admin delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/administrators` | ✅ 実装済み | | — | `GET /api/v2/projects/{projectIdOrKey}/image` | 計画中 | | `bl project status list ` | `GET /api/v2/projects/{projectIdOrKey}/statuses` | ✅ 実装済み | -| `bl project status add ` | `POST /api/v2/projects/{projectIdOrKey}/statuses` | 計画中 | -| `bl project status update ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/{id}` | 計画中 | -| `bl project status delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/statuses/{id}` | 計画中 | -| `bl project status reorder ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/updateDisplayOrder` | 計画中 | +| `bl project status add ` | `POST /api/v2/projects/{projectIdOrKey}/statuses` | ✅ 実装済み | +| `bl project status update --status-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/{id}` | ✅ 実装済み | +| `bl project status delete --status-id ` | `DELETE /api/v2/projects/{projectIdOrKey}/statuses/{id}` | ✅ 実装済み | +| `bl project status reorder ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/updateDisplayOrder` | ✅ 実装済み | | `bl project issue-type list ` | `GET /api/v2/projects/{projectIdOrKey}/issueTypes` | ✅ 実装済み | | `bl project issue-type add ` | `POST /api/v2/projects/{projectIdOrKey}/issueTypes` | 計画中 | | `bl project issue-type update ` | `PATCH /api/v2/projects/{projectIdOrKey}/issueTypes/{id}` | 計画中 |