diff --git a/src/api/git.rs b/src/api/git.rs new file mode 100644 index 0000000..42eee10 --- /dev/null +++ b/src/api/git.rs @@ -0,0 +1,138 @@ +use std::collections::BTreeMap; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use super::BacklogClient; + +fn deserialize(value: serde_json::Value) -> Result { + let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()); + serde_json::from_value(value).map_err(|e| { + anyhow::anyhow!( + "Failed to deserialize response: {}\nRaw JSON:\n{}", + e, + pretty + ) + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitUser { + pub id: u64, + pub user_id: Option, + pub name: String, + pub role_type: u8, + pub lang: Option, + pub mail_address: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitRepository { + pub id: u64, + pub project_id: u64, + pub name: String, + pub description: String, + pub hook_url: Option, + pub http_url: String, + pub ssh_url: String, + pub display_order: u64, + pub pushed_at: Option, + pub created_user: GitUser, + pub created: String, + pub updated_user: GitUser, + pub updated: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl BacklogClient { + pub fn get_git_repositories(&self, project_id_or_key: &str) -> Result> { + let value = self.get(&format!("/projects/{}/git/repositories", project_id_or_key))?; + deserialize(value) + } + + pub fn get_git_repository( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + ) -> Result { + let value = self.get(&format!( + "/projects/{}/git/repositories/{}", + project_id_or_key, repo_id_or_name + ))?; + deserialize(value) + } +} + +#[cfg(test)] +mod tests { + use httpmock::prelude::*; + use serde_json::json; + + const TEST_KEY: &str = "test-api-key"; + + fn git_user_json() -> serde_json::Value { + json!({ + "id": 1, + "userId": "john", + "name": "John Doe", + "roleType": 1, + "lang": "ja", + "mailAddress": "john@example.com" + }) + } + + fn git_repo_json() -> serde_json::Value { + json!({ + "id": 1, + "projectId": 10, + "name": "main", + "description": "My repository", + "hookUrl": null, + "httpUrl": "https://example.backlog.com/git/TEST/main.git", + "sshUrl": "git@example.backlog.com:/TEST/main.git", + "displayOrder": 0, + "pushedAt": null, + "createdUser": git_user_json(), + "created": "2024-01-01T00:00:00Z", + "updatedUser": git_user_json(), + "updated": "2024-01-01T00:00:00Z" + }) + } + + #[test] + fn get_git_repositories_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/projects/TEST/git/repositories") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([git_repo_json()])); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let repos = client.get_git_repositories("TEST").unwrap(); + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].name, "main"); + assert_eq!(repos[0].hook_url, None); + assert_eq!(repos[0].pushed_at, None); + } + + #[test] + fn get_git_repository_returns_single() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/projects/TEST/git/repositories/main") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(git_repo_json()); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let repo = client.get_git_repository("TEST", "main").unwrap(); + assert_eq!(repo.id, 1); + assert_eq!(repo.name, "main"); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 3509b4d..bbc6b89 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,6 +9,7 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); pub mod activity; pub mod disk_usage; pub mod document; +pub mod git; pub mod issue; pub mod licence; pub mod notification; @@ -27,6 +28,7 @@ pub mod wiki; use activity::Activity; use disk_usage::DiskUsage; use document::{Document, DocumentTree}; +use git::GitRepository; use issue::{ Issue, IssueAttachment, IssueComment, IssueCommentCount, IssueCommentNotification, IssueCount, IssueParticipant, IssueSharedFile, @@ -612,6 +614,16 @@ pub trait BacklogApi { fn read_watching(&self, _watching_id: u64) -> Result<()> { unimplemented!() } + fn get_git_repositories(&self, _project_id_or_key: &str) -> Result> { + unimplemented!() + } + fn get_git_repository( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + ) -> Result { + unimplemented!() + } } impl BacklogApi for BacklogClient { @@ -1273,6 +1285,16 @@ impl BacklogApi for BacklogClient { fn read_watching(&self, watching_id: u64) -> Result<()> { self.read_watching(watching_id) } + fn get_git_repositories(&self, project_id_or_key: &str) -> Result> { + self.get_git_repositories(project_id_or_key) + } + fn get_git_repository( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + ) -> Result { + self.get_git_repository(project_id_or_key, repo_id_or_name) + } } /// How the client authenticates with Backlog. diff --git a/src/cmd/git/list.rs b/src/cmd/git/list.rs new file mode 100644 index 0000000..7731388 --- /dev/null +++ b/src/cmd/git/list.rs @@ -0,0 +1,136 @@ +use anstream::println; +use anyhow::{Context, Result}; +use owo_colors::OwoColorize; + +use crate::api::{BacklogApi, BacklogClient, git::GitRepository}; + +pub struct GitRepoListArgs { + project_id_or_key: String, + json: bool, +} + +impl GitRepoListArgs { + pub fn new(project_id_or_key: String, json: bool) -> Self { + Self { + project_id_or_key, + json, + } + } +} + +pub fn list(args: &GitRepoListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &GitRepoListArgs, api: &dyn BacklogApi) -> Result<()> { + let repos = api.get_git_repositories(&args.project_id_or_key)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&repos).context("Failed to serialize JSON")? + ); + } else { + for repo in &repos { + println!("{}", format_repo_row(repo)); + } + } + Ok(()) +} + +pub fn format_repo_row(repo: &GitRepository) -> String { + format!("{}", repo.name.cyan().bold()) +} + +#[cfg(test)] +pub(crate) mod tests_helper { + use std::collections::BTreeMap; + + use crate::api::git::{GitRepository, GitUser}; + + pub fn sample_git_user() -> GitUser { + GitUser { + id: 1, + user_id: Some("john".to_string()), + name: "John Doe".to_string(), + role_type: 1, + lang: None, + mail_address: None, + extra: BTreeMap::new(), + } + } + + pub fn sample_git_repo() -> GitRepository { + GitRepository { + id: 1, + project_id: 10, + name: "main".to_string(), + description: "My repository".to_string(), + hook_url: None, + http_url: "https://example.backlog.com/git/TEST/main.git".to_string(), + ssh_url: "git@example.backlog.com:/TEST/main.git".to_string(), + display_order: 0, + pushed_at: None, + created_user: sample_git_user(), + created: "2024-01-01T00:00:00Z".to_string(), + updated_user: sample_git_user(), + updated: "2024-01-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::git::GitRepository; + use anyhow::anyhow; + use tests_helper::sample_git_repo; + + struct MockApi { + repos: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_git_repositories( + &self, + _project_id_or_key: &str, + ) -> anyhow::Result> { + self.repos.clone().ok_or_else(|| anyhow!("no repos")) + } + } + + fn args(json: bool) -> GitRepoListArgs { + GitRepoListArgs::new("TEST".to_string(), json) + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + repos: Some(vec![sample_git_repo()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + repos: Some(vec![sample_git_repo()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { repos: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no repos")); + } + + #[test] + fn format_repo_row_contains_name() { + let repo = sample_git_repo(); + let row = format_repo_row(&repo); + assert!(row.contains("main")); + } +} diff --git a/src/cmd/git/mod.rs b/src/cmd/git/mod.rs new file mode 100644 index 0000000..19319fc --- /dev/null +++ b/src/cmd/git/mod.rs @@ -0,0 +1,5 @@ +mod list; +mod show; + +pub use list::{GitRepoListArgs, list}; +pub use show::{GitRepoShowArgs, show}; diff --git a/src/cmd/git/show.rs b/src/cmd/git/show.rs new file mode 100644 index 0000000..484a8bc --- /dev/null +++ b/src/cmd/git/show.rs @@ -0,0 +1,102 @@ +use anstream::println; +use anyhow::{Context, Result}; +use owo_colors::OwoColorize; + +use crate::api::{BacklogApi, BacklogClient, git::GitRepository}; + +pub struct GitRepoShowArgs { + project_id_or_key: String, + repo_id_or_name: String, + json: bool, +} + +impl GitRepoShowArgs { + pub fn new(project_id_or_key: String, repo_id_or_name: String, json: bool) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + json, + } + } +} + +pub fn show(args: &GitRepoShowArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + show_with(args, &client) +} + +pub fn show_with(args: &GitRepoShowArgs, api: &dyn BacklogApi) -> Result<()> { + let repo = api.get_git_repository(&args.project_id_or_key, &args.repo_id_or_name)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&repo).context("Failed to serialize JSON")? + ); + } else { + print_repo(&repo); + } + Ok(()) +} + +pub fn print_repo(repo: &GitRepository) { + println!("{}", repo.name.cyan().bold()); + if !repo.description.is_empty() { + println!(" Description: {}", repo.description); + } + println!(" HTTP URL: {}", repo.http_url); + println!(" SSH URL: {}", repo.ssh_url); + if let Some(pushed_at) = &repo.pushed_at { + println!(" Pushed at: {}", pushed_at); + } + println!(" Created: {}", repo.created); + println!(" Updated: {}", repo.updated); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::git::GitRepository; + use crate::cmd::git::list::tests_helper::sample_git_repo; + use anyhow::anyhow; + + struct MockApi { + repo: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn get_git_repository( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + ) -> anyhow::Result { + self.repo.clone().ok_or_else(|| anyhow!("no repo")) + } + } + + fn args(json: bool) -> GitRepoShowArgs { + GitRepoShowArgs::new("TEST".to_string(), "main".to_string(), json) + } + + #[test] + fn show_with_text_output_succeeds() { + let api = MockApi { + repo: Some(sample_git_repo()), + }; + assert!(show_with(&args(false), &api).is_ok()); + } + + #[test] + fn show_with_json_output_succeeds() { + let api = MockApi { + repo: Some(sample_git_repo()), + }; + assert!(show_with(&args(true), &api).is_ok()); + } + + #[test] + fn show_with_propagates_api_error() { + let api = MockApi { repo: None }; + let err = show_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no repo")); + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 3db5b9a..b9cb538 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod banner; pub mod document; +pub mod git; pub mod issue; pub mod notification; pub mod priority; diff --git a/src/main.rs b/src/main.rs index 51356e2..5c99153 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use cmd::document::attachment::DocumentAttachmentGetArgs; use cmd::document::{ DocumentCreateArgs, DocumentDeleteArgs, DocumentListArgs, DocumentShowArgs, DocumentTreeArgs, }; +use cmd::git::{GitRepoListArgs, GitRepoShowArgs}; use cmd::issue::attachment::{ IssueAttachmentDeleteArgs, IssueAttachmentGetArgs, IssueAttachmentListArgs, }; @@ -196,6 +197,11 @@ enum Commands { #[command(subcommand)] action: StarCommands, }, + /// Manage Git repositories + Git { + #[command(subcommand)] + action: GitCommands, + }, } #[derive(clap::ValueEnum, Clone)] @@ -2060,6 +2066,37 @@ enum StarCommands { }, } +#[derive(Subcommand)] +enum GitCommands { + /// List Git repositories in a project + Repo { + #[command(subcommand)] + action: GitRepoCommands, + }, +} + +#[derive(Subcommand)] +enum GitRepoCommands { + /// List Git repositories in a project + List { + /// Project ID or key + project_id_or_key: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Show a Git repository + Show { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand)] enum AuthCommands { /// Login with your API key @@ -3168,6 +3205,23 @@ fn run() -> Result<()> { )?), StarCommands::Delete { id } => cmd::star::delete(&StarDeleteArgs::new(id)), }, + Commands::Git { action } => match action { + GitCommands::Repo { action } => match action { + GitRepoCommands::List { + project_id_or_key, + json, + } => cmd::git::list(&GitRepoListArgs::new(project_id_or_key, json)), + GitRepoCommands::Show { + project_id_or_key, + repo_id_or_name, + json, + } => cmd::git::show(&GitRepoShowArgs::new( + project_id_or_key, + repo_id_or_name, + json, + )), + }, + }, Commands::Space { action, json } => match action { None => cmd::space::show(&SpaceShowArgs::new(json)), Some(SpaceCommands::Activities { diff --git a/website/docs/commands.md b/website/docs/commands.md index 9976352..1b053b2 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -1939,6 +1939,42 @@ Example output: [4] Cannot Reproduce ``` +## `bl git repo list` + +List Git repositories in a project. + +```bash +bl git repo list +bl git repo list --json +``` + +Example output: + +```text +main +develop +``` + +## `bl git repo show` + +Show details of a Git repository. + +```bash +bl git repo show +bl git repo show --json +``` + +Example output: + +```text +main + Description: Main repository + HTTP URL: https://example.backlog.com/git/TEST/main.git + SSH URL: git@example.backlog.com:/TEST/main.git + Created: 2024-01-01T00:00:00Z + Updated: 2024-01-02T00:00:00Z +``` + ## `bl rate-limit` Show the current API rate limit status. @@ -2126,8 +2162,8 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | Command | API endpoint | Status | | --- | --- | --- | -| `bl git repo list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories` | Planned | -| `bl git repo show ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}` | Planned | +| `bl git repo list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories` | ✅ Implemented | +| `bl git repo show ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}` | ✅ Implemented | ### Users 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 dbf9ec3..9dfd342 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -1943,6 +1943,42 @@ bl resolution list [--json] [4] 再現しない ``` +## `bl git repo list` + +プロジェクトの Git リポジトリ一覧を表示します。 + +```bash +bl git repo list +bl git repo list --json +``` + +出力例: + +```text +main +develop +``` + +## `bl git repo show` + +Git リポジトリの詳細を表示します。 + +```bash +bl git repo show +bl git repo show --json +``` + +出力例: + +```text +main + Description: メインリポジトリ + HTTP URL: https://example.backlog.com/git/TEST/main.git + SSH URL: git@example.backlog.com:/TEST/main.git + Created: 2024-01-01T00:00:00Z + Updated: 2024-01-02T00:00:00Z +``` + ## `bl rate-limit` API レート制限の現在の状況を表示します。 @@ -2130,8 +2166,8 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | コマンド | API エンドポイント | 状態 | | --- | --- | --- | -| `bl git repo list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories` | 計画中 | -| `bl git repo show ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}` | 計画中 | +| `bl git repo list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories` | ✅ 実装済み | +| `bl git repo show ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}` | ✅ 実装済み | ### Users