Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/developing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ git switch -c feature/<name>

## Step 3 — Pick a feature (feature tasks only)

Read `docs/user-guide.md` and pick the first "Planned" entry from the command coverage table.
Read `website/docs/commands.md` and pick the first "Planned" entry from the command coverage table.
Confirm the selection with the user before proceeding.

For feature tasks, also read `references/patterns.md` for code patterns and known gotchas, and check the official API docs before writing structs:
Expand All @@ -47,7 +47,7 @@ Follow `AGENTS.md` conventions. For feature tasks, the typical file order is:
3. `src/cmd/<resource>/<subcommand>.rs` — `<cmd>()` + `<cmd>_with()` + tests
4. `src/cmd/<resource>/mod.rs` — re-export
5. `src/main.rs` — clap wiring
6. `docs/user-guide.md` — mark as implemented
6. `website/docs/commands.md` and `website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md` — add command docs and mark as implemented in the coverage table

## Step 5 — Auto-fix and check

Expand Down
10 changes: 10 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ use wiki::{Wiki, WikiAttachment, WikiHistory, WikiListItem};
pub trait BacklogApi {
fn get_space(&self) -> Result<Space>;
fn get_myself(&self) -> Result<User>;
fn get_users(&self) -> Result<Vec<User>>;
fn get_user(&self, user_id: u64) -> Result<User>;
fn get_space_activities(&self) -> Result<Vec<Activity>>;
fn get_space_disk_usage(&self) -> Result<DiskUsage>;
fn get_space_notification(&self) -> Result<SpaceNotification>;
Expand Down Expand Up @@ -76,6 +78,14 @@ impl BacklogApi for BacklogClient {
self.get_myself()
}

fn get_users(&self) -> Result<Vec<User>> {
self.get_users()
}

fn get_user(&self, user_id: u64) -> Result<User> {
self.get_user(user_id)
}

fn get_space_activities(&self) -> Result<Vec<Activity>> {
self.get_space_activities()
}
Expand Down
113 changes: 97 additions & 16 deletions src/api/user.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

use super::BacklogClient;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: u64,
pub user_id: String,
/// `null` for bot accounts (e.g. automation bots have no userId in Backlog API).
pub user_id: Option<String>,
pub name: String,
pub mail_address: String,
/// `null` for bot accounts.
pub mail_address: Option<String>,
pub role_type: u8,
#[serde(default)]
pub lang: Option<String>,
#[serde(default)]
pub last_login_time: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}

impl BacklogClient {
Expand All @@ -19,6 +28,18 @@ impl BacklogClient {
serde_json::from_value(value)
.map_err(|e| anyhow::anyhow!("Failed to deserialize user response: {}", e))
}

pub fn get_users(&self) -> Result<Vec<User>> {
let value = self.get("/users")?;
serde_json::from_value(value)
.map_err(|e| anyhow::anyhow!("Failed to deserialize users response: {}", e))
}

pub fn get_user(&self, user_id: u64) -> Result<User> {
let value = self.get(&format!("/users/{user_id}"))?;
serde_json::from_value(value)
.map_err(|e| anyhow::anyhow!("Failed to deserialize user response: {}", e))
}
}

#[cfg(test)]
Expand All @@ -33,7 +54,9 @@ mod tests {
"userId": "john",
"name": "John Doe",
"mailAddress": "john@example.com",
"roleType": 1
"roleType": 1,
"lang": "ja",
"lastLoginTime": "2024-01-01T00:00:00Z"
})
}

Expand All @@ -48,7 +71,7 @@ mod tests {
let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap();
let user = client.get_myself().unwrap();
assert_eq!(user.id, 123);
assert_eq!(user.user_id, "john");
assert_eq!(user.user_id.as_deref(), Some("john"));
assert_eq!(user.name, "John Doe");
}

Expand All @@ -67,25 +90,83 @@ mod tests {
}

#[test]
fn deserialize_user() {
let v = json!({
"id": 123,
"userId": "john",
"name": "John Doe",
"mailAddress": "john@example.com",
"roleType": 1
fn get_users_returns_list() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/users");
then.status(200).json_body(json!([user_json()]));
});

let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap();
let users = client.get_users().unwrap();
assert_eq!(users.len(), 1);
assert_eq!(users[0].id, 123);
}

#[test]
fn get_users_returns_error_on_api_failure() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).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.get_users().unwrap_err();
assert!(err.to_string().contains("Forbidden"));
}

#[test]
fn get_user_returns_parsed_struct() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/users/123");
then.status(200).json_body(user_json());
});

let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap();
let user = client.get_user(123).unwrap();
assert_eq!(user.id, 123);
assert_eq!(user.name, "John Doe");
}

#[test]
fn get_user_returns_error_on_not_found() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).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.get_user(999).unwrap_err();
assert!(err.to_string().contains("No user"));
}

#[test]
fn deserialize_user() {
let v = user_json();
let user: User = serde_json::from_value(v).unwrap();
assert_eq!(user.id, 123);
assert_eq!(user.user_id, "john");
assert_eq!(user.user_id.as_deref(), Some("john"));
assert_eq!(user.name, "John Doe");
assert_eq!(user.mail_address, "john@example.com");
assert_eq!(user.mail_address.as_deref(), Some("john@example.com"));
assert_eq!(user.role_type, 1);
}

#[test]
fn deserialize_user_fails_on_missing_required_field() {
let v = json!({"id": 123, "userId": "john"});
assert!(serde_json::from_value::<User>(v).is_err());
fn deserialize_user_with_null_user_id() {
let v = json!({
"id": 1,
"userId": null,
"name": "Bot",
"mailAddress": null,
"roleType": 2
});
let user: User = serde_json::from_value(v).unwrap();
assert_eq!(user.user_id, None);
assert_eq!(user.mail_address, None);
}
}
21 changes: 18 additions & 3 deletions src/cmd/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,11 @@ pub fn status_with(
}

match api.get_myself() {
Ok(user) => println!(" - Logged in as {} ({})", user.name.green(), user.user_id),
Ok(user) => println!(
" - Logged in as {} ({})",
user.name.green(),
user.user_id.as_deref().unwrap_or("-")
),
Err(e) => println!(" {} Token invalid: {}", "!".red(), e),
}

Expand Down Expand Up @@ -376,6 +380,14 @@ mod tests {
.ok_or_else(|| anyhow!("invalid credentials"))
}

fn get_users(&self) -> anyhow::Result<Vec<User>> {
unimplemented!()
}

fn get_user(&self, _user_id: u64) -> anyhow::Result<User> {
unimplemented!()
}

fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down Expand Up @@ -550,10 +562,13 @@ mod tests {
fn sample_user() -> User {
User {
id: 1,
user_id: "john".to_string(),
user_id: Some("john".to_string()),
name: "John Doe".to_string(),
mail_address: "john@example.com".to_string(),
mail_address: Some("john@example.com".to_string()),
role_type: 1,
lang: None,
last_login_time: None,
extra: std::collections::BTreeMap::new(),
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/attachment/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/comment/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/comment/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/comment/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/comment/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/count.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
6 changes: 6 additions & 0 deletions src/cmd/issue/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ mod tests {
fn get_myself(&self) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_users(&self) -> anyhow::Result<Vec<crate::api::user::User>> {
unimplemented!()
}
fn get_user(&self, _user_id: u64) -> anyhow::Result<crate::api::user::User> {
unimplemented!()
}
fn get_space_activities(&self) -> anyhow::Result<Vec<crate::api::activity::Activity>> {
unimplemented!()
}
Expand Down
1 change: 1 addition & 0 deletions src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pub mod banner;
pub mod issue;
pub mod project;
pub mod space;
pub mod user;
pub mod wiki;
Loading
Loading