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 @@ -115,6 +115,9 @@ pub trait BacklogApi {
fn get_projects(&self) -> Result<Vec<Project>> {
unimplemented!()
}
fn download_project_image(&self, _key: &str) -> Result<(Vec<u8>, String)> {
unimplemented!()
}
fn get_project(&self, _key: &str) -> Result<Project> {
unimplemented!()
}
Expand Down Expand Up @@ -803,6 +806,10 @@ impl BacklogApi for BacklogClient {
self.get_projects()
}

fn download_project_image(&self, key: &str) -> Result<(Vec<u8>, String)> {
self.download_project_image(key)
}

fn get_project(&self, key: &str) -> Result<Project> {
self.get_project(key)
}
Expand Down
4 changes: 4 additions & 0 deletions src/api/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>, String)> {
self.download(&format!("/projects/{key}/image"))
}
}

#[cfg(test)]
Expand Down
125 changes: 125 additions & 0 deletions src/cmd/project/image.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 ProjectImageArgs {
key: String,
output: Option<PathBuf>,
}

impl ProjectImageArgs {
pub fn new(key: String, output: Option<PathBuf>) -> 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 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("project_image")
} 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_project_image(&self, _key: &str) -> anyhow::Result<(Vec<u8>, String)> {
self.result
.clone()
.ok_or_else(|| anyhow!("download failed"))
}
}

fn args(output: Option<PathBuf>) -> 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_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_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"));
}
}
2 changes: 2 additions & 0 deletions src/cmd/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
Expand Down
13 changes: 12 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<std::path::PathBuf>,
},
/// Manage project users
User {
#[command(subcommand)]
Expand Down Expand Up @@ -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))
Expand Down
19 changes: 18 additions & 1 deletion website/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` when the filename is missing or a generic `attachment` placeholder).

```bash
bl project image <id-or-key>
bl project image <id-or-key> --output my_icon.png
```

Example output:

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

## `bl issue list`

List issues with optional filters.
Expand Down Expand Up @@ -2219,7 +2236,7 @@ The table below maps Backlog API v2 endpoints to `bl` commands.
| `bl project admin list <id-or-key>` | `GET /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented |
| `bl project admin add <id-or-key>` | `POST /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented |
| `bl project admin delete <id-or-key>` | `DELETE /api/v2/projects/{projectIdOrKey}/administrators` | ✅ Implemented |
| | `GET /api/v2/projects/{projectIdOrKey}/image` | Planned |
| `bl project image <id-or-key>` | `GET /api/v2/projects/{projectIdOrKey}/image` | ✅ Implemented |
| `bl project status list <id-or-key>` | `GET /api/v2/projects/{projectIdOrKey}/statuses` | ✅ Implemented |
| `bl project status add <id-or-key>` | `POST /api/v2/projects/{projectIdOrKey}/statuses` | ✅ Implemented |
| `bl project status update <id-or-key> --status-id <id>` | `PATCH /api/v2/projects/{projectIdOrKey}/statuses/{id}` | ✅ Implemented |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,23 @@ bl project delete <id-or-key> --json
Deleted: My Project (MYPRJ)
```

## `bl project image`

プロジェクトのアイコン画像をダウンロードします。

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

```bash
bl project image <id-or-key>
bl project image <id-or-key> --output my_icon.png
```

出力例:

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

## `bl issue list`

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