From 44bcc2251e1ab2277808de84b6608ab971e852d9 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Thu, 12 Mar 2026 22:00:15 +0900 Subject: [PATCH 1/3] feat: add bl user activities and bl user recently-viewed commands --- src/api/mod.rs | 12 +- src/api/user.rs | 115 ++++++ src/cmd/auth.rs | 11 + src/cmd/issue/attachment/list.rs | 11 + src/cmd/issue/comment/add.rs | 11 + src/cmd/issue/comment/delete.rs | 11 + src/cmd/issue/comment/list.rs | 11 + src/cmd/issue/comment/update.rs | 11 + src/cmd/issue/count.rs | 11 + src/cmd/issue/create.rs | 11 + src/cmd/issue/delete.rs | 11 + src/cmd/issue/list.rs | 11 + src/cmd/issue/show.rs | 11 + src/cmd/issue/update.rs | 11 + src/cmd/project/activities.rs | 11 + src/cmd/project/category.rs | 11 + src/cmd/project/disk_usage.rs | 11 + src/cmd/project/issue_type.rs | 11 + src/cmd/project/list.rs | 11 + src/cmd/project/show.rs | 11 + src/cmd/project/status.rs | 11 + src/cmd/project/user.rs | 11 + src/cmd/project/version.rs | 11 + src/cmd/space/activities.rs | 11 + src/cmd/space/disk_usage.rs | 11 + src/cmd/space/notification.rs | 11 + src/cmd/space/show.rs | 11 + src/cmd/team/list.rs | 11 + src/cmd/team/show.rs | 11 + src/cmd/user/activities.rs | 301 +++++++++++++++ src/cmd/user/list.rs | 11 + src/cmd/user/mod.rs | 4 + src/cmd/user/recently_viewed.rs | 342 ++++++++++++++++++ src/cmd/user/show.rs | 11 + src/cmd/wiki/attachment/list.rs | 11 + src/cmd/wiki/create.rs | 11 + src/cmd/wiki/delete.rs | 11 + src/cmd/wiki/history.rs | 11 + src/cmd/wiki/list.rs | 11 + src/cmd/wiki/show.rs | 11 + src/cmd/wiki/update.rs | 11 + src/main.rs | 22 +- website/docs/commands.md | 35 +- .../current/commands.md | 35 +- 44 files changed, 1256 insertions(+), 6 deletions(-) create mode 100644 src/cmd/user/activities.rs create mode 100644 src/cmd/user/recently_viewed.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 5d6f6cf..6f57362 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -26,7 +26,7 @@ use project::{ use space::Space; use space_notification::SpaceNotification; use team::Team; -use user::User; +use user::{RecentlyViewedIssue, User}; use wiki::{Wiki, WikiAttachment, WikiHistory, WikiListItem}; pub trait BacklogApi { @@ -71,6 +71,8 @@ pub trait BacklogApi { fn get_wiki_attachments(&self, wiki_id: u64) -> Result>; fn get_teams(&self) -> Result>; fn get_team(&self, team_id: u64) -> Result; + fn get_user_activities(&self, user_id: u64) -> Result>; + fn get_recently_viewed_issues(&self) -> Result>; } impl BacklogApi for BacklogClient { @@ -222,6 +224,14 @@ impl BacklogApi for BacklogClient { fn get_team(&self, team_id: u64) -> Result { self.get_team(team_id) } + + fn get_user_activities(&self, user_id: u64) -> Result> { + self.get_user_activities(user_id) + } + + fn get_recently_viewed_issues(&self) -> Result> { + self.get_recently_viewed_issues() + } } /// How the client authenticates with Backlog. diff --git a/src/api/user.rs b/src/api/user.rs index 962cbb1..ac5211c 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use super::BacklogClient; +use crate::api::activity::Activity; +use crate::api::issue::Issue; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -22,6 +24,13 @@ pub struct User { pub extra: BTreeMap, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentlyViewedIssue { + pub issue: Issue, + pub updated: String, +} + impl BacklogClient { pub fn get_myself(&self) -> Result { let value = self.get("/users/myself")?; @@ -40,6 +49,28 @@ impl BacklogClient { serde_json::from_value(value) .map_err(|e| anyhow::anyhow!("Failed to deserialize user response: {}", e)) } + + pub fn get_user_activities(&self, user_id: u64) -> Result> { + let value = self.get(&format!("/users/{user_id}/activities"))?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize user activities response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } + + pub fn get_recently_viewed_issues(&self) -> Result> { + let value = self.get("/users/myself/recentlyViewedIssues")?; + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize recently viewed issues response: {}\nRaw JSON:\n{}", + e, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) + } } #[cfg(test)] @@ -156,6 +187,90 @@ mod tests { assert_eq!(user.role_type, 1); } + #[test] + fn get_user_activities_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/users/123/activities"); + then.status(200).json_body(json!([{ + "id": 1, + "type": 2, + "content": {}, + "createdUser": {"id": 123, "userId": "john", "name": "John Doe"}, + "created": "2024-01-01T00:00:00Z" + }])); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let activities = client.get_user_activities(123).unwrap(); + assert_eq!(activities.len(), 1); + assert_eq!(activities[0].id, 1); + } + + #[test] + fn get_user_activities_returns_error_on_api_failure() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/users/123/activities"); + then.status(404) + .json_body(json!({"errors": [{"message": "No user"}]})); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let err = client.get_user_activities(123).unwrap_err(); + assert!(err.to_string().contains("No user")); + } + + #[test] + fn get_recently_viewed_issues_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/users/myself/recentlyViewedIssues"); + then.status(200).json_body(json!([{ + "issue": { + "id": 1, + "projectId": 1, + "issueKey": "BLG-1", + "keyId": 1, + "summary": "first issue", + "description": "", + "priority": {"id": 3, "name": "Normal"}, + "status": {"id": 1, "projectId": 1, "name": "Open", "color": "#ed8077", "displayOrder": 1000}, + "issueType": {"id": 2, "projectId": 1, "name": "Task", "color": "#7ea800", "displayOrder": 0}, + "assignee": null, + "category": [], + "versions": [], + "milestone": [], + "created": "2024-01-01T00:00:00Z", + "updated": "2024-06-01T00:00:00Z", + "createdUser": {"id": 1, "userId": "admin", "name": "admin", "roleType": 1}, + "updatedUser": {"id": 1, "userId": "admin", "name": "admin", "roleType": 1} + }, + "updated": "2024-06-01T00:00:00Z" + }])); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let items = client.get_recently_viewed_issues().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].issue.issue_key, "BLG-1"); + assert_eq!(items[0].updated, "2024-06-01T00:00:00Z"); + } + + #[test] + fn get_recently_viewed_issues_returns_error_on_api_failure() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/users/myself/recentlyViewedIssues"); + then.status(401) + .json_body(json!({"errors": [{"message": "Authentication failure"}]})); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let err = client.get_recently_viewed_issues().unwrap_err(); + assert!(err.to_string().contains("Authentication failure")); + } + #[test] fn deserialize_user_with_null_user_id() { let v = json!({ diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index f1d15a1..be68517 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -563,6 +563,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_user() -> User { diff --git a/src/cmd/issue/attachment/list.rs b/src/cmd/issue/attachment/list.rs index 414d2db..e4ac81e 100644 --- a/src/cmd/issue/attachment/list.rs +++ b/src/cmd/issue/attachment/list.rs @@ -240,6 +240,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueAttachmentListArgs { diff --git a/src/cmd/issue/comment/add.rs b/src/cmd/issue/comment/add.rs index 3aa8496..ff25863 100644 --- a/src/cmd/issue/comment/add.rs +++ b/src/cmd/issue/comment/add.rs @@ -211,6 +211,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueCommentAddArgs { diff --git a/src/cmd/issue/comment/delete.rs b/src/cmd/issue/comment/delete.rs index 89082fd..f5fd530 100644 --- a/src/cmd/issue/comment/delete.rs +++ b/src/cmd/issue/comment/delete.rs @@ -213,6 +213,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueCommentDeleteArgs { diff --git a/src/cmd/issue/comment/list.rs b/src/cmd/issue/comment/list.rs index ae5d3a2..af0645e 100644 --- a/src/cmd/issue/comment/list.rs +++ b/src/cmd/issue/comment/list.rs @@ -252,6 +252,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueCommentListArgs { diff --git a/src/cmd/issue/comment/update.rs b/src/cmd/issue/comment/update.rs index b86bbda..2dbfb50 100644 --- a/src/cmd/issue/comment/update.rs +++ b/src/cmd/issue/comment/update.rs @@ -217,6 +217,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueCommentUpdateArgs { diff --git a/src/cmd/issue/count.rs b/src/cmd/issue/count.rs index be65915..162aba0 100644 --- a/src/cmd/issue/count.rs +++ b/src/cmd/issue/count.rs @@ -268,6 +268,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueCountArgs { diff --git a/src/cmd/issue/create.rs b/src/cmd/issue/create.rs index 79a5444..dfae6dd 100644 --- a/src/cmd/issue/create.rs +++ b/src/cmd/issue/create.rs @@ -250,6 +250,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueCreateArgs { diff --git a/src/cmd/issue/delete.rs b/src/cmd/issue/delete.rs index a3d6e37..b37fc94 100644 --- a/src/cmd/issue/delete.rs +++ b/src/cmd/issue/delete.rs @@ -208,6 +208,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueDeleteArgs { diff --git a/src/cmd/issue/list.rs b/src/cmd/issue/list.rs index c18c7d4..57a8cbd 100644 --- a/src/cmd/issue/list.rs +++ b/src/cmd/issue/list.rs @@ -359,6 +359,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueListArgs { diff --git a/src/cmd/issue/show.rs b/src/cmd/issue/show.rs index 33daa92..7471088 100644 --- a/src/cmd/issue/show.rs +++ b/src/cmd/issue/show.rs @@ -232,6 +232,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueShowArgs { diff --git a/src/cmd/issue/update.rs b/src/cmd/issue/update.rs index f10177f..25d617a 100644 --- a/src/cmd/issue/update.rs +++ b/src/cmd/issue/update.rs @@ -273,6 +273,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> IssueUpdateArgs { diff --git a/src/cmd/project/activities.rs b/src/cmd/project/activities.rs index 2e2ed1e..35b6b8e 100644 --- a/src/cmd/project/activities.rs +++ b/src/cmd/project/activities.rs @@ -240,6 +240,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_activity() -> Activity { diff --git a/src/cmd/project/category.rs b/src/cmd/project/category.rs index 4e0ed24..9d333c6 100644 --- a/src/cmd/project/category.rs +++ b/src/cmd/project/category.rs @@ -230,6 +230,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_category() -> ProjectCategory { diff --git a/src/cmd/project/disk_usage.rs b/src/cmd/project/disk_usage.rs index 4df3723..471244b 100644 --- a/src/cmd/project/disk_usage.rs +++ b/src/cmd/project/disk_usage.rs @@ -237,6 +237,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_disk_usage() -> ProjectDiskUsage { diff --git a/src/cmd/project/issue_type.rs b/src/cmd/project/issue_type.rs index c84442a..89c0391 100644 --- a/src/cmd/project/issue_type.rs +++ b/src/cmd/project/issue_type.rs @@ -230,6 +230,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_issue_type() -> ProjectIssueType { diff --git a/src/cmd/project/list.rs b/src/cmd/project/list.rs index fa5d4d2..83689d2 100644 --- a/src/cmd/project/list.rs +++ b/src/cmd/project/list.rs @@ -239,6 +239,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_project() -> Project { diff --git a/src/cmd/project/show.rs b/src/cmd/project/show.rs index 339a5c0..b4fe1de 100644 --- a/src/cmd/project/show.rs +++ b/src/cmd/project/show.rs @@ -233,6 +233,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_project() -> Project { diff --git a/src/cmd/project/status.rs b/src/cmd/project/status.rs index 11daa0a..b65e58e 100644 --- a/src/cmd/project/status.rs +++ b/src/cmd/project/status.rs @@ -228,6 +228,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_status() -> ProjectStatus { diff --git a/src/cmd/project/user.rs b/src/cmd/project/user.rs index 9c5915c..fcb131e 100644 --- a/src/cmd/project/user.rs +++ b/src/cmd/project/user.rs @@ -232,6 +232,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_user() -> ProjectUser { diff --git a/src/cmd/project/version.rs b/src/cmd/project/version.rs index a847ab5..480f794 100644 --- a/src/cmd/project/version.rs +++ b/src/cmd/project/version.rs @@ -235,6 +235,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_version() -> ProjectVersion { diff --git a/src/cmd/space/activities.rs b/src/cmd/space/activities.rs index 1b4682b..b394fc3 100644 --- a/src/cmd/space/activities.rs +++ b/src/cmd/space/activities.rs @@ -238,6 +238,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_activity() -> Activity { diff --git a/src/cmd/space/disk_usage.rs b/src/cmd/space/disk_usage.rs index 1915df8..6158c4f 100644 --- a/src/cmd/space/disk_usage.rs +++ b/src/cmd/space/disk_usage.rs @@ -237,6 +237,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_disk_usage() -> DiskUsage { diff --git a/src/cmd/space/notification.rs b/src/cmd/space/notification.rs index add2926..5273311 100644 --- a/src/cmd/space/notification.rs +++ b/src/cmd/space/notification.rs @@ -231,6 +231,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_notification() -> SpaceNotification { diff --git a/src/cmd/space/show.rs b/src/cmd/space/show.rs index 8c6d601..664a73b 100644 --- a/src/cmd/space/show.rs +++ b/src/cmd/space/show.rs @@ -234,6 +234,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_space() -> Space { diff --git a/src/cmd/team/list.rs b/src/cmd/team/list.rs index ed808d2..0e3497d 100644 --- a/src/cmd/team/list.rs +++ b/src/cmd/team/list.rs @@ -233,6 +233,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_member() -> TeamMember { diff --git a/src/cmd/team/show.rs b/src/cmd/team/show.rs index 180e0e7..dbb9d0c 100644 --- a/src/cmd/team/show.rs +++ b/src/cmd/team/show.rs @@ -251,6 +251,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { self.team.clone().ok_or_else(|| anyhow!("no team")) } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_member() -> TeamMember { diff --git a/src/cmd/user/activities.rs b/src/cmd/user/activities.rs new file mode 100644 index 0000000..52c102a --- /dev/null +++ b/src/cmd/user/activities.rs @@ -0,0 +1,301 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient, activity::Activity}; + +pub struct UserActivitiesArgs { + user_id: u64, + json: bool, +} + +impl UserActivitiesArgs { + pub fn new(user_id: u64, json: bool) -> Self { + Self { user_id, json } + } +} + +pub fn activities(args: &UserActivitiesArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + activities_with(args, &client) +} + +pub fn activities_with(args: &UserActivitiesArgs, api: &dyn BacklogApi) -> Result<()> { + let activities = api.get_user_activities(args.user_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&activities).context("Failed to serialize JSON")? + ); + } else { + for a in &activities { + println!("{}", format_activity_row(a)); + } + } + Ok(()) +} + +fn format_activity_row(a: &Activity) -> String { + let project = a + .project + .as_ref() + .map(|p| p.project_key.as_str()) + .unwrap_or("-"); + format!( + "[{}] type={} project={} user={} created={}", + a.id, a.activity_type, project, a.created_user.name, a.created, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::activity::ActivityUser; + use anyhow::anyhow; + + struct MockApi { + activities: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_space(&self) -> anyhow::Result { + unimplemented!() + } + fn get_myself(&self) -> anyhow::Result { + unimplemented!() + } + fn get_users(&self) -> anyhow::Result> { + unimplemented!() + } + fn get_user(&self, _user_id: u64) -> anyhow::Result { + unimplemented!() + } + fn get_space_activities(&self) -> anyhow::Result> { + unimplemented!() + } + fn get_space_disk_usage(&self) -> anyhow::Result { + unimplemented!() + } + fn get_space_notification( + &self, + ) -> anyhow::Result { + unimplemented!() + } + fn get_projects(&self) -> anyhow::Result> { + unimplemented!() + } + fn get_project(&self, _key: &str) -> anyhow::Result { + unimplemented!() + } + fn get_project_activities(&self, _key: &str) -> anyhow::Result> { + unimplemented!() + } + fn get_project_disk_usage( + &self, + _key: &str, + ) -> anyhow::Result { + unimplemented!() + } + fn get_project_users( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_project_statuses( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_project_issue_types( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_project_categories( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_project_versions( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_issues( + &self, + _params: &[(String, String)], + ) -> anyhow::Result> { + unimplemented!() + } + fn count_issues( + &self, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn get_issue(&self, _key: &str) -> anyhow::Result { + unimplemented!() + } + fn create_issue( + &self, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn update_issue( + &self, + _key: &str, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn delete_issue(&self, _key: &str) -> anyhow::Result { + unimplemented!() + } + fn get_issue_comments( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn add_issue_comment( + &self, + _key: &str, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn update_issue_comment( + &self, + _key: &str, + _comment_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn delete_issue_comment( + &self, + _key: &str, + _comment_id: u64, + ) -> anyhow::Result { + unimplemented!() + } + fn get_issue_attachments( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_wikis( + &self, + _params: &[(String, String)], + ) -> anyhow::Result> { + unimplemented!() + } + fn get_wiki(&self, _wiki_id: u64) -> anyhow::Result { + unimplemented!() + } + fn create_wiki( + &self, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn update_wiki( + &self, + _wiki_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn delete_wiki( + &self, + _wiki_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn get_wiki_history( + &self, + _wiki_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_wiki_attachments( + &self, + _wiki_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_teams(&self) -> anyhow::Result> { + unimplemented!() + } + fn get_team(&self, _team_id: u64) -> anyhow::Result { + unimplemented!() + } + fn get_user_activities(&self, _user_id: u64) -> anyhow::Result> { + self.activities + .clone() + .ok_or_else(|| anyhow!("no activities")) + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } + } + + fn sample_activity() -> Activity { + Activity { + id: 10, + project: None, + activity_type: 2, + content: serde_json::Value::Null, + created_user: ActivityUser { + id: 1, + user_id: Some("john".to_string()), + name: "John Doe".to_string(), + extra: Default::default(), + }, + created: "2024-06-01T00:00:00Z".to_string(), + extra: Default::default(), + } + } + + #[test] + fn format_activity_row_contains_fields() { + let text = format_activity_row(&sample_activity()); + assert!(text.contains("[10]")); + assert!(text.contains("type=2")); + assert!(text.contains("project=-")); + assert!(text.contains("John Doe")); + assert!(text.contains("2024-06-01T00:00:00Z")); + } + + #[test] + fn activities_with_text_output_succeeds() { + let api = MockApi { + activities: Some(vec![sample_activity()]), + }; + assert!(activities_with(&UserActivitiesArgs::new(1, false), &api).is_ok()); + } + + #[test] + fn activities_with_json_output_succeeds() { + let api = MockApi { + activities: Some(vec![sample_activity()]), + }; + assert!(activities_with(&UserActivitiesArgs::new(1, true), &api).is_ok()); + } + + #[test] + fn activities_with_propagates_api_error() { + let api = MockApi { activities: None }; + let err = activities_with(&UserActivitiesArgs::new(1, false), &api).unwrap_err(); + assert!(err.to_string().contains("no activities")); + } +} diff --git a/src/cmd/user/list.rs b/src/cmd/user/list.rs index 38c980d..77c8a77 100644 --- a/src/cmd/user/list.rs +++ b/src/cmd/user/list.rs @@ -234,6 +234,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_user() -> User { diff --git a/src/cmd/user/mod.rs b/src/cmd/user/mod.rs index 2249afb..5edd814 100644 --- a/src/cmd/user/mod.rs +++ b/src/cmd/user/mod.rs @@ -1,5 +1,9 @@ +mod activities; mod list; +mod recently_viewed; mod show; +pub use activities::{UserActivitiesArgs, activities}; pub use list::{UserListArgs, list}; +pub use recently_viewed::{UserRecentlyViewedArgs, recently_viewed}; pub use show::{UserShowArgs, show}; diff --git a/src/cmd/user/recently_viewed.rs b/src/cmd/user/recently_viewed.rs new file mode 100644 index 0000000..426661a --- /dev/null +++ b/src/cmd/user/recently_viewed.rs @@ -0,0 +1,342 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient, user::RecentlyViewedIssue}; + +pub struct UserRecentlyViewedArgs { + json: bool, +} + +impl UserRecentlyViewedArgs { + pub fn new(json: bool) -> Self { + Self { json } + } +} + +pub fn recently_viewed(args: &UserRecentlyViewedArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + recently_viewed_with(args, &client) +} + +pub fn recently_viewed_with(args: &UserRecentlyViewedArgs, api: &dyn BacklogApi) -> Result<()> { + let items = api.get_recently_viewed_issues()?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&items).context("Failed to serialize JSON")? + ); + } else { + for item in &items { + println!("{}", format_row(item)); + } + } + Ok(()) +} + +fn format_row(item: &RecentlyViewedIssue) -> String { + let status = &item.issue.status.name; + let assignee = item + .issue + .assignee + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or("-"); + format!( + "[{}] {} ({}, {})", + item.issue.issue_key, item.issue.summary, status, assignee, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::activity::Activity; + use crate::api::issue::{Issue, IssuePriority, IssueStatus, IssueType, IssueUser}; + use anyhow::anyhow; + use std::collections::BTreeMap; + + struct MockApi { + items: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_space(&self) -> anyhow::Result { + unimplemented!() + } + fn get_myself(&self) -> anyhow::Result { + unimplemented!() + } + fn get_users(&self) -> anyhow::Result> { + unimplemented!() + } + fn get_user(&self, _user_id: u64) -> anyhow::Result { + unimplemented!() + } + fn get_space_activities(&self) -> anyhow::Result> { + unimplemented!() + } + fn get_space_disk_usage(&self) -> anyhow::Result { + unimplemented!() + } + fn get_space_notification( + &self, + ) -> anyhow::Result { + unimplemented!() + } + fn get_projects(&self) -> anyhow::Result> { + unimplemented!() + } + fn get_project(&self, _key: &str) -> anyhow::Result { + unimplemented!() + } + fn get_project_activities(&self, _key: &str) -> anyhow::Result> { + unimplemented!() + } + fn get_project_disk_usage( + &self, + _key: &str, + ) -> anyhow::Result { + unimplemented!() + } + fn get_project_users( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_project_statuses( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_project_issue_types( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_project_categories( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_project_versions( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_issues( + &self, + _params: &[(String, String)], + ) -> anyhow::Result> { + unimplemented!() + } + fn count_issues( + &self, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn get_issue(&self, _key: &str) -> anyhow::Result { + unimplemented!() + } + fn create_issue( + &self, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn update_issue( + &self, + _key: &str, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn delete_issue(&self, _key: &str) -> anyhow::Result { + unimplemented!() + } + fn get_issue_comments( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn add_issue_comment( + &self, + _key: &str, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn update_issue_comment( + &self, + _key: &str, + _comment_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn delete_issue_comment( + &self, + _key: &str, + _comment_id: u64, + ) -> anyhow::Result { + unimplemented!() + } + fn get_issue_attachments( + &self, + _key: &str, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_wikis( + &self, + _params: &[(String, String)], + ) -> anyhow::Result> { + unimplemented!() + } + fn get_wiki(&self, _wiki_id: u64) -> anyhow::Result { + unimplemented!() + } + fn create_wiki( + &self, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn update_wiki( + &self, + _wiki_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn delete_wiki( + &self, + _wiki_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + unimplemented!() + } + fn get_wiki_history( + &self, + _wiki_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_wiki_attachments( + &self, + _wiki_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_teams(&self) -> anyhow::Result> { + unimplemented!() + } + fn get_team(&self, _team_id: u64) -> anyhow::Result { + unimplemented!() + } + fn get_user_activities(&self, _user_id: u64) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues(&self) -> anyhow::Result> { + self.items.clone().ok_or_else(|| anyhow!("no items")) + } + } + + fn sample_issue_user() -> IssueUser { + IssueUser { + 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_issue() -> Issue { + Issue { + id: 1, + project_id: 1, + issue_key: "BLG-1".to_string(), + key_id: 1, + summary: "Fix login".to_string(), + description: None, + resolution: None, + status: IssueStatus { + id: 1, + project_id: 1, + name: "Open".to_string(), + color: "#ed8077".to_string(), + display_order: 1000, + }, + priority: IssuePriority { + id: 2, + name: "Normal".to_string(), + }, + issue_type: IssueType { + id: 1, + project_id: 1, + name: "Bug".to_string(), + color: "#990000".to_string(), + display_order: 0, + }, + assignee: None, + start_date: None, + due_date: None, + estimated_hours: None, + actual_hours: None, + parent_issue_id: None, + created_user: sample_issue_user(), + created: "2024-01-01T00:00:00Z".to_string(), + updated_user: sample_issue_user(), + updated: "2024-06-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } + + fn sample_item() -> RecentlyViewedIssue { + RecentlyViewedIssue { + issue: sample_issue(), + updated: "2024-06-01T00:00:00Z".to_string(), + } + } + + #[test] + fn format_row_contains_fields() { + let text = format_row(&sample_item()); + assert!(text.contains("[BLG-1]")); + assert!(text.contains("Fix login")); + assert!(text.contains("Open")); + assert!(text.contains('-')); + } + + #[test] + fn recently_viewed_with_text_output_succeeds() { + let api = MockApi { + items: Some(vec![sample_item()]), + }; + assert!(recently_viewed_with(&UserRecentlyViewedArgs::new(false), &api).is_ok()); + } + + #[test] + fn recently_viewed_with_json_output_succeeds() { + let api = MockApi { + items: Some(vec![sample_item()]), + }; + assert!(recently_viewed_with(&UserRecentlyViewedArgs::new(true), &api).is_ok()); + } + + #[test] + fn recently_viewed_with_propagates_api_error() { + let api = MockApi { items: None }; + let err = recently_viewed_with(&UserRecentlyViewedArgs::new(false), &api).unwrap_err(); + assert!(err.to_string().contains("no items")); + } +} diff --git a/src/cmd/user/show.rs b/src/cmd/user/show.rs index f36b2e4..84dc064 100644 --- a/src/cmd/user/show.rs +++ b/src/cmd/user/show.rs @@ -244,6 +244,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_user() -> User { diff --git a/src/cmd/wiki/attachment/list.rs b/src/cmd/wiki/attachment/list.rs index e3ca8bd..2ed2658 100644 --- a/src/cmd/wiki/attachment/list.rs +++ b/src/cmd/wiki/attachment/list.rs @@ -221,6 +221,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_attachment() -> WikiAttachment { diff --git a/src/cmd/wiki/create.rs b/src/cmd/wiki/create.rs index 11e2731..6c3fcd4 100644 --- a/src/cmd/wiki/create.rs +++ b/src/cmd/wiki/create.rs @@ -232,6 +232,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_wiki() -> Wiki { diff --git a/src/cmd/wiki/delete.rs b/src/cmd/wiki/delete.rs index 3fc7d91..105cd7e 100644 --- a/src/cmd/wiki/delete.rs +++ b/src/cmd/wiki/delete.rs @@ -218,6 +218,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_wiki() -> Wiki { diff --git a/src/cmd/wiki/history.rs b/src/cmd/wiki/history.rs index 1631f49..d0a6f60 100644 --- a/src/cmd/wiki/history.rs +++ b/src/cmd/wiki/history.rs @@ -219,6 +219,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_history(user: WikiUser) -> WikiHistory { diff --git a/src/cmd/wiki/list.rs b/src/cmd/wiki/list.rs index 231263e..48ae74e 100644 --- a/src/cmd/wiki/list.rs +++ b/src/cmd/wiki/list.rs @@ -272,6 +272,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn args(json: bool) -> WikiListArgs { diff --git a/src/cmd/wiki/show.rs b/src/cmd/wiki/show.rs index e8b9dd9..ec00a30 100644 --- a/src/cmd/wiki/show.rs +++ b/src/cmd/wiki/show.rs @@ -222,6 +222,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_wiki() -> Wiki { diff --git a/src/cmd/wiki/update.rs b/src/cmd/wiki/update.rs index 8e5e878..8df5399 100644 --- a/src/cmd/wiki/update.rs +++ b/src/cmd/wiki/update.rs @@ -240,6 +240,17 @@ mod tests { fn get_team(&self, _team_id: u64) -> anyhow::Result { unimplemented!() } + fn get_user_activities( + &self, + _user_id: u64, + ) -> anyhow::Result> { + unimplemented!() + } + fn get_recently_viewed_issues( + &self, + ) -> anyhow::Result> { + unimplemented!() + } } fn sample_wiki() -> Wiki { diff --git a/src/main.rs b/src/main.rs index 4de7f60..64b6223 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ use cmd::project::version::ProjectVersionListArgs; use cmd::project::{ProjectActivitiesArgs, ProjectDiskUsageArgs, ProjectListArgs, ProjectShowArgs}; use cmd::space::{SpaceActivitiesArgs, SpaceDiskUsageArgs, SpaceNotificationArgs, SpaceShowArgs}; use cmd::team::{TeamListArgs, TeamShowArgs}; -use cmd::user::{UserListArgs, UserShowArgs}; +use cmd::user::{UserActivitiesArgs, UserListArgs, UserRecentlyViewedArgs, UserShowArgs}; use cmd::wiki::attachment::WikiAttachmentListArgs; use cmd::wiki::{ WikiCreateArgs, WikiDeleteArgs, WikiHistoryArgs, WikiListArgs, WikiShowArgs, WikiUpdateArgs, @@ -553,6 +553,20 @@ enum UserCommands { #[arg(long)] json: bool, }, + /// Show recent activities of a user + Activities { + /// User numeric ID + id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Show recently viewed issues (for the authenticated user) + RecentlyViewed { + /// Output as JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -877,6 +891,12 @@ fn run() -> Result<()> { Commands::User { action } => match action { UserCommands::List { json } => cmd::user::list(&UserListArgs::new(json)), UserCommands::Show { id, json } => cmd::user::show(&UserShowArgs::new(id, json)), + UserCommands::Activities { id, json } => { + cmd::user::activities(&UserActivitiesArgs::new(id, json)) + } + UserCommands::RecentlyViewed { json } => { + cmd::user::recently_viewed(&UserRecentlyViewedArgs::new(json)) + } }, Commands::Team { action } => match action { TeamCommands::List { json } => cmd::team::list(&TeamListArgs::new(json)), diff --git a/website/docs/commands.md b/website/docs/commands.md index d5cc5b7..a8c9c40 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -560,6 +560,37 @@ Members: [3] Engineer ``` +## `bl user activities` + +Show recent activities of a specific user. + +```bash +bl user activities +bl user activities --json +``` + +Example output: + +```text +[3153] type=2 project=SUB user=admin created=2024-06-01T00:00:00Z +``` + +## `bl user recently-viewed` + +Show issues recently viewed by the authenticated user. + +```bash +bl user recently-viewed +bl user recently-viewed --json +``` + +Example output: + +```text +[BLG-1] Fix login (Open, -) +[BLG-2] Add dark mode (In Progress, John Doe) +``` + ## `bl user list` List all users in the space. @@ -680,8 +711,8 @@ 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 activities ` | `GET /api/v2/users/{userId}/activities` | Planned | -| `bl user recently-viewed ` | `GET /api/v2/users/{userId}/recentlyViewedIssues` | Planned | +| `bl user activities ` | `GET /api/v2/users/{userId}/activities` | ✅ Implemented | +| `bl user recently-viewed` | `GET /api/v2/users/myself/recentlyViewedIssues` | ✅ Implemented | ### 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 3e031c4..5bd3cd7 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -560,6 +560,37 @@ Members: [3] Engineer ``` +## `bl user activities` + +特定のユーザーの最近のアクティビティを表示します。 + +```bash +bl user activities +bl user activities --json +``` + +出力例: + +```text +[3153] type=2 project=SUB user=admin created=2024-06-01T00:00:00Z +``` + +## `bl user recently-viewed` + +認証ユーザーが最近閲覧した課題を表示します。 + +```bash +bl user recently-viewed +bl user recently-viewed --json +``` + +出力例: + +```text +[BLG-1] Fix login (Open, -) +[BLG-2] Add dark mode (In Progress, John Doe) +``` + ## `bl user list` スペース内のユーザーを一覧表示します。 @@ -680,8 +711,8 @@ 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 activities ` | `GET /api/v2/users/{userId}/activities` | 計画中 | -| `bl user recently-viewed ` | `GET /api/v2/users/{userId}/recentlyViewedIssues` | 計画中 | +| `bl user activities ` | `GET /api/v2/users/{userId}/activities` | ✅ 実装済み | +| `bl user recently-viewed` | `GET /api/v2/users/myself/recentlyViewedIssues` | ✅ 実装済み | ### Notifications From dba7c785aef1273402495c8feb6a75d5090c922f Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Thu, 12 Mar 2026 22:30:31 +0900 Subject: [PATCH 2/3] test: tighten recently-viewed assignee assertion Addresses review comment: assert contains '-' also matched the issue key hyphen --- src/cmd/user/recently_viewed.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cmd/user/recently_viewed.rs b/src/cmd/user/recently_viewed.rs index 426661a..c253ceb 100644 --- a/src/cmd/user/recently_viewed.rs +++ b/src/cmd/user/recently_viewed.rs @@ -313,8 +313,7 @@ mod tests { let text = format_row(&sample_item()); assert!(text.contains("[BLG-1]")); assert!(text.contains("Fix login")); - assert!(text.contains("Open")); - assert!(text.contains('-')); + assert!(text.contains("(Open, -)")); } #[test] From 47d8f9bcea7f215ed6c335d7f7c26a6b8e80033c Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Thu, 12 Mar 2026 22:31:44 +0900 Subject: [PATCH 3/3] fix: add extra field to RecentlyViewedIssue and unify deserialization error format Addresses review comments: add flatten extra map for consistency; factor repeated Raw JSON block into shared helper --- src/api/user.rs | 36 +++++++++++++++------------------ src/cmd/user/recently_viewed.rs | 1 + 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/api/user.rs b/src/api/user.rs index ac5211c..8e918cf 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -6,6 +6,15 @@ use super::BacklogClient; use crate::api::activity::Activity; use crate::api::issue::Issue; +fn deserialize(value: serde_json::Value, ctx: &str) -> Result { + serde_json::from_value(value.clone()).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize {ctx}: {e}\nRaw JSON:\n{}", + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + ) + }) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { @@ -29,47 +38,34 @@ pub struct User { pub struct RecentlyViewedIssue { pub issue: Issue, pub updated: String, + #[serde(flatten)] + pub extra: BTreeMap, } impl BacklogClient { pub fn get_myself(&self) -> Result { let value = self.get("/users/myself")?; - serde_json::from_value(value) - .map_err(|e| anyhow::anyhow!("Failed to deserialize user response: {}", e)) + deserialize(value, "user response") } pub fn get_users(&self) -> Result> { let value = self.get("/users")?; - serde_json::from_value(value) - .map_err(|e| anyhow::anyhow!("Failed to deserialize users response: {}", e)) + deserialize(value, "users response") } pub fn get_user(&self, user_id: u64) -> Result { let value = self.get(&format!("/users/{user_id}"))?; - serde_json::from_value(value) - .map_err(|e| anyhow::anyhow!("Failed to deserialize user response: {}", e)) + deserialize(value, "user response") } pub fn get_user_activities(&self, user_id: u64) -> Result> { let value = self.get(&format!("/users/{user_id}/activities"))?; - serde_json::from_value(value.clone()).map_err(|e| { - anyhow::anyhow!( - "Failed to deserialize user activities response: {}\nRaw JSON:\n{}", - e, - serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) - ) - }) + deserialize(value, "user activities response") } pub fn get_recently_viewed_issues(&self) -> Result> { let value = self.get("/users/myself/recentlyViewedIssues")?; - serde_json::from_value(value.clone()).map_err(|e| { - anyhow::anyhow!( - "Failed to deserialize recently viewed issues response: {}\nRaw JSON:\n{}", - e, - serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) - ) - }) + deserialize(value, "recently viewed issues response") } } diff --git a/src/cmd/user/recently_viewed.rs b/src/cmd/user/recently_viewed.rs index c253ceb..e068983 100644 --- a/src/cmd/user/recently_viewed.rs +++ b/src/cmd/user/recently_viewed.rs @@ -305,6 +305,7 @@ mod tests { RecentlyViewedIssue { issue: sample_issue(), updated: "2024-06-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), } }