From c5790aab008048a6321f360fcabfe4f78a55f522 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 23:03:24 +0900 Subject: [PATCH 1/2] feat: implement bl project image (GET /api/v2/projects/{projectIdOrKey}/image) --- src/api/mod.rs | 7 + src/api/project.rs | 4 + src/cmd/project/image.rs | 142 ++++++++++++++++++ src/cmd/project/mod.rs | 2 + src/main.rs | 13 +- website/docs/commands.md | 19 ++- .../current/commands.md | 19 ++- 7 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 src/cmd/project/image.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 12f3172..cb3d5ee 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -115,6 +115,9 @@ pub trait BacklogApi { fn get_projects(&self) -> Result> { unimplemented!() } + fn download_project_image(&self, _key: &str) -> Result<(Vec, String)> { + unimplemented!() + } fn get_project(&self, _key: &str) -> Result { unimplemented!() } @@ -803,6 +806,10 @@ impl BacklogApi for BacklogClient { self.get_projects() } + fn download_project_image(&self, key: &str) -> Result<(Vec, String)> { + self.download_project_image(key) + } + fn get_project(&self, key: &str) -> Result { self.get_project(key) } diff --git a/src/api/project.rs b/src/api/project.rs index 90e9f56..01990e6 100644 --- a/src/api/project.rs +++ b/src/api/project.rs @@ -604,6 +604,10 @@ impl BacklogClient { let value = self.delete_req(&format!("/projects/{key}/webhooks/{webhook_id}"))?; deserialize(value) } + + pub fn download_project_image(&self, key: &str) -> Result<(Vec, String)> { + self.download(&format!("/projects/{key}/image")) + } } #[cfg(test)] diff --git a/src/cmd/project/image.rs b/src/cmd/project/image.rs new file mode 100644 index 0000000..5817cf6 --- /dev/null +++ b/src/cmd/project/image.rs @@ -0,0 +1,142 @@ +use anstream::println; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct ProjectImageArgs { + key: String, + output: Option, +} + +impl ProjectImageArgs { + pub fn new(key: String, output: Option) -> Self { + Self { key, output } + } +} + +pub fn image(args: &ProjectImageArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + image_with(args, &client) +} + +pub fn image_with(args: &ProjectImageArgs, api: &dyn BacklogApi) -> Result<()> { + let (bytes, filename) = api.download_project_image(&args.key)?; + 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 lower = normalized.to_ascii_lowercase(); + let is_generic_attachment = lower == "attachment" || lower.starts_with("attachment."); + + let effective = if normalized.is_empty() || is_generic_attachment { + "project_image" + } else { + normalized + }; + let base = std::path::Path::new(effective) + .file_name() + .unwrap_or(std::ffi::OsStr::new("project_image")); + 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_project_image(&self, _key: &str) -> anyhow::Result<(Vec, String)> { + self.result + .clone() + .ok_or_else(|| anyhow!("download failed")) + } + } + + fn args(output: Option) -> ProjectImageArgs { + ProjectImageArgs::new("TEST".to_string(), output) + } + + #[test] + fn image_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(), "project_image.png".to_string())), + }; + assert!(image_with(&args(Some(path.clone())), &api).is_ok()); + assert_eq!(std::fs::read(&path).unwrap(), b"png-data"); + } + + #[test] + fn image_with_saves_file_to_default_filename() { + let dir = tempdir().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + + struct DirGuard(std::path::PathBuf); + impl Drop for DirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + let _guard = DirGuard(original_dir); + + let api = MockApi { + result: Some((b"png-data".to_vec(), "project_image.png".to_string())), + }; + assert!(image_with(&args(None), &api).is_ok()); + assert_eq!( + std::fs::read(dir.path().join("project_image.png")).unwrap(), + b"png-data" + ); + } + + #[test] + fn image_with_propagates_api_error() { + let api = MockApi { result: None }; + let err = image_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("project_image.png"), + PathBuf::from("project_image.png") + ); + } + + #[test] + fn default_output_path_falls_back_for_attachment() { + assert_eq!( + default_output_path("attachment"), + PathBuf::from("project_image") + ); + } + + #[test] + fn default_output_path_falls_back_for_attachment_with_extension() { + assert_eq!( + default_output_path("attachment.png"), + PathBuf::from("project_image") + ); + } + + #[test] + fn default_output_path_falls_back_for_empty() { + assert_eq!(default_output_path(""), PathBuf::from("project_image")); + } +} diff --git a/src/cmd/project/mod.rs b/src/cmd/project/mod.rs index b9351ca..70c76cb 100644 --- a/src/cmd/project/mod.rs +++ b/src/cmd/project/mod.rs @@ -5,6 +5,7 @@ mod create; pub mod custom_field; mod delete; mod disk_usage; +mod image; pub mod issue_type; mod list; mod show; @@ -19,6 +20,7 @@ pub use activities::{ProjectActivitiesArgs, activities}; pub use create::{ProjectCreateArgs, create}; pub use delete::{ProjectDeleteArgs, delete}; pub use disk_usage::{ProjectDiskUsageArgs, disk_usage}; +pub use image::{ProjectImageArgs, image}; pub use list::{ProjectListArgs, list}; pub use show::{ProjectShowArgs, show}; pub use update::{ProjectUpdateArgs, update}; diff --git a/src/main.rs b/src/main.rs index 2a748a8..982f9a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,7 +69,7 @@ use cmd::project::webhook::{ }; use cmd::project::{ ProjectActivitiesArgs, ProjectCreateArgs, ProjectDeleteArgs, ProjectDiskUsageArgs, - ProjectListArgs, ProjectShowArgs, ProjectUpdateArgs, + ProjectImageArgs, ProjectListArgs, ProjectShowArgs, ProjectUpdateArgs, }; use cmd::rate_limit::RateLimitArgs; use cmd::resolution::ResolutionListArgs; @@ -446,6 +446,14 @@ enum ProjectCommands { #[arg(long)] json: bool, }, + /// Download the project icon image + Image { + /// Project ID or key + id_or_key: String, + /// Output file path (default: server-provided filename, or "project_image" if none) + #[arg(long, short = 'o')] + output: Option, + }, /// Manage project users User { #[command(subcommand)] @@ -2519,6 +2527,9 @@ fn run() -> Result<()> { ProjectCommands::Delete { id_or_key, json } => { cmd::project::delete(&ProjectDeleteArgs::new(id_or_key, json)) } + ProjectCommands::Image { id_or_key, output } => { + cmd::project::image(&ProjectImageArgs::new(id_or_key, output)) + } ProjectCommands::User { action } => match action { ProjectUserCommands::List { id_or_key, json } => { cmd::project::user::list(&ProjectUserListArgs::new(id_or_key, json)) diff --git a/website/docs/commands.md b/website/docs/commands.md index 22f6aaf..e3694bb 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -924,6 +924,23 @@ Example output: Deleted: My Project (MYPRJ) ``` +## `bl project image` + +Download the project 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 `project_image` if none is provided). + +```bash +bl project image +bl project image --output my_icon.png +``` + +Example output: + +```text +Saved: project_image.png (1234 bytes) +``` + ## `bl issue list` List issues with optional filters. @@ -2219,7 +2236,7 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | `bl project admin list ` | `GET /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented | | `bl project admin add ` | `POST /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented | | `bl project admin delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented | -| — | `GET /api/v2/projects/{projectIdOrKey}/image` | Planned | +| `bl project image ` | `GET /api/v2/projects/{projectIdOrKey}/image` | ✅ Implemented | | `bl project status list ` | `GET /api/v2/projects/{projectIdOrKey}/statuses` | ✅ Implemented | | `bl project status add ` | `POST /api/v2/projects/{projectIdOrKey}/statuses` | ✅ Implemented | | `bl project status update --status-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/{id}` | ✅ Implemented | 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 3d51d2f..caa987a 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -924,6 +924,23 @@ bl project delete --json Deleted: My Project (MYPRJ) ``` +## `bl project image` + +プロジェクトのアイコン画像をダウンロードします。 + +レスポンスはバイナリデータです。`--output` / `-o` で保存先を指定してください。省略した場合は、サーバーが返すファイル名(取得できない場合は `project_image`)を使ってカレントディレクトリに保存します。 + +```bash +bl project image +bl project image --output my_icon.png +``` + +出力例: + +```text +Saved: project_image.png (1234 bytes) +``` + ## `bl issue list` オプションのフィルターで課題を一覧表示します。 @@ -2221,7 +2238,7 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `bl project admin list ` | `GET /api/v2/projects/{projectIdOrKey}/administrators` | ✅ 実装済み | | `bl project admin add ` | `POST /api/v2/projects/{projectIdOrKey}/administrators` | ✅ 実装済み | | `bl project admin delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/administrators` | ✅ 実装済み | -| — | `GET /api/v2/projects/{projectIdOrKey}/image` | 計画中 | +| `bl project image ` | `GET /api/v2/projects/{projectIdOrKey}/image` | ✅ 実装済み | | `bl project status list ` | `GET /api/v2/projects/{projectIdOrKey}/statuses` | ✅ 実装済み | | `bl project status add ` | `POST /api/v2/projects/{projectIdOrKey}/statuses` | ✅ 実装済み | | `bl project status update --status-id ` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/{id}` | ✅ 実装済み | From 85dab94c5e20c72171cc47e89c53435bc5c54ec1 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 23:12:16 +0900 Subject: [PATCH 2/2] fix: apply attachment check on basename, remove set_current_dir test, align docs Addresses review comments: basename-first attachment detection, CWD-mutation test removal, and doc clarification for attachment fallback --- src/cmd/project/image.rs | 51 +++++++------------ website/docs/commands.md | 4 +- .../current/commands.md | 4 +- 3 files changed, 21 insertions(+), 38 deletions(-) diff --git a/src/cmd/project/image.rs b/src/cmd/project/image.rs index 5817cf6..6a7c9e6 100644 --- a/src/cmd/project/image.rs +++ b/src/cmd/project/image.rs @@ -33,18 +33,17 @@ pub fn image_with(args: &ProjectImageArgs, api: &dyn BacklogApi) -> Result<()> { fn default_output_path(filename: &str) -> PathBuf { let normalized = filename.trim(); - let lower = normalized.to_ascii_lowercase(); - let is_generic_attachment = lower == "attachment" || lower.starts_with("attachment."); + 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."); - let effective = if normalized.is_empty() || is_generic_attachment { - "project_image" + if base.is_empty() || is_generic_attachment { + PathBuf::from("project_image") } else { - normalized - }; - let base = std::path::Path::new(effective) - .file_name() - .unwrap_or(std::ffi::OsStr::new("project_image")); - PathBuf::from(base) + PathBuf::from(base) + } } #[cfg(test)] @@ -80,30 +79,6 @@ mod tests { assert_eq!(std::fs::read(&path).unwrap(), b"png-data"); } - #[test] - fn image_with_saves_file_to_default_filename() { - let dir = tempdir().unwrap(); - let original_dir = std::env::current_dir().unwrap(); - std::env::set_current_dir(&dir).unwrap(); - - struct DirGuard(std::path::PathBuf); - impl Drop for DirGuard { - fn drop(&mut self) { - let _ = std::env::set_current_dir(&self.0); - } - } - let _guard = DirGuard(original_dir); - - let api = MockApi { - result: Some((b"png-data".to_vec(), "project_image.png".to_string())), - }; - assert!(image_with(&args(None), &api).is_ok()); - assert_eq!( - std::fs::read(dir.path().join("project_image.png")).unwrap(), - b"png-data" - ); - } - #[test] fn image_with_propagates_api_error() { let api = MockApi { result: None }; @@ -135,6 +110,14 @@ mod tests { ); } + #[test] + fn default_output_path_falls_back_for_path_with_attachment_basename() { + assert_eq!( + default_output_path("foo/attachment.png"), + PathBuf::from("project_image") + ); + } + #[test] fn default_output_path_falls_back_for_empty() { assert_eq!(default_output_path(""), PathBuf::from("project_image")); diff --git a/website/docs/commands.md b/website/docs/commands.md index e3694bb..a59c2d9 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -928,7 +928,7 @@ Deleted: My Project (MYPRJ) Download the project 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 `project_image` if none is provided). +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 `project_image` when the filename is missing or a generic `attachment` placeholder). ```bash bl project image @@ -938,7 +938,7 @@ bl project image --output my_icon.png Example output: ```text -Saved: project_image.png (1234 bytes) +Saved: project_image (1234 bytes) ``` ## `bl issue list` 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 caa987a..c4d5e74 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -928,7 +928,7 @@ Deleted: My Project (MYPRJ) プロジェクトのアイコン画像をダウンロードします。 -レスポンスはバイナリデータです。`--output` / `-o` で保存先を指定してください。省略した場合は、サーバーが返すファイル名(取得できない場合は `project_image`)を使ってカレントディレクトリに保存します。 +レスポンスはバイナリデータです。`--output` / `-o` で保存先を指定してください。省略した場合は、サーバーが返すファイル名(ファイル名がない場合や `attachment` などの汎用プレースホルダーの場合は `project_image`)を使ってカレントディレクトリに保存します。 ```bash bl project image @@ -938,7 +938,7 @@ bl project image --output my_icon.png 出力例: ```text -Saved: project_image.png (1234 bytes) +Saved: project_image (1234 bytes) ``` ## `bl issue list`