From 036a05a9a641379e778a66f5c093692e31225f61 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Mon, 16 Mar 2026 21:00:35 +0900 Subject: [PATCH 1/4] feat: bl user add/update/delete, recently-viewed-projects/wikis, star list/count --- src/api/mod.rs | 63 ++++- src/api/user.rs | 245 ++++++++++++++++++ src/cmd/user/add.rs | 128 +++++++++ src/cmd/user/delete.rs | 95 +++++++ src/cmd/user/mod.rs | 11 + src/cmd/user/recently_viewed_projects.rs | 136 ++++++++++ src/cmd/user/recently_viewed_wikis.rs | 151 +++++++++++ src/cmd/user/star/count.rs | 95 +++++++ src/cmd/user/star/list.rs | 147 +++++++++++ src/cmd/user/star/mod.rs | 5 + src/cmd/user/update.rs | 127 +++++++++ src/main.rs | 227 +++++++++++++++- website/docs/commands.md | 130 +++++++++- .../current/commands.md | 130 +++++++++- 14 files changed, 1670 insertions(+), 20 deletions(-) create mode 100644 src/cmd/user/add.rs create mode 100644 src/cmd/user/delete.rs create mode 100644 src/cmd/user/recently_viewed_projects.rs create mode 100644 src/cmd/user/recently_viewed_wikis.rs create mode 100644 src/cmd/user/star/count.rs create mode 100644 src/cmd/user/star/list.rs create mode 100644 src/cmd/user/star/mod.rs create mode 100644 src/cmd/user/update.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 40b5de0..cf1592f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -33,7 +33,7 @@ use project::{ use space::Space; use space_notification::SpaceNotification; use team::Team; -use user::{RecentlyViewedIssue, User}; +use user::{RecentlyViewedIssue, RecentlyViewedProject, RecentlyViewedWiki, Star, StarCount, User}; use wiki::{Wiki, WikiAttachment, WikiHistory, WikiListItem}; /// Abstraction over the Backlog HTTP API. @@ -242,6 +242,33 @@ pub trait BacklogApi { ) -> Result> { unimplemented!() } + fn add_user(&self, _params: &[(String, String)]) -> Result { + unimplemented!() + } + fn update_user(&self, _user_id: u64, _params: &[(String, String)]) -> Result { + unimplemented!() + } + fn delete_user(&self, _user_id: u64) -> Result { + unimplemented!() + } + fn get_recently_viewed_projects( + &self, + _params: &[(String, String)], + ) -> Result> { + unimplemented!() + } + fn get_recently_viewed_wikis( + &self, + _params: &[(String, String)], + ) -> Result> { + unimplemented!() + } + fn get_user_stars(&self, _user_id: u64, _params: &[(String, String)]) -> Result> { + unimplemented!() + } + fn count_user_stars(&self, _user_id: u64, _params: &[(String, String)]) -> Result { + unimplemented!() + } fn get_notifications(&self, _params: &[(String, String)]) -> Result> { unimplemented!() } @@ -500,6 +527,40 @@ impl BacklogApi for BacklogClient { self.get_recently_viewed_issues(params) } + fn add_user(&self, params: &[(String, String)]) -> Result { + self.add_user(params) + } + + fn update_user(&self, user_id: u64, params: &[(String, String)]) -> Result { + self.update_user(user_id, params) + } + + fn delete_user(&self, user_id: u64) -> Result { + self.delete_user(user_id) + } + + fn get_recently_viewed_projects( + &self, + params: &[(String, String)], + ) -> Result> { + self.get_recently_viewed_projects(params) + } + + fn get_recently_viewed_wikis( + &self, + params: &[(String, String)], + ) -> Result> { + self.get_recently_viewed_wikis(params) + } + + fn get_user_stars(&self, user_id: u64, params: &[(String, String)]) -> Result> { + self.get_user_stars(user_id, params) + } + + fn count_user_stars(&self, user_id: u64, params: &[(String, String)]) -> Result { + self.count_user_stars(user_id, params) + } + 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 cf72b7b..20408f0 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -5,6 +5,8 @@ use std::collections::BTreeMap; use super::BacklogClient; use crate::api::activity::Activity; use crate::api::issue::Issue; +use crate::api::project::Project; +use crate::api::wiki::WikiListItem; fn deserialize(value: serde_json::Value, ctx: &str) -> Result { serde_json::from_value(value.clone()).map_err(|e| { @@ -42,6 +44,42 @@ pub struct RecentlyViewedIssue { pub extra: BTreeMap, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentlyViewedProject { + pub project: Project, + pub updated: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentlyViewedWiki { + pub page: WikiListItem, + pub updated: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Star { + pub id: u64, + pub comment: Option, + pub url: String, + pub title: String, + pub presenter: User, + pub created: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StarCount { + pub count: u64, +} + impl BacklogClient { pub fn get_myself(&self) -> Result { let value = self.get("/users/myself")?; @@ -74,6 +112,47 @@ impl BacklogClient { let value = self.get_with_query("/users/myself/recentlyViewedIssues", params)?; deserialize(value, "recently viewed issues response") } + + pub fn add_user(&self, params: &[(String, String)]) -> Result { + let value = self.post_form("/users", params)?; + deserialize(value, "user response") + } + + pub fn update_user(&self, user_id: u64, params: &[(String, String)]) -> Result { + let value = self.patch_form(&format!("/users/{user_id}"), params)?; + deserialize(value, "user response") + } + + pub fn delete_user(&self, user_id: u64) -> Result { + let value = self.delete_req(&format!("/users/{user_id}"))?; + deserialize(value, "user response") + } + + pub fn get_recently_viewed_projects( + &self, + params: &[(String, String)], + ) -> Result> { + let value = self.get_with_query("/users/myself/recentlyViewedProjects", params)?; + deserialize(value, "recently viewed projects response") + } + + pub fn get_recently_viewed_wikis( + &self, + params: &[(String, String)], + ) -> Result> { + let value = self.get_with_query("/users/myself/recentlyViewedWikis", params)?; + deserialize(value, "recently viewed wikis response") + } + + pub fn get_user_stars(&self, user_id: u64, params: &[(String, String)]) -> Result> { + let value = self.get_with_query(&format!("/users/{user_id}/stars"), params)?; + deserialize(value, "user stars response") + } + + pub fn count_user_stars(&self, user_id: u64, params: &[(String, String)]) -> Result { + let value = self.get_with_query(&format!("/users/{user_id}/stars/count"), params)?; + deserialize(value, "star count response") + } } #[cfg(test)] @@ -82,6 +161,8 @@ mod tests { use httpmock::prelude::*; use serde_json::json; + const TEST_KEY: &str = "test-key"; + fn user_json() -> serde_json::Value { json!({ "id": 123, @@ -287,4 +368,168 @@ mod tests { assert_eq!(user.user_id, None); assert_eq!(user.mail_address, None); } + + #[test] + fn add_user_returns_parsed_struct() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/users"); + then.status(201).json_body(user_json()); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let user = client + .add_user(&[ + ("userId".to_string(), "john".to_string()), + ("password".to_string(), "secret".to_string()), + ("name".to_string(), "John Doe".to_string()), + ("mailAddress".to_string(), "john@example.com".to_string()), + ("roleType".to_string(), "1".to_string()), + ]) + .unwrap(); + assert_eq!(user.id, 123); + assert_eq!(user.name, "John Doe"); + } + + #[test] + fn add_user_returns_error_on_api_failure() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/users"); + then.status(403) + .json_body(json!({"errors": [{"message": "Forbidden"}]})); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let err = client.add_user(&[]).unwrap_err(); + assert!(err.to_string().contains("Forbidden")); + } + + #[test] + fn update_user_returns_parsed_struct() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::PATCH).path("/users/123"); + then.status(200).json_body(user_json()); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let user = client + .update_user(123, &[("name".to_string(), "New Name".to_string())]) + .unwrap(); + assert_eq!(user.id, 123); + } + + #[test] + fn update_user_returns_error_on_not_found() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::PATCH).path("/users/999"); + then.status(404) + .json_body(json!({"errors": [{"message": "No user"}]})); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let err = client.update_user(999, &[]).unwrap_err(); + assert!(err.to_string().contains("No user")); + } + + #[test] + fn delete_user_returns_parsed_struct() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE).path("/users/123"); + then.status(200).json_body(user_json()); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let user = client.delete_user(123).unwrap(); + assert_eq!(user.id, 123); + } + + #[test] + fn delete_user_returns_error_on_not_found() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE).path("/users/999"); + then.status(404) + .json_body(json!({"errors": [{"message": "No user"}]})); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let err = client.delete_user(999).unwrap_err(); + assert!(err.to_string().contains("No user")); + } + + #[test] + fn get_recently_viewed_projects_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/users/myself/recentlyViewedProjects"); + then.status(200).json_body(json!([{ + "project": { + "id": 1, "projectKey": "TEST", "name": "Test Project", + "chartEnabled": false, "subtaskingEnabled": false, + "projectLeaderCanEditProjectLeader": false, + "textFormattingRule": "markdown", "archived": false + }, + "updated": "2024-06-01T00:00:00Z" + }])); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let items = client.get_recently_viewed_projects(&[]).unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].project.project_key, "TEST"); + } + + #[test] + fn get_recently_viewed_wikis_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/users/myself/recentlyViewedWikis"); + then.status(200).json_body(json!([{ + "page": { + "id": 1, "projectId": 1, "name": "Home", + "tags": [], + "createdUser": {"id": 1, "userId": "admin", "name": "Admin", "roleType": 1}, + "created": "2024-01-01T00:00:00Z", + "updatedUser": {"id": 1, "userId": "admin", "name": "Admin", "roleType": 1}, + "updated": "2024-06-01T00:00:00Z" + }, + "updated": "2024-06-01T00:00:00Z" + }])); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let items = client.get_recently_viewed_wikis(&[]).unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].page.name, "Home"); + } + + #[test] + fn get_user_stars_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/users/123/stars"); + then.status(200).json_body(json!([{ + "id": 1, + "comment": null, + "url": "https://example.com/issue/1", + "title": "Issue title", + "presenter": {"id": 2, "userId": "alice", "name": "Alice", "roleType": 1}, + "created": "2024-01-01T00:00:00Z" + }])); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let stars = client.get_user_stars(123, &[]).unwrap(); + assert_eq!(stars.len(), 1); + assert_eq!(stars[0].title, "Issue title"); + assert_eq!(stars[0].comment, None); + } + + #[test] + fn count_user_stars_returns_count() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/users/123/stars/count"); + then.status(200).json_body(json!({"count": 42})); + }); + let client = BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let result = client.count_user_stars(123, &[]).unwrap(); + assert_eq!(result.count, 42); + } } diff --git a/src/cmd/user/add.rs b/src/cmd/user/add.rs new file mode 100644 index 0000000..ef58668 --- /dev/null +++ b/src/cmd/user/add.rs @@ -0,0 +1,128 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct UserAddArgs { + user_id: String, + password: String, + name: String, + mail_address: String, + role_type: u8, + json: bool, +} + +impl UserAddArgs { + pub fn new( + user_id: String, + password: String, + name: String, + mail_address: String, + role_type: u8, + json: bool, + ) -> Self { + Self { + user_id, + password, + name, + mail_address, + role_type, + json, + } + } +} + +pub fn add(args: &UserAddArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + add_with(args, &client) +} + +pub fn add_with(args: &UserAddArgs, api: &dyn BacklogApi) -> Result<()> { + let params = vec![ + ("userId".to_string(), args.user_id.clone()), + ("password".to_string(), args.password.clone()), + ("name".to_string(), args.name.clone()), + ("mailAddress".to_string(), args.mail_address.clone()), + ("roleType".to_string(), args.role_type.to_string()), + ]; + let user = api.add_user(¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&user).context("Failed to serialize JSON")? + ); + } else { + println!( + "Added: {} ({}) [roleType: {}]", + user.user_id.as_deref().unwrap_or("-"), + user.name, + user.role_type + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::user::User; + use anyhow::anyhow; + use std::collections::BTreeMap; + + struct MockApi { + user: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn add_user(&self, _params: &[(String, String)]) -> anyhow::Result { + self.user.clone().ok_or_else(|| anyhow!("add failed")) + } + } + + fn sample_user() -> User { + User { + id: 1, + user_id: Some("john".to_string()), + name: "John Doe".to_string(), + mail_address: Some("john@example.com".to_string()), + role_type: 2, + lang: None, + last_login_time: None, + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> UserAddArgs { + UserAddArgs::new( + "john".to_string(), + "secret".to_string(), + "John Doe".to_string(), + "john@example.com".to_string(), + 2, + json, + ) + } + + #[test] + fn add_with_text_output_succeeds() { + let api = MockApi { + user: Some(sample_user()), + }; + assert!(add_with(&args(false), &api).is_ok()); + } + + #[test] + fn add_with_json_output_succeeds() { + let api = MockApi { + user: Some(sample_user()), + }; + assert!(add_with(&args(true), &api).is_ok()); + } + + #[test] + fn add_with_propagates_api_error() { + let api = MockApi { user: None }; + let err = add_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("add failed")); + } +} diff --git a/src/cmd/user/delete.rs b/src/cmd/user/delete.rs new file mode 100644 index 0000000..bca4243 --- /dev/null +++ b/src/cmd/user/delete.rs @@ -0,0 +1,95 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct UserDeleteArgs { + user_id: u64, + json: bool, +} + +impl UserDeleteArgs { + pub fn new(user_id: u64, json: bool) -> Self { + Self { user_id, json } + } +} + +pub fn delete(args: &UserDeleteArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + delete_with(args, &client) +} + +pub fn delete_with(args: &UserDeleteArgs, api: &dyn BacklogApi) -> Result<()> { + let user = api.delete_user(args.user_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&user).context("Failed to serialize JSON")? + ); + } else { + println!( + "Deleted: {} ({})", + user.user_id.as_deref().unwrap_or("-"), + user.name + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::user::User; + use anyhow::anyhow; + use std::collections::BTreeMap; + + struct MockApi { + user: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn delete_user(&self, _user_id: u64) -> anyhow::Result { + self.user.clone().ok_or_else(|| anyhow!("delete failed")) + } + } + + fn sample_user() -> User { + User { + id: 1, + user_id: Some("john".to_string()), + name: "John Doe".to_string(), + mail_address: Some("john@example.com".to_string()), + role_type: 2, + lang: None, + last_login_time: None, + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> UserDeleteArgs { + UserDeleteArgs::new(1, json) + } + + #[test] + fn delete_with_text_output_succeeds() { + let api = MockApi { + user: Some(sample_user()), + }; + assert!(delete_with(&args(false), &api).is_ok()); + } + + #[test] + fn delete_with_json_output_succeeds() { + let api = MockApi { + user: Some(sample_user()), + }; + assert!(delete_with(&args(true), &api).is_ok()); + } + + #[test] + fn delete_with_propagates_api_error() { + let api = MockApi { user: None }; + let err = delete_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("delete failed")); + } +} diff --git a/src/cmd/user/mod.rs b/src/cmd/user/mod.rs index 5edd814..db4b6a3 100644 --- a/src/cmd/user/mod.rs +++ b/src/cmd/user/mod.rs @@ -1,9 +1,20 @@ mod activities; +mod add; +mod delete; mod list; mod recently_viewed; +mod recently_viewed_projects; +mod recently_viewed_wikis; mod show; +pub mod star; +mod update; pub use activities::{UserActivitiesArgs, activities}; +pub use add::{UserAddArgs, add}; +pub use delete::{UserDeleteArgs, delete}; pub use list::{UserListArgs, list}; pub use recently_viewed::{UserRecentlyViewedArgs, recently_viewed}; +pub use recently_viewed_projects::{UserRecentlyViewedProjectsArgs, recently_viewed_projects}; +pub use recently_viewed_wikis::{UserRecentlyViewedWikisArgs, recently_viewed_wikis}; pub use show::{UserShowArgs, show}; +pub use update::{UserUpdateArgs, update}; diff --git a/src/cmd/user/recently_viewed_projects.rs b/src/cmd/user/recently_viewed_projects.rs new file mode 100644 index 0000000..1857bb5 --- /dev/null +++ b/src/cmd/user/recently_viewed_projects.rs @@ -0,0 +1,136 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct UserRecentlyViewedProjectsArgs { + json: bool, + pub count: u32, + pub offset: u64, + pub order: Option, +} + +impl UserRecentlyViewedProjectsArgs { + pub fn try_new( + json: bool, + count: u32, + offset: u64, + order: Option, + ) -> anyhow::Result { + if !(1..=100).contains(&count) { + anyhow::bail!("count must be between 1 and 100"); + } + Ok(Self { + json, + count, + offset, + order, + }) + } +} + +pub fn recently_viewed_projects(args: &UserRecentlyViewedProjectsArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + recently_viewed_projects_with(args, &client) +} + +pub fn recently_viewed_projects_with( + args: &UserRecentlyViewedProjectsArgs, + api: &dyn BacklogApi, +) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + params.push(("count".to_string(), args.count.to_string())); + params.push(("offset".to_string(), args.offset.to_string())); + if let Some(ref order) = args.order { + params.push(("order".to_string(), order.clone())); + } + let items = api.get_recently_viewed_projects(¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&items).context("Failed to serialize JSON")? + ); + } else { + for item in &items { + println!("[{}] {}", item.project.project_key, item.project.name); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::project::Project; + use crate::api::user::RecentlyViewedProject; + use anyhow::anyhow; + use std::collections::BTreeMap; + + struct MockApi { + items: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_recently_viewed_projects( + &self, + _: &[(String, String)], + ) -> anyhow::Result> { + self.items.clone().ok_or_else(|| anyhow!("no items")) + } + } + + fn sample_item() -> RecentlyViewedProject { + RecentlyViewedProject { + project: Project { + id: 1, + project_key: "TEST".to_string(), + name: "Test Project".to_string(), + chart_enabled: false, + subtasking_enabled: false, + project_leader_can_edit_project_leader: false, + text_formatting_rule: "markdown".to_string(), + archived: false, + extra: BTreeMap::new(), + }, + updated: "2024-06-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> UserRecentlyViewedProjectsArgs { + UserRecentlyViewedProjectsArgs::try_new(json, 20, 0, None).unwrap() + } + + #[test] + fn recently_viewed_projects_with_text_output_succeeds() { + let api = MockApi { + items: Some(vec![sample_item()]), + }; + assert!(recently_viewed_projects_with(&args(false), &api).is_ok()); + } + + #[test] + fn recently_viewed_projects_with_json_output_succeeds() { + let api = MockApi { + items: Some(vec![sample_item()]), + }; + assert!(recently_viewed_projects_with(&args(true), &api).is_ok()); + } + + #[test] + fn recently_viewed_projects_with_propagates_api_error() { + let api = MockApi { items: None }; + let err = recently_viewed_projects_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no items")); + } + + #[test] + fn try_new_rejects_count_over_100() { + assert!(UserRecentlyViewedProjectsArgs::try_new(false, 101, 0, None).is_err()); + } + + #[test] + fn try_new_rejects_count_zero() { + assert!(UserRecentlyViewedProjectsArgs::try_new(false, 0, 0, None).is_err()); + } +} diff --git a/src/cmd/user/recently_viewed_wikis.rs b/src/cmd/user/recently_viewed_wikis.rs new file mode 100644 index 0000000..8b77253 --- /dev/null +++ b/src/cmd/user/recently_viewed_wikis.rs @@ -0,0 +1,151 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct UserRecentlyViewedWikisArgs { + json: bool, + pub count: u32, + pub offset: u64, + pub order: Option, +} + +impl UserRecentlyViewedWikisArgs { + pub fn try_new( + json: bool, + count: u32, + offset: u64, + order: Option, + ) -> anyhow::Result { + if !(1..=100).contains(&count) { + anyhow::bail!("count must be between 1 and 100"); + } + Ok(Self { + json, + count, + offset, + order, + }) + } +} + +pub fn recently_viewed_wikis(args: &UserRecentlyViewedWikisArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + recently_viewed_wikis_with(args, &client) +} + +pub fn recently_viewed_wikis_with( + args: &UserRecentlyViewedWikisArgs, + api: &dyn BacklogApi, +) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + params.push(("count".to_string(), args.count.to_string())); + params.push(("offset".to_string(), args.offset.to_string())); + if let Some(ref order) = args.order { + params.push(("order".to_string(), order.clone())); + } + let items = api.get_recently_viewed_wikis(¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&items).context("Failed to serialize JSON")? + ); + } else { + for item in &items { + println!( + "[{}] {} (project: {})", + item.page.id, item.page.name, item.page.project_id + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::user::RecentlyViewedWiki; + use crate::api::wiki::{WikiListItem, WikiUser}; + use anyhow::anyhow; + use std::collections::BTreeMap; + + struct MockApi { + items: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_recently_viewed_wikis( + &self, + _: &[(String, String)], + ) -> anyhow::Result> { + self.items.clone().ok_or_else(|| anyhow!("no items")) + } + } + + fn sample_wiki_user() -> WikiUser { + WikiUser { + id: 1, + user_id: Some("admin".to_string()), + name: "Admin".to_string(), + role_type: 1, + lang: None, + mail_address: None, + extra: BTreeMap::new(), + } + } + + fn sample_item() -> RecentlyViewedWiki { + RecentlyViewedWiki { + page: WikiListItem { + id: 1, + project_id: 1, + name: "Home".to_string(), + tags: vec![], + created_user: sample_wiki_user(), + created: "2024-01-01T00:00:00Z".to_string(), + updated_user: sample_wiki_user(), + updated: "2024-06-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + }, + updated: "2024-06-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> UserRecentlyViewedWikisArgs { + UserRecentlyViewedWikisArgs::try_new(json, 20, 0, None).unwrap() + } + + #[test] + fn recently_viewed_wikis_with_text_output_succeeds() { + let api = MockApi { + items: Some(vec![sample_item()]), + }; + assert!(recently_viewed_wikis_with(&args(false), &api).is_ok()); + } + + #[test] + fn recently_viewed_wikis_with_json_output_succeeds() { + let api = MockApi { + items: Some(vec![sample_item()]), + }; + assert!(recently_viewed_wikis_with(&args(true), &api).is_ok()); + } + + #[test] + fn recently_viewed_wikis_with_propagates_api_error() { + let api = MockApi { items: None }; + let err = recently_viewed_wikis_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no items")); + } + + #[test] + fn try_new_rejects_count_over_100() { + assert!(UserRecentlyViewedWikisArgs::try_new(false, 101, 0, None).is_err()); + } + + #[test] + fn try_new_rejects_count_zero() { + assert!(UserRecentlyViewedWikisArgs::try_new(false, 0, 0, None).is_err()); + } +} diff --git a/src/cmd/user/star/count.rs b/src/cmd/user/star/count.rs new file mode 100644 index 0000000..dd8f916 --- /dev/null +++ b/src/cmd/user/star/count.rs @@ -0,0 +1,95 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct UserStarCountArgs { + user_id: u64, + since: Option, + until: Option, + json: bool, +} + +impl UserStarCountArgs { + pub fn new(user_id: u64, since: Option, until: Option, json: bool) -> Self { + Self { + user_id, + since, + until, + json, + } + } +} + +pub fn count(args: &UserStarCountArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + count_with(args, &client) +} + +pub fn count_with(args: &UserStarCountArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + if let Some(ref since) = args.since { + params.push(("since".to_string(), since.clone())); + } + if let Some(ref until) = args.until { + params.push(("until".to_string(), until.clone())); + } + let result = api.count_user_stars(args.user_id, ¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&result).context("Failed to serialize JSON")? + ); + } else { + println!("{}", result.count); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::user::StarCount; + use anyhow::anyhow; + + struct MockApi { + result: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn count_user_stars( + &self, + _user_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + self.result.clone().ok_or_else(|| anyhow!("count failed")) + } + } + + fn args(json: bool) -> UserStarCountArgs { + UserStarCountArgs::new(1, None, None, json) + } + + #[test] + fn count_with_text_output_succeeds() { + let api = MockApi { + result: Some(StarCount { count: 42 }), + }; + assert!(count_with(&args(false), &api).is_ok()); + } + + #[test] + fn count_with_json_output_succeeds() { + let api = MockApi { + result: Some(StarCount { count: 42 }), + }; + assert!(count_with(&args(true), &api).is_ok()); + } + + #[test] + fn count_with_propagates_api_error() { + let api = MockApi { result: None }; + let err = count_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("count failed")); + } +} diff --git a/src/cmd/user/star/list.rs b/src/cmd/user/star/list.rs new file mode 100644 index 0000000..3792f0e --- /dev/null +++ b/src/cmd/user/star/list.rs @@ -0,0 +1,147 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct UserStarListArgs { + user_id: u64, + min_id: Option, + max_id: Option, + count: u32, + order: Option, + json: bool, +} + +impl UserStarListArgs { + pub fn try_new( + user_id: u64, + min_id: Option, + max_id: Option, + count: u32, + order: Option, + json: bool, + ) -> anyhow::Result { + if !(1..=100).contains(&count) { + anyhow::bail!("count must be between 1 and 100"); + } + Ok(Self { + user_id, + min_id, + max_id, + count, + order, + json, + }) + } +} + +pub fn list(args: &UserStarListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &UserStarListArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + params.push(("count".to_string(), args.count.to_string())); + if let Some(min_id) = args.min_id { + params.push(("minId".to_string(), min_id.to_string())); + } + if let Some(max_id) = args.max_id { + params.push(("maxId".to_string(), max_id.to_string())); + } + if let Some(ref order) = args.order { + params.push(("order".to_string(), order.clone())); + } + let stars = api.get_user_stars(args.user_id, ¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&stars).context("Failed to serialize JSON")? + ); + } else { + for star in &stars { + println!("[{}] {}", star.id, star.title); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::user::{Star, User}; + use anyhow::anyhow; + use std::collections::BTreeMap; + + struct MockApi { + stars: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_user_stars( + &self, + _user_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result> { + self.stars.clone().ok_or_else(|| anyhow!("no stars")) + } + } + + fn sample_star() -> Star { + Star { + id: 1, + comment: None, + url: "https://example.com/issue/1".to_string(), + title: "Issue title".to_string(), + presenter: User { + id: 2, + user_id: Some("alice".to_string()), + name: "Alice".to_string(), + mail_address: None, + role_type: 1, + lang: None, + last_login_time: None, + extra: BTreeMap::new(), + }, + created: "2024-01-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> UserStarListArgs { + UserStarListArgs::try_new(1, None, None, 20, None, json).unwrap() + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + stars: Some(vec![sample_star()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + stars: Some(vec![sample_star()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { stars: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no stars")); + } + + #[test] + fn try_new_rejects_count_over_100() { + assert!(UserStarListArgs::try_new(1, None, None, 101, None, false).is_err()); + } + + #[test] + fn try_new_rejects_count_zero() { + assert!(UserStarListArgs::try_new(1, None, None, 0, None, false).is_err()); + } +} diff --git a/src/cmd/user/star/mod.rs b/src/cmd/user/star/mod.rs new file mode 100644 index 0000000..f08d5de --- /dev/null +++ b/src/cmd/user/star/mod.rs @@ -0,0 +1,5 @@ +mod count; +mod list; + +pub use count::{UserStarCountArgs, count}; +pub use list::{UserStarListArgs, list}; diff --git a/src/cmd/user/update.rs b/src/cmd/user/update.rs new file mode 100644 index 0000000..25a6eae --- /dev/null +++ b/src/cmd/user/update.rs @@ -0,0 +1,127 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct UserUpdateArgs { + user_id: u64, + name: Option, + password: Option, + mail_address: Option, + role_type: Option, + json: bool, +} + +impl UserUpdateArgs { + pub fn new( + user_id: u64, + name: Option, + password: Option, + mail_address: Option, + role_type: Option, + json: bool, + ) -> Self { + Self { + user_id, + name, + password, + mail_address, + role_type, + json, + } + } +} + +pub fn update(args: &UserUpdateArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + update_with(args, &client) +} + +pub fn update_with(args: &UserUpdateArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + if let Some(ref name) = args.name { + params.push(("name".to_string(), name.clone())); + } + if let Some(ref password) = args.password { + params.push(("password".to_string(), password.clone())); + } + if let Some(ref mail_address) = args.mail_address { + params.push(("mailAddress".to_string(), mail_address.clone())); + } + if let Some(role_type) = args.role_type { + params.push(("roleType".to_string(), role_type.to_string())); + } + let user = api.update_user(args.user_id, ¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&user).context("Failed to serialize JSON")? + ); + } else { + println!( + "Updated: {} ({}) [roleType: {}]", + user.user_id.as_deref().unwrap_or("-"), + user.name, + user.role_type + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::user::User; + use anyhow::anyhow; + use std::collections::BTreeMap; + + struct MockApi { + user: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn update_user(&self, _user_id: u64, _params: &[(String, String)]) -> anyhow::Result { + self.user.clone().ok_or_else(|| anyhow!("update failed")) + } + } + + fn sample_user() -> User { + User { + id: 1, + user_id: Some("john".to_string()), + name: "John Doe".to_string(), + mail_address: Some("john@example.com".to_string()), + role_type: 2, + lang: None, + last_login_time: None, + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> UserUpdateArgs { + UserUpdateArgs::new(1, Some("John Doe".to_string()), None, None, None, json) + } + + #[test] + fn update_with_text_output_succeeds() { + let api = MockApi { + user: Some(sample_user()), + }; + assert!(update_with(&args(false), &api).is_ok()); + } + + #[test] + fn update_with_json_output_succeeds() { + let api = MockApi { + user: Some(sample_user()), + }; + assert!(update_with(&args(true), &api).is_ok()); + } + + #[test] + fn update_with_propagates_api_error() { + let api = MockApi { user: None }; + let err = update_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("update failed")); + } +} diff --git a/src/main.rs b/src/main.rs index aefad8e..c96f5dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,7 +42,11 @@ use cmd::space::{ SpaceShowArgs, SpaceUpdateNotificationArgs, }; use cmd::team::{TeamListArgs, TeamShowArgs}; -use cmd::user::{UserActivitiesArgs, UserListArgs, UserRecentlyViewedArgs, UserShowArgs}; +use cmd::user::star::{UserStarCountArgs, UserStarListArgs}; +use cmd::user::{ + UserActivitiesArgs, UserAddArgs, UserDeleteArgs, UserListArgs, UserRecentlyViewedArgs, + UserRecentlyViewedProjectsArgs, UserRecentlyViewedWikisArgs, UserShowArgs, UserUpdateArgs, +}; use cmd::wiki::attachment::WikiAttachmentListArgs; use cmd::wiki::{ WikiCreateArgs, WikiDeleteArgs, WikiHistoryArgs, WikiListArgs, WikiShowArgs, WikiUpdateArgs, @@ -144,6 +148,29 @@ impl TextFormattingRule { } } +#[derive(clap::ValueEnum, Clone)] +enum RoleType { + Administrator, + Normal, + Reporter, + Viewer, + GuestReporter, + GuestViewer, +} + +impl RoleType { + fn as_u8(&self) -> u8 { + match self { + RoleType::Administrator => 1, + RoleType::Normal => 2, + RoleType::Reporter => 3, + RoleType::Viewer => 4, + RoleType::GuestReporter => 5, + RoleType::GuestViewer => 6, + } + } +} + #[derive(Subcommand)] enum SpaceCommands { /// Show recent space activities @@ -868,6 +895,128 @@ enum UserCommands { #[arg(long)] json: bool, }, + /// Add a new user + Add { + /// User ID (login name) + #[arg(long)] + user_id: String, + /// Password + #[arg(long)] + password: String, + /// Display name + #[arg(long)] + name: String, + /// Email address + #[arg(long)] + mail_address: String, + /// Role type + #[arg(long)] + role_type: RoleType, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Update a user + Update { + /// User numeric ID + id: u64, + /// Display name + #[arg(long)] + name: Option, + /// Password + #[arg(long)] + password: Option, + /// Email address + #[arg(long)] + mail_address: Option, + /// Role type + #[arg(long)] + role_type: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Delete a user + Delete { + /// User numeric ID + id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Show recently viewed projects (for the authenticated user) + RecentlyViewedProjects { + /// Number of items to retrieve + #[arg(long, default_value = "20")] + count: u32, + /// Offset for pagination + #[arg(long, default_value = "0")] + offset: u64, + /// Sort order + #[arg(long)] + order: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Show recently viewed wikis (for the authenticated user) + RecentlyViewedWikis { + /// Number of items to retrieve + #[arg(long, default_value = "20")] + count: u32, + /// Offset for pagination + #[arg(long, default_value = "0")] + offset: u64, + /// Sort order + #[arg(long)] + order: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Manage user stars + Star { + #[command(subcommand)] + action: UserStarCommands, + }, +} + +#[derive(Subcommand)] +enum UserStarCommands { + /// List stars of a user + List { + /// User numeric ID + id: u64, + /// Minimum star ID + #[arg(long)] + min_id: Option, + /// Maximum star ID + #[arg(long)] + max_id: Option, + /// Number of stars to retrieve + #[arg(long, default_value = "20")] + count: u32, + /// Sort order + #[arg(long)] + order: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Count stars of a user + Count { + /// User numeric ID + id: u64, + /// Start date (YYYY-MM-DD) + #[arg(long)] + since: Option, + /// End date (YYYY-MM-DD) + #[arg(long)] + until: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -1400,6 +1549,82 @@ fn run() -> Result<()> { offset, order.map(|o| o.as_str().to_string()), )?), + UserCommands::Add { + user_id, + password, + name, + mail_address, + role_type, + json, + } => cmd::user::add(&UserAddArgs::new( + user_id, + password, + name, + mail_address, + role_type.as_u8(), + json, + )), + UserCommands::Update { + id, + name, + password, + mail_address, + role_type, + json, + } => cmd::user::update(&UserUpdateArgs::new( + id, + name, + password, + mail_address, + role_type.map(|r| r.as_u8()), + json, + )), + UserCommands::Delete { id, json } => cmd::user::delete(&UserDeleteArgs::new(id, json)), + UserCommands::RecentlyViewedProjects { + count, + offset, + order, + json, + } => cmd::user::recently_viewed_projects(&UserRecentlyViewedProjectsArgs::try_new( + json, + count, + offset, + order.map(|o| o.as_str().to_string()), + )?), + UserCommands::RecentlyViewedWikis { + count, + offset, + order, + json, + } => cmd::user::recently_viewed_wikis(&UserRecentlyViewedWikisArgs::try_new( + json, + count, + offset, + order.map(|o| o.as_str().to_string()), + )?), + UserCommands::Star { action } => match action { + UserStarCommands::List { + id, + min_id, + max_id, + count, + order, + json, + } => cmd::user::star::list(&UserStarListArgs::try_new( + id, + min_id, + max_id, + count, + order.map(|o| o.as_str().to_string()), + json, + )?), + UserStarCommands::Count { + id, + since, + until, + json, + } => cmd::user::star::count(&UserStarCountArgs::new(id, since, until, json)), + }, }, Commands::Team { action } => match action { TeamCommands::List { diff --git a/website/docs/commands.md b/website/docs/commands.md index f7f961f..d44f7a9 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -805,6 +805,118 @@ Example output: [BLG-2] Add dark mode (In Progress, John Doe) ``` +## `bl user add` + +Add a new user. Requires Space Administrator privileges. + +```bash +bl user add --user-id john --password secret --name "John Doe" --mail-address john@example.com --role-type normal +bl user add --user-id john --password secret --name "John Doe" --mail-address john@example.com --role-type normal --json +``` + +Role types: `administrator`, `normal`, `reporter`, `viewer`, `guest-reporter`, `guest-viewer`. + +Example output: + +```text +Added: john (John Doe) [roleType: 2] +``` + +## `bl user update` + +Update an existing user. Requires Space Administrator privileges. + +```bash +bl user update --name "New Name" +bl user update --mail-address new@example.com --role-type viewer --json +``` + +Example output: + +```text +Updated: john (New Name) [roleType: 4] +``` + +## `bl user delete` + +Delete a user. Requires Space Administrator privileges. + +```bash +bl user delete +bl user delete --json +``` + +Example output: + +```text +Deleted: john (John Doe) +``` + +## `bl user recently-viewed-projects` + +Show projects recently viewed by the authenticated user. + +```bash +bl user recently-viewed-projects +bl user recently-viewed-projects --count 50 --offset 20 --order asc +bl user recently-viewed-projects --json +``` + +Example output: + +```text +[MYPRJ] My Project +[TEST] Test Project +``` + +## `bl user recently-viewed-wikis` + +Show wiki pages recently viewed by the authenticated user. + +```bash +bl user recently-viewed-wikis +bl user recently-viewed-wikis --count 50 --offset 20 --order asc +bl user recently-viewed-wikis --json +``` + +Example output: + +```text +[1] Home (project: 1) +[2] API Reference (project: 2) +``` + +## `bl user star list` + +List stars given by a user. + +```bash +bl user star list +bl user star list --count 50 --order asc --json +``` + +Example output: + +```text +[1] Fix login bug +[2] Add dark mode feature +``` + +## `bl user star count` + +Count stars given by a user. + +```bash +bl user star count +bl user star count --since 2024-01-01 --until 2024-12-31 --json +``` + +Example output: + +```text +42 +``` + ## `bl user list` List all users in the space. @@ -1046,8 +1158,8 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | --- | --- | --- | | `bl star add` | `POST /api/v2/stars` | Planned | | `bl star delete ` | `DELETE /api/v2/stars/{starId}` | Planned | -| `bl user star list ` | `GET /api/v2/users/{userId}/stars` | Planned | -| `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | Planned | +| `bl user star list ` | `GET /api/v2/users/{userId}/stars` | ✅ Implemented | +| `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | ✅ Implemented | ### Pull Requests @@ -1080,15 +1192,15 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | `bl auth status` | `GET /api/v2/users/myself` | ✅ Implemented (internal) | | `bl user list` | `GET /api/v2/users` | ✅ Implemented | | `bl user show ` | `GET /api/v2/users/{userId}` | ✅ Implemented | -| `bl user add` | `POST /api/v2/users` | Planned | -| `bl user update ` | `PATCH /api/v2/users/{userId}` | Planned | -| `bl user delete ` | `DELETE /api/v2/users/{userId}` | Planned | +| `bl user add` | `POST /api/v2/users` | ✅ Implemented | +| `bl user update ` | `PATCH /api/v2/users/{userId}` | ✅ Implemented | +| `bl user delete ` | `DELETE /api/v2/users/{userId}` | ✅ Implemented | | `bl user activities ` | `GET /api/v2/users/{userId}/activities` | ✅ Implemented | | `bl user recently-viewed` | `GET /api/v2/users/myself/recentlyViewedIssues` | ✅ Implemented | -| `bl user recently-viewed-projects` | `GET /api/v2/users/myself/recentlyViewedProjects` | Planned | -| `bl user recently-viewed-wikis` | `GET /api/v2/users/myself/recentlyViewedWikis` | Planned | -| `bl user star list ` | `GET /api/v2/users/{userId}/stars` | Planned | -| `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | Planned | +| `bl user recently-viewed-projects` | `GET /api/v2/users/myself/recentlyViewedProjects` | ✅ Implemented | +| `bl user recently-viewed-wikis` | `GET /api/v2/users/myself/recentlyViewedWikis` | ✅ Implemented | +| `bl user star list ` | `GET /api/v2/users/{userId}/stars` | ✅ Implemented | +| `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | ✅ Implemented | | — | `GET /api/v2/users/{userId}/icon` | Planned | ### Notifications 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 9565f52..628ffe7 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -808,6 +808,118 @@ bl user recently-viewed --offset 10 [BLG-2] Add dark mode (In Progress, John Doe) ``` +## `bl user add` + +新しいユーザーを追加します。スペース管理者権限が必要です。 + +```bash +bl user add --user-id john --password secret --name "John Doe" --mail-address john@example.com --role-type normal +bl user add --user-id john --password secret --name "John Doe" --mail-address john@example.com --role-type normal --json +``` + +ロールタイプ: `administrator`, `normal`, `reporter`, `viewer`, `guest-reporter`, `guest-viewer` + +出力例: + +```text +Added: john (John Doe) [roleType: 2] +``` + +## `bl user update` + +既存のユーザーを更新します。スペース管理者権限が必要です。 + +```bash +bl user update --name "New Name" +bl user update --mail-address new@example.com --role-type viewer --json +``` + +出力例: + +```text +Updated: john (New Name) [roleType: 4] +``` + +## `bl user delete` + +ユーザーを削除します。スペース管理者権限が必要です。 + +```bash +bl user delete +bl user delete --json +``` + +出力例: + +```text +Deleted: john (John Doe) +``` + +## `bl user recently-viewed-projects` + +認証ユーザーが最近閲覧したプロジェクトを表示します。 + +```bash +bl user recently-viewed-projects +bl user recently-viewed-projects --count 50 --offset 20 --order asc +bl user recently-viewed-projects --json +``` + +出力例: + +```text +[MYPRJ] My Project +[TEST] Test Project +``` + +## `bl user recently-viewed-wikis` + +認証ユーザーが最近閲覧したWikiページを表示します。 + +```bash +bl user recently-viewed-wikis +bl user recently-viewed-wikis --count 50 --offset 20 --order asc +bl user recently-viewed-wikis --json +``` + +出力例: + +```text +[1] Home (project: 1) +[2] API Reference (project: 2) +``` + +## `bl user star list` + +ユーザーがスターをつけた一覧を表示します。 + +```bash +bl user star list +bl user star list --count 50 --order asc --json +``` + +出力例: + +```text +[1] Fix login bug +[2] Add dark mode feature +``` + +## `bl user star count` + +ユーザーがつけたスターの数を表示します。 + +```bash +bl user star count +bl user star count --since 2024-01-01 --until 2024-12-31 --json +``` + +出力例: + +```text +42 +``` + ## `bl user list` スペース内のユーザーを一覧表示します。 @@ -1050,8 +1162,8 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | --- | --- | --- | | `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` | 計画中 | +| `bl user star list ` | `GET /api/v2/users/{userId}/stars` | ✅ 実装済み | +| `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | ✅ 実装済み | ### Pull Requests @@ -1084,15 +1196,15 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `bl auth status` | `GET /api/v2/users/myself` | ✅ 実装済み(内部) | | `bl user list` | `GET /api/v2/users` | ✅ 実装済み | | `bl user show ` | `GET /api/v2/users/{userId}` | ✅ 実装済み | -| `bl user add` | `POST /api/v2/users` | 計画中 | -| `bl user update ` | `PATCH /api/v2/users/{userId}` | 計画中 | -| `bl user delete ` | `DELETE /api/v2/users/{userId}` | 計画中 | +| `bl user add` | `POST /api/v2/users` | ✅ 実装済み | +| `bl user update ` | `PATCH /api/v2/users/{userId}` | ✅ 実装済み | +| `bl user delete ` | `DELETE /api/v2/users/{userId}` | ✅ 実装済み | | `bl user activities ` | `GET /api/v2/users/{userId}/activities` | ✅ 実装済み | | `bl user recently-viewed` | `GET /api/v2/users/myself/recentlyViewedIssues` | ✅ 実装済み | -| `bl user recently-viewed-projects` | `GET /api/v2/users/myself/recentlyViewedProjects` | 計画中 | -| `bl user recently-viewed-wikis` | `GET /api/v2/users/myself/recentlyViewedWikis` | 計画中 | -| `bl user star list ` | `GET /api/v2/users/{userId}/stars` | 計画中 | -| `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | 計画中 | +| `bl user recently-viewed-projects` | `GET /api/v2/users/myself/recentlyViewedProjects` | ✅ 実装済み | +| `bl user recently-viewed-wikis` | `GET /api/v2/users/myself/recentlyViewedWikis` | ✅ 実装済み | +| `bl user star list ` | `GET /api/v2/users/{userId}/stars` | ✅ 実装済み | +| `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | ✅ 実装済み | | — | `GET /api/v2/users/{userId}/icon` | 計画中 | ### Notifications From ba884dbd1cd47a09b979d56d88be462f0dbccb46 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Mon, 16 Mar 2026 22:03:46 +0900 Subject: [PATCH 2/4] docs: add --min-id/--max-id examples for bl user star list Addresses review comment: flags not shown in docs, hard to discover --- website/docs/commands.md | 1 + .../i18n/ja/docusaurus-plugin-content-docs/current/commands.md | 1 + 2 files changed, 2 insertions(+) diff --git a/website/docs/commands.md b/website/docs/commands.md index d44f7a9..2930996 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -893,6 +893,7 @@ List stars given by a user. ```bash bl user star list bl user star list --count 50 --order asc --json +bl user star list --min-id 100 --max-id 200 --json ``` Example output: 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 628ffe7..dadb3bb 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -896,6 +896,7 @@ bl user recently-viewed-wikis --json ```bash bl user star list bl user star list --count 50 --order asc --json +bl user star list --min-id 100 --max-id 200 --json ``` 出力例: From 6d7e9b13dec43c3260f98c8ec66ff0e304372a08 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Mon, 16 Mar 2026 22:06:23 +0900 Subject: [PATCH 3/4] fix: UserUpdateArgs::try_new rejects all-None update fields Addresses review comment: empty PATCH should be rejected locally --- src/cmd/user/update.rs | 20 +++++++++++++++----- src/main.rs | 4 ++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/cmd/user/update.rs b/src/cmd/user/update.rs index 25a6eae..65ac660 100644 --- a/src/cmd/user/update.rs +++ b/src/cmd/user/update.rs @@ -13,22 +13,27 @@ pub struct UserUpdateArgs { } impl UserUpdateArgs { - pub fn new( + pub fn try_new( user_id: u64, name: Option, password: Option, mail_address: Option, role_type: Option, json: bool, - ) -> Self { - Self { + ) -> anyhow::Result { + if name.is_none() && password.is_none() && mail_address.is_none() && role_type.is_none() { + anyhow::bail!( + "at least one of --name, --password, --mail-address, --role-type must be specified" + ); + } + Ok(Self { user_id, name, password, mail_address, role_type, json, - } + }) } } @@ -99,7 +104,7 @@ mod tests { } fn args(json: bool) -> UserUpdateArgs { - UserUpdateArgs::new(1, Some("John Doe".to_string()), None, None, None, json) + UserUpdateArgs::try_new(1, Some("John Doe".to_string()), None, None, None, json).unwrap() } #[test] @@ -124,4 +129,9 @@ mod tests { let err = update_with(&args(false), &api).unwrap_err(); assert!(err.to_string().contains("update failed")); } + + #[test] + fn try_new_rejects_all_none_fields() { + assert!(UserUpdateArgs::try_new(1, None, None, None, None, false).is_err()); + } } diff --git a/src/main.rs b/src/main.rs index c96f5dd..bd38883 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1571,14 +1571,14 @@ fn run() -> Result<()> { mail_address, role_type, json, - } => cmd::user::update(&UserUpdateArgs::new( + } => cmd::user::update(&UserUpdateArgs::try_new( id, name, password, mail_address, role_type.map(|r| r.as_u8()), json, - )), + )?), UserCommands::Delete { id, json } => cmd::user::delete(&UserDeleteArgs::new(id, json)), UserCommands::RecentlyViewedProjects { count, From 403d90165fbe78dc0eb5787bf713ea1d6e2bcef9 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Mon, 16 Mar 2026 22:08:22 +0900 Subject: [PATCH 4/4] fix: reject min-id > max-id in bl user star list Addresses review comment: contradictory query params should be validated --- src/cmd/user/star/list.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/cmd/user/star/list.rs b/src/cmd/user/star/list.rs index 3792f0e..ecbb180 100644 --- a/src/cmd/user/star/list.rs +++ b/src/cmd/user/star/list.rs @@ -24,6 +24,11 @@ impl UserStarListArgs { if !(1..=100).contains(&count) { anyhow::bail!("count must be between 1 and 100"); } + if let (Some(min), Some(max)) = (min_id, max_id) + && min > max + { + anyhow::bail!("min-id must be less than or equal to max-id"); + } Ok(Self { user_id, min_id, @@ -144,4 +149,9 @@ mod tests { fn try_new_rejects_count_zero() { assert!(UserStarListArgs::try_new(1, None, None, 0, None, false).is_err()); } + + #[test] + fn try_new_rejects_min_id_greater_than_max_id() { + assert!(UserStarListArgs::try_new(1, Some(200), Some(100), 20, None, false).is_err()); + } }