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
7 changes: 7 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,9 @@ pub trait BacklogApi {
fn count_user_stars(&self, _user_id: u64, _params: &[(String, String)]) -> Result<StarCount> {
unimplemented!()
}
fn download_user_icon(&self, _user_id: u64) -> Result<(Vec<u8>, String)> {
unimplemented!()
}
fn add_star(&self, _params: &[(String, String)]) -> Result<()> {
unimplemented!()
}
Expand Down Expand Up @@ -1390,6 +1393,10 @@ impl BacklogApi for BacklogClient {
self.count_user_stars(user_id, params)
}

fn download_user_icon(&self, user_id: u64) -> Result<(Vec<u8>, String)> {
self.download_user_icon(user_id)
}

fn add_star(&self, params: &[(String, String)]) -> Result<()> {
self.add_star(params)
}
Expand Down
4 changes: 4 additions & 0 deletions src/api/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ impl BacklogClient {
deserialize(value)
}

pub fn download_user_icon(&self, user_id: u64) -> Result<(Vec<u8>, String)> {
self.download(&format!("/users/{user_id}/icon"))
}
Comment on lines +149 to +151
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

download_user_icon is a new API endpoint but src/api/user.rs already has httpmock-based coverage for other user endpoints. Please add a unit test that exercises this method (e.g., mock GET /users/{id}/icon returning a binary body and a Content-Disposition filename) and asserts the returned bytes + filename.

Copilot uses AI. Check for mistakes.

pub fn add_star(&self, params: &[(String, String)]) -> Result<()> {
self.post_form("/stars", params)?;
Ok(())
Expand Down
125 changes: 125 additions & 0 deletions src/cmd/user/icon.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use anstream::println;
use anyhow::{Context, Result};
use std::path::PathBuf;

use crate::api::{BacklogApi, BacklogClient};

pub struct UserIconArgs {
id: u64,
output: Option<PathBuf>,
}

impl UserIconArgs {
pub fn new(id: u64, output: Option<PathBuf>) -> Self {
Self { id, output }
}
}

pub fn icon(args: &UserIconArgs) -> Result<()> {
let client = BacklogClient::from_config()?;
icon_with(args, &client)
}

pub fn icon_with(args: &UserIconArgs, api: &dyn BacklogApi) -> Result<()> {
let (bytes, filename) = api.download_user_icon(args.id)?;
let path = args
.output
.clone()
.unwrap_or_else(|| default_output_path(&filename));
std::fs::write(&path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?;
println!("Saved: {} ({} bytes)", path.display(), bytes.len());
Ok(())
}

fn default_output_path(filename: &str) -> PathBuf {
let normalized = filename.trim();
let base = std::path::Path::new(normalized)
.file_name()
.unwrap_or(std::ffi::OsStr::new(""));
let base_lower = base.to_string_lossy().to_ascii_lowercase();
let is_generic_attachment = base_lower == "attachment" || base_lower.starts_with("attachment.");

if base.is_empty() || is_generic_attachment {
PathBuf::from("user_icon")
} else {
PathBuf::from(base)
}
}

#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
use tempfile::tempdir;

struct MockApi {
result: Option<(Vec<u8>, String)>,
}

impl crate::api::BacklogApi for MockApi {
fn download_user_icon(&self, _user_id: u64) -> anyhow::Result<(Vec<u8>, String)> {
self.result
.clone()
.ok_or_else(|| anyhow!("download failed"))
}
}

fn args(output: Option<PathBuf>) -> UserIconArgs {
UserIconArgs::new(1, output)
}

#[test]
fn icon_with_saves_file_to_specified_path() {
let dir = tempdir().unwrap();
let path = dir.path().join("out.png");
let api = MockApi {
result: Some((b"png-data".to_vec(), "user_icon.png".to_string())),
};
assert!(icon_with(&args(Some(path.clone())), &api).is_ok());
assert_eq!(std::fs::read(&path).unwrap(), b"png-data");
}

#[test]
fn icon_with_propagates_api_error() {
let api = MockApi { result: None };
let err = icon_with(&args(None), &api).unwrap_err();
assert!(err.to_string().contains("download failed"));
}

#[test]
fn default_output_path_uses_server_filename() {
assert_eq!(
default_output_path("user_icon.png"),
PathBuf::from("user_icon.png")
);
}

#[test]
fn default_output_path_falls_back_for_attachment() {
assert_eq!(
default_output_path("attachment"),
PathBuf::from("user_icon")
);
}

#[test]
fn default_output_path_falls_back_for_attachment_with_extension() {
assert_eq!(
default_output_path("attachment.png"),
PathBuf::from("user_icon")
);
}

#[test]
fn default_output_path_falls_back_for_path_with_attachment_basename() {
assert_eq!(
default_output_path("foo/attachment.png"),
PathBuf::from("user_icon")
);
}

#[test]
fn default_output_path_falls_back_for_empty() {
assert_eq!(default_output_path(""), PathBuf::from("user_icon"));
}
}
2 changes: 2 additions & 0 deletions src/cmd/user/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod activities;
mod add;
mod delete;
mod icon;
mod list;
mod recently_viewed;
mod recently_viewed_projects;
Expand All @@ -10,6 +11,7 @@ pub mod star;
mod update;

pub use activities::{UserActivitiesArgs, activities};
pub use icon::{UserIconArgs, icon};

#[cfg(test)]
pub(crate) fn sample_user() -> crate::api::user::User {
Expand Down
14 changes: 12 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ use cmd::team::{
};
use cmd::user::star::{UserStarCountArgs, UserStarListArgs};
use cmd::user::{
UserActivitiesArgs, UserAddArgs, UserDeleteArgs, UserListArgs, UserRecentlyViewedArgs,
UserRecentlyViewedProjectsArgs, UserRecentlyViewedWikisArgs, UserShowArgs, UserUpdateArgs,
UserActivitiesArgs, UserAddArgs, UserDeleteArgs, UserIconArgs, UserListArgs,
UserRecentlyViewedArgs, UserRecentlyViewedProjectsArgs, UserRecentlyViewedWikisArgs,
UserShowArgs, UserUpdateArgs,
};
use cmd::watch::{
WatchAddArgs, WatchCountArgs, WatchDeleteArgs, WatchListArgs, WatchReadArgs, WatchShowArgs,
Expand Down Expand Up @@ -1685,6 +1686,14 @@ enum UserCommands {
#[arg(long)]
json: bool,
},
/// Download a user icon image
Icon {
/// User numeric ID
id: u64,
/// Output file path (default: server-provided filename, or "user_icon" if none)
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI help text for --output says the fallback is "user_icon" "if none", but the implementation also falls back when the server-provided filename is a generic attachment placeholder. Please update the help text to match the actual behavior so users aren’t surprised by the default filename choice.

Suggested change
/// Output file path (default: server-provided filename, or "user_icon" if none)
/// Output file path (default: server-provided filename; if missing or a generic
/// "attachment" placeholder, falls back to "user_icon")

Copilot uses AI. Check for mistakes.
#[arg(long, short = 'o')]
output: Option<std::path::PathBuf>,
},
/// Show a user
Show {
/// User numeric ID
Expand Down Expand Up @@ -3248,6 +3257,7 @@ fn run() -> Result<()> {
},
Commands::User { action } => match action {
UserCommands::List { json } => cmd::user::list(&UserListArgs::new(json)),
UserCommands::Icon { id, output } => cmd::user::icon(&UserIconArgs::new(id, output)),
UserCommands::Show { id, json } => cmd::user::show(&UserShowArgs::new(id, json)),
UserCommands::Activities {
id,
Expand Down
19 changes: 18 additions & 1 deletion website/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,23 @@ Example output:
42
```

## `bl user icon`

Download a user's icon image.

The response is binary data. Use `--output` / `-o` to specify where the file is written. If omitted, the command saves the file in the current directory using the filename returned by the server (or `user_icon` when the filename is missing or a generic `attachment` placeholder).

```bash
bl user icon <id>
bl user icon <id> --output my_icon.png
```

Example output:

```text
Saved: user_icon (1234 bytes)
```

## `bl user list`

List all users in the space.
Expand Down Expand Up @@ -2391,7 +2408,7 @@ The table below maps Backlog API v2 endpoints to `bl` commands.
| `bl user recently-viewed-wikis` | `GET /api/v2/users/myself/recentlyViewedWikis` | ✅ Implemented |
| `bl user star list <id>` | `GET /api/v2/users/{userId}/stars` | ✅ Implemented |
| `bl user star count <id>` | `GET /api/v2/users/{userId}/stars/count` | ✅ Implemented |
| | `GET /api/v2/users/{userId}/icon` | Planned |
| `bl user icon <id>` | `GET /api/v2/users/{userId}/icon` | ✅ Implemented |

### Notifications

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1817,6 +1817,23 @@ bl user star count <id> --since 2024-01-01 --until 2024-12-31 --json
42
```

## `bl user icon`

ユーザーのアイコン画像をダウンロードします。

レスポンスはバイナリデータです。`--output` / `-o` で保存先を指定してください。省略した場合は、サーバーが返すファイル名(ファイル名がない場合や `attachment` などの汎用プレースホルダーの場合は `user_icon`)を使ってカレントディレクトリに保存します。

```bash
bl user icon <id>
bl user icon <id> --output my_icon.png
```

出力例:

```text
Saved: user_icon (1234 bytes)
```

## `bl user list`

スペース内のユーザーを一覧表示します。
Expand Down Expand Up @@ -2393,7 +2410,7 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。
| `bl user recently-viewed-wikis` | `GET /api/v2/users/myself/recentlyViewedWikis` | ✅ 実装済み |
| `bl user star list <id>` | `GET /api/v2/users/{userId}/stars` | ✅ 実装済み |
| `bl user star count <id>` | `GET /api/v2/users/{userId}/stars/count` | ✅ 実装済み |
| | `GET /api/v2/users/{userId}/icon` | 計画中 |
| `bl user icon <id>` | `GET /api/v2/users/{userId}/icon` | ✅ 実装済み |

### Notifications

Expand Down
Loading