Skip to content

Commit 89bbd1e

Browse files
authored
Merge pull request #123 from 23prime/feature/119-project-image
feat: implement bl project image (GET /api/v2/projects/{projectIdOrKey}/image)
2 parents 28bf878 + 85dab94 commit 89bbd1e

7 files changed

Lines changed: 186 additions & 3 deletions

File tree

src/api/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ pub trait BacklogApi {
115115
fn get_projects(&self) -> Result<Vec<Project>> {
116116
unimplemented!()
117117
}
118+
fn download_project_image(&self, _key: &str) -> Result<(Vec<u8>, String)> {
119+
unimplemented!()
120+
}
118121
fn get_project(&self, _key: &str) -> Result<Project> {
119122
unimplemented!()
120123
}
@@ -803,6 +806,10 @@ impl BacklogApi for BacklogClient {
803806
self.get_projects()
804807
}
805808

809+
fn download_project_image(&self, key: &str) -> Result<(Vec<u8>, String)> {
810+
self.download_project_image(key)
811+
}
812+
806813
fn get_project(&self, key: &str) -> Result<Project> {
807814
self.get_project(key)
808815
}

src/api/project.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,10 @@ impl BacklogClient {
604604
let value = self.delete_req(&format!("/projects/{key}/webhooks/{webhook_id}"))?;
605605
deserialize(value)
606606
}
607+
608+
pub fn download_project_image(&self, key: &str) -> Result<(Vec<u8>, String)> {
609+
self.download(&format!("/projects/{key}/image"))
610+
}
607611
}
608612

609613
#[cfg(test)]

src/cmd/project/image.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
use anstream::println;
2+
use anyhow::{Context, Result};
3+
use std::path::PathBuf;
4+
5+
use crate::api::{BacklogApi, BacklogClient};
6+
7+
pub struct ProjectImageArgs {
8+
key: String,
9+
output: Option<PathBuf>,
10+
}
11+
12+
impl ProjectImageArgs {
13+
pub fn new(key: String, output: Option<PathBuf>) -> Self {
14+
Self { key, output }
15+
}
16+
}
17+
18+
pub fn image(args: &ProjectImageArgs) -> Result<()> {
19+
let client = BacklogClient::from_config()?;
20+
image_with(args, &client)
21+
}
22+
23+
pub fn image_with(args: &ProjectImageArgs, api: &dyn BacklogApi) -> Result<()> {
24+
let (bytes, filename) = api.download_project_image(&args.key)?;
25+
let path = args
26+
.output
27+
.clone()
28+
.unwrap_or_else(|| default_output_path(&filename));
29+
std::fs::write(&path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?;
30+
println!("Saved: {} ({} bytes)", path.display(), bytes.len());
31+
Ok(())
32+
}
33+
34+
fn default_output_path(filename: &str) -> PathBuf {
35+
let normalized = filename.trim();
36+
let base = std::path::Path::new(normalized)
37+
.file_name()
38+
.unwrap_or(std::ffi::OsStr::new(""));
39+
let base_lower = base.to_string_lossy().to_ascii_lowercase();
40+
let is_generic_attachment = base_lower == "attachment" || base_lower.starts_with("attachment.");
41+
42+
if base.is_empty() || is_generic_attachment {
43+
PathBuf::from("project_image")
44+
} else {
45+
PathBuf::from(base)
46+
}
47+
}
48+
49+
#[cfg(test)]
50+
mod tests {
51+
use super::*;
52+
use anyhow::anyhow;
53+
use tempfile::tempdir;
54+
55+
struct MockApi {
56+
result: Option<(Vec<u8>, String)>,
57+
}
58+
59+
impl crate::api::BacklogApi for MockApi {
60+
fn download_project_image(&self, _key: &str) -> anyhow::Result<(Vec<u8>, String)> {
61+
self.result
62+
.clone()
63+
.ok_or_else(|| anyhow!("download failed"))
64+
}
65+
}
66+
67+
fn args(output: Option<PathBuf>) -> ProjectImageArgs {
68+
ProjectImageArgs::new("TEST".to_string(), output)
69+
}
70+
71+
#[test]
72+
fn image_with_saves_file_to_specified_path() {
73+
let dir = tempdir().unwrap();
74+
let path = dir.path().join("out.png");
75+
let api = MockApi {
76+
result: Some((b"png-data".to_vec(), "project_image.png".to_string())),
77+
};
78+
assert!(image_with(&args(Some(path.clone())), &api).is_ok());
79+
assert_eq!(std::fs::read(&path).unwrap(), b"png-data");
80+
}
81+
82+
#[test]
83+
fn image_with_propagates_api_error() {
84+
let api = MockApi { result: None };
85+
let err = image_with(&args(None), &api).unwrap_err();
86+
assert!(err.to_string().contains("download failed"));
87+
}
88+
89+
#[test]
90+
fn default_output_path_uses_server_filename() {
91+
assert_eq!(
92+
default_output_path("project_image.png"),
93+
PathBuf::from("project_image.png")
94+
);
95+
}
96+
97+
#[test]
98+
fn default_output_path_falls_back_for_attachment() {
99+
assert_eq!(
100+
default_output_path("attachment"),
101+
PathBuf::from("project_image")
102+
);
103+
}
104+
105+
#[test]
106+
fn default_output_path_falls_back_for_attachment_with_extension() {
107+
assert_eq!(
108+
default_output_path("attachment.png"),
109+
PathBuf::from("project_image")
110+
);
111+
}
112+
113+
#[test]
114+
fn default_output_path_falls_back_for_path_with_attachment_basename() {
115+
assert_eq!(
116+
default_output_path("foo/attachment.png"),
117+
PathBuf::from("project_image")
118+
);
119+
}
120+
121+
#[test]
122+
fn default_output_path_falls_back_for_empty() {
123+
assert_eq!(default_output_path(""), PathBuf::from("project_image"));
124+
}
125+
}

src/cmd/project/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod create;
55
pub mod custom_field;
66
mod delete;
77
mod disk_usage;
8+
mod image;
89
pub mod issue_type;
910
mod list;
1011
mod show;
@@ -19,6 +20,7 @@ pub use activities::{ProjectActivitiesArgs, activities};
1920
pub use create::{ProjectCreateArgs, create};
2021
pub use delete::{ProjectDeleteArgs, delete};
2122
pub use disk_usage::{ProjectDiskUsageArgs, disk_usage};
23+
pub use image::{ProjectImageArgs, image};
2224
pub use list::{ProjectListArgs, list};
2325
pub use show::{ProjectShowArgs, show};
2426
pub use update::{ProjectUpdateArgs, update};

src/main.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ use cmd::project::webhook::{
6969
};
7070
use cmd::project::{
7171
ProjectActivitiesArgs, ProjectCreateArgs, ProjectDeleteArgs, ProjectDiskUsageArgs,
72-
ProjectListArgs, ProjectShowArgs, ProjectUpdateArgs,
72+
ProjectImageArgs, ProjectListArgs, ProjectShowArgs, ProjectUpdateArgs,
7373
};
7474
use cmd::rate_limit::RateLimitArgs;
7575
use cmd::resolution::ResolutionListArgs;
@@ -446,6 +446,14 @@ enum ProjectCommands {
446446
#[arg(long)]
447447
json: bool,
448448
},
449+
/// Download the project icon image
450+
Image {
451+
/// Project ID or key
452+
id_or_key: String,
453+
/// Output file path (default: server-provided filename, or "project_image" if none)
454+
#[arg(long, short = 'o')]
455+
output: Option<std::path::PathBuf>,
456+
},
449457
/// Manage project users
450458
User {
451459
#[command(subcommand)]
@@ -2519,6 +2527,9 @@ fn run() -> Result<()> {
25192527
ProjectCommands::Delete { id_or_key, json } => {
25202528
cmd::project::delete(&ProjectDeleteArgs::new(id_or_key, json))
25212529
}
2530+
ProjectCommands::Image { id_or_key, output } => {
2531+
cmd::project::image(&ProjectImageArgs::new(id_or_key, output))
2532+
}
25222533
ProjectCommands::User { action } => match action {
25232534
ProjectUserCommands::List { id_or_key, json } => {
25242535
cmd::project::user::list(&ProjectUserListArgs::new(id_or_key, json))

website/docs/commands.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,23 @@ Example output:
924924
Deleted: My Project (MYPRJ)
925925
```
926926

927+
## `bl project image`
928+
929+
Download the project icon image.
930+
931+
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).
932+
933+
```bash
934+
bl project image <id-or-key>
935+
bl project image <id-or-key> --output my_icon.png
936+
```
937+
938+
Example output:
939+
940+
```text
941+
Saved: project_image (1234 bytes)
942+
```
943+
927944
## `bl issue list`
928945

929946
List issues with optional filters.
@@ -2219,7 +2236,7 @@ The table below maps Backlog API v2 endpoints to `bl` commands.
22192236
| `bl project admin list <id-or-key>` | `GET /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented |
22202237
| `bl project admin add <id-or-key>` | `POST /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented |
22212238
| `bl project admin delete <id-or-key>` | `DELETE /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented |
2222-
| | `GET /api/v2/projects/{projectIdOrKey}/image` | Planned |
2239+
| `bl project image <id-or-key>` | `GET /api/v2/projects/{projectIdOrKey}/image` | ✅ Implemented |
22232240
| `bl project status list <id-or-key>` | `GET /api/v2/projects/{projectIdOrKey}/statuses` | ✅ Implemented |
22242241
| `bl project status add <id-or-key>` | `POST /api/v2/projects/{projectIdOrKey}/statuses` | ✅ Implemented |
22252242
| `bl project status update <id-or-key> --status-id <id>` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/{id}` | ✅ Implemented |

website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,23 @@ bl project delete <id-or-key> --json
924924
Deleted: My Project (MYPRJ)
925925
```
926926

927+
## `bl project image`
928+
929+
プロジェクトのアイコン画像をダウンロードします。
930+
931+
レスポンスはバイナリデータです。`--output` / `-o` で保存先を指定してください。省略した場合は、サーバーが返すファイル名(ファイル名がない場合や `attachment` などの汎用プレースホルダーの場合は `project_image`)を使ってカレントディレクトリに保存します。
932+
933+
```bash
934+
bl project image <id-or-key>
935+
bl project image <id-or-key> --output my_icon.png
936+
```
937+
938+
出力例:
939+
940+
```text
941+
Saved: project_image (1234 bytes)
942+
```
943+
927944
## `bl issue list`
928945

929946
オプションのフィルターで課題を一覧表示します。
@@ -2221,7 +2238,7 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。
22212238
| `bl project admin list <id-or-key>` | `GET /api/v2/projects/{projectIdOrKey}/administrators` | ✅ 実装済み |
22222239
| `bl project admin add <id-or-key>` | `POST /api/v2/projects/{projectIdOrKey}/administrators` | ✅ 実装済み |
22232240
| `bl project admin delete <id-or-key>` | `DELETE /api/v2/projects/{projectIdOrKey}/administrators` | ✅ 実装済み |
2224-
| | `GET /api/v2/projects/{projectIdOrKey}/image` | 計画中 |
2241+
| `bl project image <id-or-key>` | `GET /api/v2/projects/{projectIdOrKey}/image` | ✅ 実装済み |
22252242
| `bl project status list <id-or-key>` | `GET /api/v2/projects/{projectIdOrKey}/statuses` | ✅ 実装済み |
22262243
| `bl project status add <id-or-key>` | `POST /api/v2/projects/{projectIdOrKey}/statuses` | ✅ 実装済み |
22272244
| `bl project status update <id-or-key> --status-id <id>` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/{id}` | ✅ 実装済み |

0 commit comments

Comments
 (0)