From 69bf2b51dff1e8afe6a4cc839344b5ebd53e9b91 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 23:19:36 +0900 Subject: [PATCH 1/3] feat: implement bl user icon (GET /api/v2/users/{userId}/icon) --- src/api/mod.rs | 7 + src/api/user.rs | 4 + src/cmd/user/icon.rs | 125 ++++++++++++++++++ src/cmd/user/mod.rs | 2 + src/main.rs | 14 +- website/docs/commands.md | 19 ++- .../current/commands.md | 19 ++- 7 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/cmd/user/icon.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index cb3d5ee..3c4c72b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -595,6 +595,9 @@ pub trait BacklogApi { fn count_user_stars(&self, _user_id: u64, _params: &[(String, String)]) -> Result { unimplemented!() } + fn download_user_icon(&self, _user_id: u64) -> Result<(Vec, String)> { + unimplemented!() + } fn add_star(&self, _params: &[(String, String)]) -> Result<()> { unimplemented!() } @@ -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, String)> { + self.download_user_icon(user_id) + } + fn add_star(&self, params: &[(String, String)]) -> Result<()> { self.add_star(params) } diff --git a/src/api/user.rs b/src/api/user.rs index ab771f0..cb44df4 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -146,6 +146,10 @@ impl BacklogClient { deserialize(value) } + pub fn download_user_icon(&self, user_id: u64) -> Result<(Vec, String)> { + self.download(&format!("/users/{user_id}/icon")) + } + pub fn add_star(&self, params: &[(String, String)]) -> Result<()> { self.post_form("/stars", params)?; Ok(()) diff --git a/src/cmd/user/icon.rs b/src/cmd/user/icon.rs new file mode 100644 index 0000000..9e9d898 --- /dev/null +++ b/src/cmd/user/icon.rs @@ -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, +} + +impl UserIconArgs { + pub fn new(id: u64, output: Option) -> 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, String)>, + } + + impl crate::api::BacklogApi for MockApi { + fn download_user_icon(&self, _user_id: u64) -> anyhow::Result<(Vec, String)> { + self.result + .clone() + .ok_or_else(|| anyhow!("download failed")) + } + } + + fn args(output: Option) -> 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")); + } +} diff --git a/src/cmd/user/mod.rs b/src/cmd/user/mod.rs index f0c37c8..d4203a6 100644 --- a/src/cmd/user/mod.rs +++ b/src/cmd/user/mod.rs @@ -1,6 +1,7 @@ mod activities; mod add; mod delete; +mod icon; mod list; mod recently_viewed; mod recently_viewed_projects; @@ -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 { diff --git a/src/main.rs b/src/main.rs index 982f9a5..697b9ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, @@ -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) + #[arg(long, short = 'o')] + output: Option, + }, /// Show a user Show { /// User numeric ID @@ -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, diff --git a/website/docs/commands.md b/website/docs/commands.md index a59c2d9..d5aaeca 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -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 +bl user icon --output my_icon.png +``` + +Example output: + +```text +Saved: user_icon (1234 bytes) +``` + ## `bl user list` List all users in the space. @@ -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 ` | `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 | +| `bl user icon ` | `GET /api/v2/users/{userId}/icon` | ✅ 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 c4d5e74..380968e 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -1817,6 +1817,23 @@ bl user star count --since 2024-01-01 --until 2024-12-31 --json 42 ``` +## `bl user icon` + +ユーザーのアイコン画像をダウンロードします。 + +レスポンスはバイナリデータです。`--output` / `-o` で保存先を指定してください。省略した場合は、サーバーが返すファイル名(ファイル名がない場合や `attachment` などの汎用プレースホルダーの場合は `user_icon`)を使ってカレントディレクトリに保存します。 + +```bash +bl user icon +bl user icon --output my_icon.png +``` + +出力例: + +```text +Saved: user_icon (1234 bytes) +``` + ## `bl user list` スペース内のユーザーを一覧表示します。 @@ -2393,7 +2410,7 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `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` | 計画中 | +| `bl user icon ` | `GET /api/v2/users/{userId}/icon` | ✅ 実装済み | ### Notifications From 3d0d355360cca747da0e56c5cf3b88acf2ef98b1 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 23:28:47 +0900 Subject: [PATCH 2/3] feat: add bl space upload-attachment command Implements POST /api/v2/space/attachment via multipart form upload. Outputs attachment metadata (ID, name, size, created) as text or JSON. Closes #121 --- Cargo.lock | 23 ++++ Cargo.toml | 2 +- src/api/mod.rs | 29 ++++- src/api/space.rs | 38 +++++- src/cmd/space/mod.rs | 2 + src/cmd/space/upload_attachment.rs | 116 ++++++++++++++++++ src/main.rs | 18 ++- website/docs/commands.md | 20 ++- .../current/commands.md | 20 ++- 9 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 src/cmd/space/upload_attachment.rs diff --git a/Cargo.lock b/Cargo.lock index bba7a5b..a9d8334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1240,6 +1240,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.1.1" @@ -1692,6 +1708,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -2319,6 +2336,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index cb3c7a8..2321ac7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ path = "src/main.rs" anyhow = "1" clap = { version = "4", features = ["derive"] } dirs = "6" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "1.0" diff --git a/src/api/mod.rs b/src/api/mod.rs index 3c4c72b..91834c8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -62,7 +62,7 @@ use pull_request::{ use rate_limit::RateLimit; use resolution::Resolution; use shared_file::SharedFile; -use space::Space; +use space::{Space, SpaceAttachment}; use space_notification::SpaceNotification; use team::Team; use user::{RecentlyViewedIssue, RecentlyViewedProject, RecentlyViewedWiki, Star, StarCount, User}; @@ -91,6 +91,9 @@ pub trait BacklogApi { fn download_space_image(&self) -> Result<(Vec, String)> { unimplemented!() } + fn upload_space_attachment(&self, _file_path: &std::path::Path) -> Result { + unimplemented!() + } fn get_rate_limit(&self) -> Result { unimplemented!() } @@ -777,6 +780,10 @@ impl BacklogApi for BacklogClient { self.download_space_image() } + fn upload_space_attachment(&self, file_path: &std::path::Path) -> Result { + self.upload_space_attachment(file_path) + } + fn get_rate_limit(&self) -> Result { self.get_rate_limit() } @@ -1752,6 +1759,26 @@ impl BacklogClient { }) } + /// Post a multipart/form-data request. + /// + /// Note: unlike [`Self::execute`], this method does not retry on 401 because + /// `reqwest::blocking::multipart::Form` is consumed on the first send and cannot be + /// reconstructed for a retry. + pub fn post_multipart( + &self, + path: &str, + form: reqwest::blocking::multipart::Form, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + crate::logger::verbose(&format!("→ POST {url}")); + let response = self + .apply_auth(self.client.post(&url)) + .multipart(form) + .send() + .with_context(|| format!("Failed to POST {url}"))?; + self.finish_response(response) + } + pub fn patch_form(&self, path: &str, params: &[(String, String)]) -> Result { let url = format!("{}{}", self.base_url, path); self.execute(|| { diff --git a/src/api/space.rs b/src/api/space.rs index 437f1b9..b5285c0 100644 --- a/src/api/space.rs +++ b/src/api/space.rs @@ -1,4 +1,6 @@ -use anyhow::Result; +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use super::BacklogClient; @@ -17,6 +19,26 @@ pub struct Space { pub updated: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpaceAttachmentUser { + pub id: u64, + pub user_id: Option, + pub name: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpaceAttachment { + pub id: u64, + pub name: String, + pub size: u64, + pub created_user: SpaceAttachmentUser, + pub created: String, +} + impl BacklogClient { pub fn get_space(&self) -> Result { let value = self.get("/space")?; @@ -26,6 +48,20 @@ impl BacklogClient { pub fn download_space_image(&self) -> Result<(Vec, String)> { self.download("/space/image") } + + pub fn upload_space_attachment(&self, file_path: &std::path::Path) -> Result { + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("attachment") + .to_string(); + let bytes = std::fs::read(file_path) + .with_context(|| format!("Failed to read {}", file_path.display()))?; + let part = reqwest::blocking::multipart::Part::bytes(bytes).file_name(filename); + let form = reqwest::blocking::multipart::Form::new().part("file", part); + let value = self.post_multipart("/space/attachment", form)?; + deserialize(value) + } } #[cfg(test)] diff --git a/src/cmd/space/mod.rs b/src/cmd/space/mod.rs index f84e0d4..baba87d 100644 --- a/src/cmd/space/mod.rs +++ b/src/cmd/space/mod.rs @@ -5,6 +5,7 @@ mod licence; mod notification; mod show; mod update_notification; +mod upload_attachment; pub use activities::{SpaceActivitiesArgs, activities}; pub use image::{SpaceImageArgs, image}; @@ -21,3 +22,4 @@ pub use licence::{SpaceLicenceArgs, licence}; pub use notification::{SpaceNotificationArgs, notification}; pub use show::{SpaceShowArgs, show}; pub use update_notification::{SpaceUpdateNotificationArgs, update_notification}; +pub use upload_attachment::{SpaceUploadAttachmentArgs, upload_attachment}; diff --git a/src/cmd/space/upload_attachment.rs b/src/cmd/space/upload_attachment.rs new file mode 100644 index 0000000..975ba00 --- /dev/null +++ b/src/cmd/space/upload_attachment.rs @@ -0,0 +1,116 @@ +use anstream::println; +use anyhow::Result; +use std::path::PathBuf; + +use crate::api::{BacklogApi, BacklogClient, space::SpaceAttachment}; + +pub struct SpaceUploadAttachmentArgs { + file: PathBuf, + json: bool, +} + +impl SpaceUploadAttachmentArgs { + pub fn new(file: PathBuf, json: bool) -> Self { + Self { file, json } + } +} + +pub fn upload_attachment(args: &SpaceUploadAttachmentArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + upload_attachment_with(args, &client) +} + +pub fn upload_attachment_with( + args: &SpaceUploadAttachmentArgs, + api: &dyn BacklogApi, +) -> Result<()> { + let attachment = api.upload_space_attachment(&args.file)?; + if args.json { + crate::cmd::print_json(&attachment)?; + } else { + println!("{}", format_attachment_text(&attachment)); + } + Ok(()) +} + +fn format_attachment_text(a: &SpaceAttachment) -> String { + format!( + "ID: {}\nName: {}\nSize: {} bytes\nCreated: {}", + a.id, a.name, a.size, a.created + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use std::collections::BTreeMap; + use tempfile::NamedTempFile; + + use crate::api::space::{SpaceAttachment, SpaceAttachmentUser}; + + struct MockApi { + result: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn upload_space_attachment( + &self, + _file_path: &std::path::Path, + ) -> anyhow::Result { + self.result.clone().ok_or_else(|| anyhow!("upload failed")) + } + } + + fn sample_attachment() -> SpaceAttachment { + SpaceAttachment { + id: 1, + name: "test.txt".to_string(), + size: 100, + created_user: SpaceAttachmentUser { + id: 1, + user_id: Some("alice".to_string()), + name: "Alice".to_string(), + extra: BTreeMap::new(), + }, + created: "2024-01-01T00:00:00Z".to_string(), + } + } + + fn args(json: bool) -> SpaceUploadAttachmentArgs { + let tmp = NamedTempFile::new().unwrap(); + SpaceUploadAttachmentArgs::new(tmp.path().to_path_buf(), json) + } + + #[test] + fn upload_attachment_with_text_output_succeeds() { + let api = MockApi { + result: Some(sample_attachment()), + }; + assert!(upload_attachment_with(&args(false), &api).is_ok()); + } + + #[test] + fn upload_attachment_with_json_output_succeeds() { + let api = MockApi { + result: Some(sample_attachment()), + }; + assert!(upload_attachment_with(&args(true), &api).is_ok()); + } + + #[test] + fn upload_attachment_with_propagates_api_error() { + let api = MockApi { result: None }; + let err = upload_attachment_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("upload failed")); + } + + #[test] + fn format_attachment_text_contains_all_fields() { + let text = format_attachment_text(&sample_attachment()); + assert!(text.contains("1")); + assert!(text.contains("test.txt")); + assert!(text.contains("100")); + assert!(text.contains("2024-01-01T00:00:00Z")); + } +} diff --git a/src/main.rs b/src/main.rs index 697b9ac..c260b1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,7 +76,7 @@ use cmd::resolution::ResolutionListArgs; use cmd::shared_file::{SharedFileGetArgs, SharedFileListArgs}; use cmd::space::{ SpaceActivitiesArgs, SpaceDiskUsageArgs, SpaceImageArgs, SpaceLicenceArgs, - SpaceNotificationArgs, SpaceShowArgs, SpaceUpdateNotificationArgs, + SpaceNotificationArgs, SpaceShowArgs, SpaceUpdateNotificationArgs, SpaceUploadAttachmentArgs, }; use cmd::star::{StarAddArgs, StarDeleteArgs}; use cmd::team::{ @@ -343,6 +343,15 @@ enum SpaceCommands { #[arg(long, short = 'o')] output: Option, }, + /// Upload a file as a space attachment + UploadAttachment { + /// File to upload + #[arg(value_name = "FILE")] + file: std::path::PathBuf, + /// Output as JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -3725,6 +3734,13 @@ fn run() -> Result<()> { Some(SpaceCommands::Image { output }) => { cmd::space::image(&SpaceImageArgs::new(output)) } + Some(SpaceCommands::UploadAttachment { + file, + json: sub_json, + }) => cmd::space::upload_attachment(&SpaceUploadAttachmentArgs::new( + file, + json || sub_json, + )), }, } } diff --git a/website/docs/commands.md b/website/docs/commands.md index d5aaeca..e2b7f02 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -168,6 +168,24 @@ Example output: Saved: space_image.png (1234 bytes) ``` +## `bl space upload-attachment` + +Upload a file as a space attachment. Returns the uploaded attachment metadata. + +```bash +bl space upload-attachment ./report.pdf +bl space upload-attachment ./image.png --json +``` + +Example output: + +```text +ID: 1 +Name: report.pdf +Size: 204800 bytes +Created: 2024-01-01T00:00:00Z +``` + ## `bl project list` List all projects you have access to. @@ -2234,7 +2252,7 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | `bl space licence` | `GET /api/v2/space/licence` | ✅ Implemented | | `bl space update-notification` | `PUT /api/v2/space/notification` | ✅ Implemented | | `bl space image` | `GET /api/v2/space/image` | ✅ Implemented | -| — | `POST /api/v2/space/attachment` | Planned | +| `bl space upload-attachment ` | `POST /api/v2/space/attachment` | ✅ Implemented | ### Projects 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 380968e..6c7f3e2 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -167,6 +167,24 @@ bl space image --output my_icon.png Saved: space_image.png (1234 bytes) ``` +## `bl space upload-attachment` + +ファイルをスペースの添付ファイルとしてアップロードします。アップロードされた添付ファイルのメタデータを返します。 + +```bash +bl space upload-attachment ./report.pdf +bl space upload-attachment ./image.png --json +``` + +出力例: + +```text +ID: 1 +Name: report.pdf +Size: 204800 bytes +Created: 2024-01-01T00:00:00Z +``` + ## `bl project list` アクセス可能なプロジェクトを一覧表示します。 @@ -2236,7 +2254,7 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `bl space licence` | `GET /api/v2/space/licence` | ✅ 実装済み | | `bl space update-notification` | `PUT /api/v2/space/notification` | ✅ 実装済み | | `bl space image` | `GET /api/v2/space/image` | ✅ 実装済み | -| — | `POST /api/v2/space/attachment` | 計画中 | +| `bl space upload-attachment ` | `POST /api/v2/space/attachment` | ✅ 実装済み | ### Projects From 4543aabfc5642633ccfed76ce9afcaff303ba093 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 23:36:59 +0900 Subject: [PATCH 3/3] refactor: use factory closure and streaming for post_multipart Addresses review comment: post_multipart bypasses execute() retry on 401 and reads entire file into memory --- src/api/mod.rs | 30 +++++++++++++++--------------- src/api/space.rs | 16 +++++++++++----- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 91834c8..363b907 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1761,22 +1761,22 @@ impl BacklogClient { /// Post a multipart/form-data request. /// - /// Note: unlike [`Self::execute`], this method does not retry on 401 because - /// `reqwest::blocking::multipart::Form` is consumed on the first send and cannot be - /// reconstructed for a retry. - pub fn post_multipart( - &self, - path: &str, - form: reqwest::blocking::multipart::Form, - ) -> Result { + /// The provided factory closure is called on each attempt to build a fresh + /// `reqwest::blocking::multipart::Form`, allowing this method to reuse the + /// standard [`Self::execute`] retry logic (including retry-on-401). + pub fn post_multipart(&self, path: &str, form_factory: F) -> Result + where + F: Fn() -> reqwest::blocking::multipart::Form, + { let url = format!("{}{}", self.base_url, path); - crate::logger::verbose(&format!("→ POST {url}")); - let response = self - .apply_auth(self.client.post(&url)) - .multipart(form) - .send() - .with_context(|| format!("Failed to POST {url}"))?; - self.finish_response(response) + self.execute(|| { + crate::logger::verbose(&format!("→ POST {url}")); + let form = form_factory(); + self.apply_auth(self.client.post(&url)) + .multipart(form) + .send() + .with_context(|| format!("Failed to POST {url}")) + }) } pub fn patch_form(&self, path: &str, params: &[(String, String)]) -> Result { diff --git a/src/api/space.rs b/src/api/space.rs index b5285c0..d9ee17e 100644 --- a/src/api/space.rs +++ b/src/api/space.rs @@ -55,11 +55,17 @@ impl BacklogClient { .and_then(|n| n.to_str()) .unwrap_or("attachment") .to_string(); - let bytes = std::fs::read(file_path) - .with_context(|| format!("Failed to read {}", file_path.display()))?; - let part = reqwest::blocking::multipart::Part::bytes(bytes).file_name(filename); - let form = reqwest::blocking::multipart::Form::new().part("file", part); - let value = self.post_multipart("/space/attachment", form)?; + let file_len = std::fs::metadata(file_path) + .with_context(|| format!("Failed to access {}", file_path.display()))? + .len(); + let file_path = file_path.to_path_buf(); + let value = self.post_multipart("/space/attachment", || { + let file = + std::fs::File::open(&file_path).expect("file open succeeded during pre-check"); + let part = reqwest::blocking::multipart::Part::reader_with_length(file, file_len) + .file_name(filename.clone()); + reqwest::blocking::multipart::Form::new().part("file", part) + })?; deserialize(value) } }