diff --git a/src/api/mod.rs b/src/api/mod.rs index b698a85..f64db62 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -174,6 +174,31 @@ pub trait BacklogApi { fn get_project_versions(&self, _key: &str) -> Result> { unimplemented!() } + fn add_project_version( + &self, + _key: &str, + _name: &str, + _description: Option<&str>, + _start_date: Option<&str>, + _release_due_date: Option<&str>, + ) -> Result { + unimplemented!() + } + fn update_project_version( + &self, + _key: &str, + _version_id: u64, + _name: &str, + _description: Option<&str>, + _start_date: Option<&str>, + _release_due_date: Option<&str>, + _archived: Option, + ) -> Result { + unimplemented!() + } + fn delete_project_version(&self, _key: &str, _version_id: u64) -> Result { + unimplemented!() + } fn create_project(&self, _params: &[(String, String)]) -> Result { unimplemented!() } @@ -548,6 +573,42 @@ impl BacklogApi for BacklogClient { self.get_project_versions(key) } + fn add_project_version( + &self, + key: &str, + name: &str, + description: Option<&str>, + start_date: Option<&str>, + release_due_date: Option<&str>, + ) -> Result { + self.add_project_version(key, name, description, start_date, release_due_date) + } + + fn update_project_version( + &self, + key: &str, + version_id: u64, + name: &str, + description: Option<&str>, + start_date: Option<&str>, + release_due_date: Option<&str>, + archived: Option, + ) -> Result { + self.update_project_version( + key, + version_id, + name, + description, + start_date, + release_due_date, + archived, + ) + } + + fn delete_project_version(&self, key: &str, version_id: u64) -> Result { + self.delete_project_version(key, version_id) + } + fn create_project(&self, params: &[(String, String)]) -> Result { self.create_project(params) } diff --git a/src/api/project.rs b/src/api/project.rs index b0666e1..b022cce 100644 --- a/src/api/project.rs +++ b/src/api/project.rs @@ -290,6 +290,81 @@ impl BacklogClient { }) } + pub fn add_project_version( + &self, + key: &str, + name: &str, + description: Option<&str>, + start_date: Option<&str>, + release_due_date: Option<&str>, + ) -> Result { + let mut params = vec![("name".to_string(), name.to_string())]; + if let Some(d) = description { + params.push(("description".to_string(), d.to_string())); + } + if let Some(s) = start_date { + params.push(("startDate".to_string(), s.to_string())); + } + if let Some(r) = release_due_date { + params.push(("releaseDueDate".to_string(), r.to_string())); + } + let value = self.post_form(&format!("/projects/{}/versions", key), ¶ms)?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize add project version response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn update_project_version( + &self, + key: &str, + version_id: u64, + name: &str, + description: Option<&str>, + start_date: Option<&str>, + release_due_date: Option<&str>, + archived: Option, + ) -> Result { + let mut params = vec![("name".to_string(), name.to_string())]; + if let Some(d) = description { + params.push(("description".to_string(), d.to_string())); + } + if let Some(s) = start_date { + params.push(("startDate".to_string(), s.to_string())); + } + if let Some(r) = release_due_date { + params.push(("releaseDueDate".to_string(), r.to_string())); + } + if let Some(a) = archived { + params.push(("archived".to_string(), a.to_string())); + } + let value = self.patch_form( + &format!("/projects/{}/versions/{}", key, version_id), + ¶ms, + )?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize update project version response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn delete_project_version(&self, key: &str, version_id: u64) -> Result { + let value = self.delete_req(&format!("/projects/{}/versions/{}", key, version_id))?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize delete project version 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/version.rs b/src/cmd/project/version.rs index b97b6d2..787c352 100644 --- a/src/cmd/project/version.rs +++ b/src/cmd/project/version.rs @@ -14,6 +14,86 @@ impl ProjectVersionListArgs { } } +pub struct ProjectVersionAddArgs { + key: String, + name: String, + description: Option, + start_date: Option, + release_due_date: Option, + json: bool, +} + +impl ProjectVersionAddArgs { + pub fn new( + key: String, + name: String, + description: Option, + start_date: Option, + release_due_date: Option, + json: bool, + ) -> Self { + Self { + key, + name, + description, + start_date, + release_due_date, + json, + } + } +} + +pub struct ProjectVersionUpdateArgs { + key: String, + version_id: u64, + name: String, + description: Option, + start_date: Option, + release_due_date: Option, + archived: Option, + json: bool, +} + +impl ProjectVersionUpdateArgs { + pub fn new( + key: String, + version_id: u64, + name: String, + description: Option, + start_date: Option, + release_due_date: Option, + archived: Option, + json: bool, + ) -> Self { + Self { + key, + version_id, + name, + description, + start_date, + release_due_date, + archived, + json, + } + } +} + +pub struct ProjectVersionDeleteArgs { + key: String, + version_id: u64, + json: bool, +} + +impl ProjectVersionDeleteArgs { + pub fn new(key: String, version_id: u64, json: bool) -> Self { + Self { + key, + version_id, + json, + } + } +} + pub fn list(args: &ProjectVersionListArgs) -> Result<()> { let client = BacklogClient::from_config()?; list_with(args, &client) @@ -34,6 +114,74 @@ pub fn list_with(args: &ProjectVersionListArgs, api: &dyn BacklogApi) -> Result< Ok(()) } +pub fn add(args: &ProjectVersionAddArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + add_with(args, &client) +} + +pub fn add_with(args: &ProjectVersionAddArgs, api: &dyn BacklogApi) -> Result<()> { + let version = api.add_project_version( + &args.key, + &args.name, + args.description.as_deref(), + args.start_date.as_deref(), + args.release_due_date.as_deref(), + )?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&version).context("Failed to serialize JSON")? + ); + } else { + println!("Added: {}", format_version_row(&version)); + } + Ok(()) +} + +pub fn update(args: &ProjectVersionUpdateArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + update_with(args, &client) +} + +pub fn update_with(args: &ProjectVersionUpdateArgs, api: &dyn BacklogApi) -> Result<()> { + let version = api.update_project_version( + &args.key, + args.version_id, + &args.name, + args.description.as_deref(), + args.start_date.as_deref(), + args.release_due_date.as_deref(), + args.archived, + )?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&version).context("Failed to serialize JSON")? + ); + } else { + println!("Updated: {}", format_version_row(&version)); + } + Ok(()) +} + +pub fn delete(args: &ProjectVersionDeleteArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + delete_with(args, &client) +} + +pub fn delete_with(args: &ProjectVersionDeleteArgs, api: &dyn BacklogApi) -> Result<()> { + let version = api.delete_project_version(&args.key, args.version_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&version).context("Failed to serialize JSON")? + ); + } else { + println!("Deleted: {}", format_version_row(&version)); + } + Ok(()) +} + fn format_version_row(v: &ProjectVersion) -> String { let dates = match (&v.start_date, &v.release_due_date) { (Some(s), Some(e)) => format!(" ({} → {})", s, e), @@ -51,12 +199,49 @@ mod tests { use anyhow::anyhow; struct MockApi { - versions: Option>, + list: Option>, + single: Option, + } + + fn mock(list: Option>, single: Option) -> MockApi { + MockApi { list, single } } impl crate::api::BacklogApi for MockApi { fn get_project_versions(&self, _key: &str) -> anyhow::Result> { - self.versions.clone().ok_or_else(|| anyhow!("no versions")) + self.list.clone().ok_or_else(|| anyhow!("list failed")) + } + + fn add_project_version( + &self, + _key: &str, + _name: &str, + _description: Option<&str>, + _start_date: Option<&str>, + _release_due_date: Option<&str>, + ) -> anyhow::Result { + self.single.clone().ok_or_else(|| anyhow!("add failed")) + } + + fn update_project_version( + &self, + _key: &str, + _version_id: u64, + _name: &str, + _description: Option<&str>, + _start_date: Option<&str>, + _release_due_date: Option<&str>, + _archived: Option, + ) -> anyhow::Result { + self.single.clone().ok_or_else(|| anyhow!("update failed")) + } + + fn delete_project_version( + &self, + _key: &str, + _version_id: u64, + ) -> anyhow::Result { + self.single.clone().ok_or_else(|| anyhow!("delete failed")) } } @@ -107,9 +292,7 @@ mod tests { #[test] fn list_with_text_output_succeeds() { - let api = MockApi { - versions: Some(vec![sample_version()]), - }; + let api = mock(Some(vec![sample_version()]), None); assert!( list_with( &ProjectVersionListArgs::new("TEST".to_string(), false), @@ -121,20 +304,132 @@ mod tests { #[test] fn list_with_json_output_succeeds() { - let api = MockApi { - versions: Some(vec![sample_version()]), - }; + let api = mock(Some(vec![sample_version()]), None); assert!(list_with(&ProjectVersionListArgs::new("TEST".to_string(), true), &api).is_ok()); } #[test] fn list_with_propagates_api_error() { - let api = MockApi { versions: None }; + let api = mock(None, None); let err = list_with( &ProjectVersionListArgs::new("TEST".to_string(), false), &api, ) .unwrap_err(); - assert!(err.to_string().contains("no versions")); + assert!(err.to_string().contains("list failed")); + } + + #[test] + fn add_with_text_output_succeeds() { + let api = mock(None, Some(sample_version())); + let args = ProjectVersionAddArgs::new( + "TEST".to_string(), + "v1.0".to_string(), + None, + None, + None, + false, + ); + assert!(add_with(&args, &api).is_ok()); + } + + #[test] + fn add_with_json_output_succeeds() { + let api = mock(None, Some(sample_version())); + let args = ProjectVersionAddArgs::new( + "TEST".to_string(), + "v1.0".to_string(), + None, + None, + None, + true, + ); + assert!(add_with(&args, &api).is_ok()); + } + + #[test] + fn add_with_propagates_api_error() { + let api = mock(None, None); + let args = ProjectVersionAddArgs::new( + "TEST".to_string(), + "v1.0".to_string(), + None, + None, + None, + 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_version())); + let args = ProjectVersionUpdateArgs::new( + "TEST".to_string(), + 3, + "v1.1".to_string(), + None, + None, + None, + None, + false, + ); + assert!(update_with(&args, &api).is_ok()); + } + + #[test] + fn update_with_json_output_succeeds() { + let api = mock(None, Some(sample_version())); + let args = ProjectVersionUpdateArgs::new( + "TEST".to_string(), + 3, + "v1.1".to_string(), + None, + None, + None, + Some(true), + true, + ); + assert!(update_with(&args, &api).is_ok()); + } + + #[test] + fn update_with_propagates_api_error() { + let api = mock(None, None); + let args = ProjectVersionUpdateArgs::new( + "TEST".to_string(), + 3, + "v1.1".to_string(), + None, + None, + None, + None, + 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_version())); + let args = ProjectVersionDeleteArgs::new("TEST".to_string(), 3, false); + assert!(delete_with(&args, &api).is_ok()); + } + + #[test] + fn delete_with_json_output_succeeds() { + let api = mock(None, Some(sample_version())); + let args = ProjectVersionDeleteArgs::new("TEST".to_string(), 3, true); + assert!(delete_with(&args, &api).is_ok()); + } + + #[test] + fn delete_with_propagates_api_error() { + let api = mock(None, None); + let args = ProjectVersionDeleteArgs::new("TEST".to_string(), 3, 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 ddd613d..187823f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,10 @@ use cmd::project::status::{ ProjectStatusUpdateArgs, }; use cmd::project::user::{ProjectUserAddArgs, ProjectUserDeleteArgs, ProjectUserListArgs}; -use cmd::project::version::ProjectVersionListArgs; +use cmd::project::version::{ + ProjectVersionAddArgs, ProjectVersionDeleteArgs, ProjectVersionListArgs, + ProjectVersionUpdateArgs, +}; use cmd::project::{ ProjectActivitiesArgs, ProjectCreateArgs, ProjectDeleteArgs, ProjectDiskUsageArgs, ProjectListArgs, ProjectShowArgs, ProjectUpdateArgs, @@ -656,6 +659,63 @@ enum ProjectVersionCommands { #[arg(long)] json: bool, }, + /// Add a version to a project + Add { + /// Project ID or key + id_or_key: String, + /// Version name + #[arg(long)] + name: String, + /// Description + #[arg(long)] + description: Option, + /// Start date (YYYY-MM-DD) + #[arg(long)] + start_date: Option, + /// Release due date (YYYY-MM-DD) + #[arg(long)] + release_due_date: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Update a version in a project + Update { + /// Project ID or key + id_or_key: String, + /// Version ID + #[arg(long)] + version_id: u64, + /// Version name + #[arg(long)] + name: String, + /// Description + #[arg(long)] + description: Option, + /// Start date (YYYY-MM-DD) + #[arg(long)] + start_date: Option, + /// Release due date (YYYY-MM-DD) + #[arg(long)] + release_due_date: Option, + /// Mark as archived + #[arg(long)] + archived: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Delete a version from a project + Delete { + /// Project ID or key + id_or_key: String, + /// Version ID + #[arg(long)] + version_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -1742,6 +1802,47 @@ fn run() -> Result<()> { ProjectVersionCommands::List { id_or_key, json } => { cmd::project::version::list(&ProjectVersionListArgs::new(id_or_key, json)) } + ProjectVersionCommands::Add { + id_or_key, + name, + description, + start_date, + release_due_date, + json, + } => cmd::project::version::add(&ProjectVersionAddArgs::new( + id_or_key, + name, + description, + start_date, + release_due_date, + json, + )), + ProjectVersionCommands::Update { + id_or_key, + version_id, + name, + description, + start_date, + release_due_date, + archived, + json, + } => cmd::project::version::update(&ProjectVersionUpdateArgs::new( + id_or_key, + version_id, + name, + description, + start_date, + release_due_date, + archived, + json, + )), + ProjectVersionCommands::Delete { + id_or_key, + version_id, + json, + } => cmd::project::version::delete(&ProjectVersionDeleteArgs::new( + id_or_key, version_id, json, + )), }, }, Commands::Issue { action } => match action { diff --git a/website/docs/commands.md b/website/docs/commands.md index 8a79623..6f585d2 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -548,6 +548,72 @@ Example output: [4] Version 0.2 [archived] ``` +## `bl project version add` + +Add a version (milestone) to a project. + +```bash +bl project version add --name "v1.0" +bl project version add --name "v1.0" --start-date 2024-01-01 --release-due-date 2024-03-31 +bl project version add --name "v1.0" --description "First release" --json +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--name` | — | Version name (required) | +| `--description` | — | Description | +| `--start-date` | — | Start date (YYYY-MM-DD) | +| `--release-due-date` | — | Release due date (YYYY-MM-DD) | + +Example output: + +```text +Added: [5] v1.0 (2024-01-01 → 2024-03-31) +``` + +## `bl project version update` + +Update a version in a project. + +```bash +bl project version update --version-id 5 --name "v1.0.1" +bl project version update --version-id 5 --name "v1.0" --archived true --json +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--version-id` | — | Version ID (required) | +| `--name` | — | Version name (required) | +| `--description` | — | Description | +| `--start-date` | — | Start date (YYYY-MM-DD) | +| `--release-due-date` | — | Release due date (YYYY-MM-DD) | +| `--archived` | — | `true` to archive, `false` to unarchive | + +Example output: + +```text +Updated: [5] v1.0.1 (2024-01-01 → 2024-03-31) +``` + +## `bl project version delete` + +Delete a version from a project. + +```bash +bl project version delete --version-id 5 +bl project version delete --version-id 5 --json +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--version-id` | — | Version ID (required) | + +Example output: + +```text +Deleted: [5] v1.0 +``` + ## `bl project create` Create a new project. @@ -1432,9 +1498,9 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | `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 | -| `bl project version delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/versions/{id}` | Planned | +| `bl project version add ` | `POST /api/v2/projects/{projectIdOrKey}/versions` | ✅ Implemented | +| `bl project version update --version-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/versions/{id}` | ✅ Implemented | +| `bl project version delete --version-id ` | `DELETE /api/v2/projects/{projectIdOrKey}/versions/{id}` | ✅ Implemented | | `bl project custom-field list ` | `GET /api/v2/projects/{projectIdOrKey}/customFields` | Planned | | `bl project custom-field add ` | `POST /api/v2/projects/{projectIdOrKey}/customFields` | Planned | | `bl project custom-field update ` | `PATCH /api/v2/projects/{projectIdOrKey}/customFields/{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 23fd5cc..36f7dc3 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -548,6 +548,72 @@ bl project version list --json [4] Version 0.2 [archived] ``` +## `bl project version add` + +プロジェクトにバージョン(マイルストーン)を追加します。 + +```bash +bl project version add --name "v1.0" +bl project version add --name "v1.0" --start-date 2024-01-01 --release-due-date 2024-03-31 +bl project version add --name "v1.0" --description "最初のリリース" --json +``` + +| フラグ | デフォルト | 説明 | +| --- | --- | --- | +| `--name` | — | バージョン名(必須) | +| `--description` | — | 説明 | +| `--start-date` | — | 開始日(YYYY-MM-DD) | +| `--release-due-date` | — | リリース期限日(YYYY-MM-DD) | + +出力例: + +```text +Added: [5] v1.0 (2024-01-01 → 2024-03-31) +``` + +## `bl project version update` + +プロジェクトのバージョンを更新します。 + +```bash +bl project version update --version-id 5 --name "v1.0.1" +bl project version update --version-id 5 --name "v1.0" --archived true --json +``` + +| フラグ | デフォルト | 説明 | +| --- | --- | --- | +| `--version-id` | — | バージョン ID(必須) | +| `--name` | — | バージョン名(必須) | +| `--description` | — | 説明 | +| `--start-date` | — | 開始日(YYYY-MM-DD) | +| `--release-due-date` | — | リリース期限日(YYYY-MM-DD) | +| `--archived` | — | `true` でアーカイブ、`false` で解除 | + +出力例: + +```text +Updated: [5] v1.0.1 (2024-01-01 → 2024-03-31) +``` + +## `bl project version delete` + +プロジェクトからバージョンを削除します。 + +```bash +bl project version delete --version-id 5 +bl project version delete --version-id 5 --json +``` + +| フラグ | デフォルト | 説明 | +| --- | --- | --- | +| `--version-id` | — | バージョン ID(必須) | + +出力例: + +```text +Deleted: [5] v1.0 +``` + ## `bl project create` 新しいプロジェクトを作成します。 @@ -1436,9 +1502,9 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `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}` | 計画中 | -| `bl project version delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/versions/{id}` | 計画中 | +| `bl project version add ` | `POST /api/v2/projects/{projectIdOrKey}/versions` | ✅ 実装済み | +| `bl project version update --version-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/versions/{id}` | ✅ 実装済み | +| `bl project version delete --version-id ` | `DELETE /api/v2/projects/{projectIdOrKey}/versions/{id}` | ✅ 実装済み | | `bl project custom-field list ` | `GET /api/v2/projects/{projectIdOrKey}/customFields` | 計画中 | | `bl project custom-field add ` | `POST /api/v2/projects/{projectIdOrKey}/customFields` | 計画中 | | `bl project custom-field update ` | `PATCH /api/v2/projects/{projectIdOrKey}/customFields/{id}` | 計画中 |