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
138 changes: 138 additions & 0 deletions src/api/git.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use std::collections::BTreeMap;

use anyhow::Result;
use serde::{Deserialize, Serialize};

use super::BacklogClient;

fn deserialize<T: serde::de::DeserializeOwned>(value: serde_json::Value) -> Result<T> {
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<String>,
pub name: String,
pub role_type: u8,
pub lang: Option<String>,
pub mail_address: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}

#[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<String>,
pub http_url: String,
pub ssh_url: String,
pub display_order: u64,
pub pushed_at: Option<String>,
pub created_user: GitUser,
pub created: String,
pub updated_user: GitUser,
pub updated: String,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}

impl BacklogClient {
pub fn get_git_repositories(&self, project_id_or_key: &str) -> Result<Vec<GitRepository>> {
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<GitRepository> {
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");
}
}
22 changes: 22 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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<Vec<GitRepository>> {
unimplemented!()
}
fn get_git_repository(
&self,
_project_id_or_key: &str,
_repo_id_or_name: &str,
) -> Result<GitRepository> {
unimplemented!()
}
}

impl BacklogApi for BacklogClient {
Expand Down Expand Up @@ -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<Vec<GitRepository>> {
self.get_git_repositories(project_id_or_key)
}
fn get_git_repository(
&self,
project_id_or_key: &str,
repo_id_or_name: &str,
) -> Result<GitRepository> {
self.get_git_repository(project_id_or_key, repo_id_or_name)
}
}

/// How the client authenticates with Backlog.
Expand Down
136 changes: 136 additions & 0 deletions src/cmd/git/list.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<GitRepository>>,
}

impl crate::api::BacklogApi for MockApi {
fn get_git_repositories(
&self,
_project_id_or_key: &str,
) -> anyhow::Result<Vec<GitRepository>> {
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"));
}
}
5 changes: 5 additions & 0 deletions src/cmd/git/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mod list;
mod show;

pub use list::{GitRepoListArgs, list};
pub use show::{GitRepoShowArgs, show};
Loading