diff --git a/src/api/mod.rs b/src/api/mod.rs index 4dd92e2..49af56c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -497,6 +497,12 @@ pub trait BacklogApi { fn count_user_stars(&self, _user_id: u64, _params: &[(String, String)]) -> Result { unimplemented!() } + fn add_star(&self, _params: &[(String, String)]) -> Result<()> { + unimplemented!() + } + fn delete_star(&self, _star_id: u64) -> Result<()> { + unimplemented!() + } fn get_notifications(&self, _params: &[(String, String)]) -> Result> { unimplemented!() } @@ -1084,6 +1090,14 @@ impl BacklogApi for BacklogClient { self.count_user_stars(user_id, params) } + fn add_star(&self, params: &[(String, String)]) -> Result<()> { + self.add_star(params) + } + + fn delete_star(&self, star_id: u64) -> Result<()> { + self.delete_star(star_id) + } + fn get_notifications(&self, params: &[(String, String)]) -> Result> { self.get_notifications(params) } diff --git a/src/api/user.rs b/src/api/user.rs index 20408f0..91216a3 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -153,6 +153,16 @@ impl BacklogClient { let value = self.get_with_query(&format!("/users/{user_id}/stars/count"), params)?; deserialize(value, "star count response") } + + pub fn add_star(&self, params: &[(String, String)]) -> Result<()> { + self.post_form("/stars", params)?; + Ok(()) + } + + pub fn delete_star(&self, star_id: u64) -> Result<()> { + self.delete_req(&format!("/stars/{star_id}"))?; + Ok(()) + } } #[cfg(test)] diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index e67e401..5ad1883 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -8,6 +8,7 @@ pub mod project; pub mod rate_limit; pub mod resolution; pub mod space; +pub mod star; pub mod team; pub mod user; pub mod watch; diff --git a/src/cmd/star/add.rs b/src/cmd/star/add.rs new file mode 100644 index 0000000..92f29f7 --- /dev/null +++ b/src/cmd/star/add.rs @@ -0,0 +1,195 @@ +use anyhow::Result; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct StarAddArgs { + issue_id: Option, + comment_id: Option, + wiki_id: Option, + pull_request_id: Option, + pull_request_comment_id: Option, +} + +impl StarAddArgs { + pub fn try_new( + issue_id: Option, + comment_id: Option, + wiki_id: Option, + pull_request_id: Option, + pull_request_comment_id: Option, + ) -> anyhow::Result { + let count = [ + issue_id.is_some(), + comment_id.is_some(), + wiki_id.is_some(), + pull_request_id.is_some(), + pull_request_comment_id.is_some(), + ] + .iter() + .filter(|&&b| b) + .count(); + if count != 1 { + anyhow::bail!( + "exactly one of --issue-id, --comment-id, --wiki-id, \ + --pull-request-id, --pull-request-comment-id must be specified" + ); + } + Ok(Self { + issue_id, + comment_id, + wiki_id, + pull_request_id, + pull_request_comment_id, + }) + } +} + +pub fn add(args: &StarAddArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + add_with(args, &client) +} + +pub fn add_with(args: &StarAddArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + if let Some(id) = args.issue_id { + params.push(("issueId".to_string(), id.to_string())); + } + if let Some(id) = args.comment_id { + params.push(("commentId".to_string(), id.to_string())); + } + if let Some(id) = args.wiki_id { + params.push(("wikiId".to_string(), id.to_string())); + } + if let Some(id) = args.pull_request_id { + params.push(("pullRequestId".to_string(), id.to_string())); + } + if let Some(id) = args.pull_request_comment_id { + params.push(("pullRequestCommentId".to_string(), id.to_string())); + } + api.add_star(¶ms) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use std::cell::RefCell; + + struct MockApi { + ok: bool, + captured_params: RefCell>, + } + + impl MockApi { + fn new(ok: bool) -> Self { + Self { + ok, + captured_params: RefCell::new(Vec::new()), + } + } + } + + impl crate::api::BacklogApi for MockApi { + fn add_star(&self, params: &[(String, String)]) -> anyhow::Result<()> { + *self.captured_params.borrow_mut() = params.to_vec(); + if self.ok { + Ok(()) + } else { + Err(anyhow!("api error")) + } + } + } + + fn args_with_issue() -> StarAddArgs { + StarAddArgs::try_new(Some(1), None, None, None, None).unwrap() + } + + #[test] + fn add_with_succeeds() { + let api = MockApi::new(true); + assert!(add_with(&args_with_issue(), &api).is_ok()); + } + + #[test] + fn add_with_propagates_api_error() { + let api = MockApi::new(false); + let err = add_with(&args_with_issue(), &api).unwrap_err(); + assert!(err.to_string().contains("api error")); + } + + #[test] + fn try_new_rejects_no_target() { + assert!(StarAddArgs::try_new(None, None, None, None, None).is_err()); + } + + #[test] + fn try_new_rejects_multiple_targets() { + assert!(StarAddArgs::try_new(Some(1), Some(2), None, None, None).is_err()); + } + + #[test] + fn try_new_accepts_each_target() { + assert!(StarAddArgs::try_new(Some(1), None, None, None, None).is_ok()); + assert!(StarAddArgs::try_new(None, Some(1), None, None, None).is_ok()); + assert!(StarAddArgs::try_new(None, None, Some(1), None, None).is_ok()); + assert!(StarAddArgs::try_new(None, None, None, Some(1), None).is_ok()); + assert!(StarAddArgs::try_new(None, None, None, None, Some(1)).is_ok()); + } + + #[test] + fn add_with_sends_issue_id_param() { + let api = MockApi::new(true); + let args = StarAddArgs::try_new(Some(42), None, None, None, None).unwrap(); + add_with(&args, &api).unwrap(); + let params = api.captured_params.borrow(); + assert_eq!( + params.as_slice(), + [("issueId".to_string(), "42".to_string())] + ); + } + + #[test] + fn add_with_sends_comment_id_param() { + let api = MockApi::new(true); + let args = StarAddArgs::try_new(None, Some(10), None, None, None).unwrap(); + add_with(&args, &api).unwrap(); + let params = api.captured_params.borrow(); + assert_eq!( + params.as_slice(), + [("commentId".to_string(), "10".to_string())] + ); + } + + #[test] + fn add_with_sends_wiki_id_param() { + let api = MockApi::new(true); + let args = StarAddArgs::try_new(None, None, Some(5), None, None).unwrap(); + add_with(&args, &api).unwrap(); + let params = api.captured_params.borrow(); + assert_eq!(params.as_slice(), [("wikiId".to_string(), "5".to_string())]); + } + + #[test] + fn add_with_sends_pull_request_id_param() { + let api = MockApi::new(true); + let args = StarAddArgs::try_new(None, None, None, Some(7), None).unwrap(); + add_with(&args, &api).unwrap(); + let params = api.captured_params.borrow(); + assert_eq!( + params.as_slice(), + [("pullRequestId".to_string(), "7".to_string())] + ); + } + + #[test] + fn add_with_sends_pull_request_comment_id_param() { + let api = MockApi::new(true); + let args = StarAddArgs::try_new(None, None, None, None, Some(3)).unwrap(); + add_with(&args, &api).unwrap(); + let params = api.captured_params.borrow(); + assert_eq!( + params.as_slice(), + [("pullRequestCommentId".to_string(), "3".to_string())] + ); + } +} diff --git a/src/cmd/star/delete.rs b/src/cmd/star/delete.rs new file mode 100644 index 0000000..eb1b850 --- /dev/null +++ b/src/cmd/star/delete.rs @@ -0,0 +1,59 @@ +use anyhow::Result; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct StarDeleteArgs { + star_id: u64, +} + +impl StarDeleteArgs { + pub fn new(star_id: u64) -> Self { + Self { star_id } + } +} + +pub fn delete(args: &StarDeleteArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + delete_with(args, &client) +} + +pub fn delete_with(args: &StarDeleteArgs, api: &dyn BacklogApi) -> Result<()> { + api.delete_star(args.star_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + + struct MockApi { + ok: bool, + } + + impl crate::api::BacklogApi for MockApi { + fn delete_star(&self, _star_id: u64) -> anyhow::Result<()> { + if self.ok { + Ok(()) + } else { + Err(anyhow!("api error")) + } + } + } + + fn args() -> StarDeleteArgs { + StarDeleteArgs::new(42) + } + + #[test] + fn delete_with_succeeds() { + let api = MockApi { ok: true }; + assert!(delete_with(&args(), &api).is_ok()); + } + + #[test] + fn delete_with_propagates_api_error() { + let api = MockApi { ok: false }; + let err = delete_with(&args(), &api).unwrap_err(); + assert!(err.to_string().contains("api error")); + } +} diff --git a/src/cmd/star/mod.rs b/src/cmd/star/mod.rs new file mode 100644 index 0000000..4ad8ed8 --- /dev/null +++ b/src/cmd/star/mod.rs @@ -0,0 +1,5 @@ +mod add; +mod delete; + +pub use add::{StarAddArgs, add}; +pub use delete::{StarDeleteArgs, delete}; diff --git a/src/main.rs b/src/main.rs index 4a3da15..de90774 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,6 +71,7 @@ use cmd::space::{ SpaceActivitiesArgs, SpaceDiskUsageArgs, SpaceLicenceArgs, SpaceNotificationArgs, SpaceShowArgs, SpaceUpdateNotificationArgs, }; +use cmd::star::{StarAddArgs, StarDeleteArgs}; use cmd::team::{TeamListArgs, TeamShowArgs}; use cmd::user::star::{UserStarCountArgs, UserStarListArgs}; use cmd::user::{ @@ -176,6 +177,11 @@ enum Commands { #[arg(long)] json: bool, }, + /// Manage stars + Star { + #[command(subcommand)] + action: StarCommands, + }, } #[derive(clap::ValueEnum, Clone)] @@ -1868,6 +1874,33 @@ enum ResolutionCommands { }, } +#[derive(Subcommand)] +enum StarCommands { + /// Add a star to an issue, comment, wiki page, pull request, or pull request comment + Add { + /// ID of the issue to star + #[arg(long)] + issue_id: Option, + /// ID of the issue comment to star + #[arg(long)] + comment_id: Option, + /// ID of the wiki page to star + #[arg(long)] + wiki_id: Option, + /// ID of the pull request to star + #[arg(long)] + pull_request_id: Option, + /// ID of the pull request comment to star + #[arg(long)] + pull_request_comment_id: Option, + }, + /// Remove a star + Delete { + /// Star ID + id: u64, + }, +} + #[derive(Subcommand)] enum AuthCommands { /// Login with your API key @@ -2873,6 +2906,22 @@ fn run() -> Result<()> { } }, Commands::RateLimit { json } => cmd::rate_limit::show(&RateLimitArgs::new(json)), + Commands::Star { action } => match action { + StarCommands::Add { + issue_id, + comment_id, + wiki_id, + pull_request_id, + pull_request_comment_id, + } => cmd::star::add(&StarAddArgs::try_new( + issue_id, + comment_id, + wiki_id, + pull_request_id, + pull_request_comment_id, + )?), + StarCommands::Delete { id } => cmd::star::delete(&StarDeleteArgs::new(id)), + }, Commands::Space { action, json } => match action { None => cmd::space::show(&SpaceShowArgs::new(json)), Some(SpaceCommands::Activities { diff --git a/website/docs/commands.md b/website/docs/commands.md index fe7b796..471342f 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -1499,6 +1499,39 @@ Example output: [2] API Reference (project: 2) ``` +## `bl star add` + +Add a star to an issue, comment, wiki page, pull request, or pull request comment. +Exactly one target must be specified. + +```bash +bl star add --issue-id +bl star add --comment-id +bl star add --wiki-id +bl star add --pull-request-id +bl star add --pull-request-comment-id +``` + +| Option | Default | Description | +| --- | --- | --- | +| `--issue-id` | — | ID of the issue to star | +| `--comment-id` | — | ID of the issue comment to star | +| `--wiki-id` | — | ID of the wiki page to star | +| `--pull-request-id` | — | ID of the pull request to star | +| `--pull-request-comment-id` | — | ID of the pull request comment to star | + +On success, this command prints no output (HTTP 204 No Content). + +## `bl star delete` + +Remove a star. + +```bash +bl star delete +``` + +On success, this command prints no output (HTTP 204 No Content). + ## `bl user star list` List stars given by a user. @@ -1917,8 +1950,8 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | Command | API endpoint | Status | | --- | --- | --- | -| `bl star add` | `POST /api/v2/stars` | Planned | -| `bl star delete ` | `DELETE /api/v2/stars/{starId}` | Planned | +| `bl star add` | `POST /api/v2/stars` | ✅ Implemented | +| `bl star delete ` | `DELETE /api/v2/stars/{starId}` | ✅ Implemented | | `bl user star list ` | `GET /api/v2/users/{userId}/stars` | ✅ Implemented | | `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | ✅ Implemented | 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 ece8895..eddada8 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -1502,6 +1502,39 @@ bl user recently-viewed-wikis --json [2] API Reference (project: 2) ``` +## `bl star add` + +課題・コメント・Wiki・プルリクエスト・プルリクエストコメントにスターを追加します。 +対象は1つだけ指定してください。 + +```bash +bl star add --issue-id +bl star add --comment-id +bl star add --wiki-id +bl star add --pull-request-id +bl star add --pull-request-comment-id +``` + +| オプション | デフォルト | 説明 | +| --- | --- | --- | +| `--issue-id` | — | スターをつける課題の ID | +| `--comment-id` | — | スターをつける課題コメントの ID | +| `--wiki-id` | — | スターをつける Wiki ページの ID | +| `--pull-request-id` | — | スターをつけるプルリクエストの ID | +| `--pull-request-comment-id` | — | スターをつけるプルリクエストコメントの ID | + +成功時は出力なし(HTTP 204 No Content)。 + +## `bl star delete` + +スターを削除します。 + +```bash +bl star delete +``` + +成功時は出力なし(HTTP 204 No Content)。 + ## `bl user star list` ユーザーがスターをつけた一覧を表示します。 @@ -1921,8 +1954,8 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | コマンド | API エンドポイント | 状態 | | --- | --- | --- | -| `bl star add` | `POST /api/v2/stars` | 計画中 | -| `bl star delete ` | `DELETE /api/v2/stars/{starId}` | 計画中 | +| `bl star add` | `POST /api/v2/stars` | ✅ 実装済み | +| `bl star delete ` | `DELETE /api/v2/stars/{starId}` | ✅ 実装済み | | `bl user star list ` | `GET /api/v2/users/{userId}/stars` | ✅ 実装済み | | `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | ✅ 実装済み |